From 1049596bbe0685e137600d579d4d6ea2f071f0fa Mon Sep 17 00:00:00 2001 From: snackman Date: Mon, 4 May 2026 08:50:41 -0400 Subject: [PATCH 1/8] feat: GA4 analytics dashboard at /admin/analytics Co-Authored-By: Claude Opus 4.6 --- src/app/admin/analytics/page.tsx | 596 +++++++++++++++++++++++++++ src/app/api/admin/analytics/route.ts | 232 +++++++++++ 2 files changed, 828 insertions(+) create mode 100644 src/app/admin/analytics/page.tsx create mode 100644 src/app/api/admin/analytics/route.ts diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx new file mode 100644 index 00000000..8bc99101 --- /dev/null +++ b/src/app/admin/analytics/page.tsx @@ -0,0 +1,596 @@ +'use client'; + +import { useState, useEffect, useRef, useCallback } from 'react'; +import Script from 'next/script'; +import Link from 'next/link'; + +const SESSION_KEY = 'sheeets-admin-auth'; + +interface Audience { + displayName?: string; + description?: string; +} + +interface AnalyticsData { + totals: { + activeUsers: string; + sessions: string; + pageViews: string; + avgSessionDuration: string; + bounceRate: string; + }; + traffic: string[][]; + events: string[][]; + funnels: string[][]; + conversions: string[][]; + timeSeries: string[][]; + pages: string[][]; + devices: string[][]; + audiences: Audience[]; +} + +const FUNNEL_ORDER = [ + 'session_start', + 'onboarding_start', + 'onboarding_complete', + 'auth_prompt', + 'auth_success', + 'itinerary', + 'check_in', + 'submit_event_open', + 'submit_event_success', +]; + +function formatDuration(seconds: number): string { + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function formatPercent(rate: number): string { + return `${(rate * 100).toFixed(1)}%`; +} + +export default function AnalyticsPage() { + const [authed, setAuthed] = useState(false); + const [password, setPassword] = useState(''); + const [loading, setLoading] = useState(false); + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [chartReady, setChartReady] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); + + const timeSeriesCanvasRef = useRef(null); + const devicesCanvasRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const timeSeriesChartRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const devicesChartRef = useRef(null); + + useEffect(() => { + if (sessionStorage.getItem(SESSION_KEY) === 'true') { + setAuthed(true); + setPassword('trusttheplan'); + } + }, []); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/admin/analytics?password=trusttheplan'); + if (!res.ok) { + const err = await res.json(); + throw new Error(err.error || `HTTP ${res.status}`); + } + const json = await res.json(); + setData(json); + setLastUpdated(new Date()); + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (authed) { + fetchData(); + } + }, [authed, fetchData]); + + // Time series chart + useEffect(() => { + if (!chartReady || !data?.timeSeries || !timeSeriesCanvasRef.current) return; + + // Destroy previous chart + if (timeSeriesChartRef.current) { + timeSeriesChartRef.current.destroy(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Chart = (window as any).Chart; + if (!Chart) return; + + const labels = data.timeSeries.map((row) => { + const d = row[0]; + return `${d.slice(4, 6)}/${d.slice(6, 8)}`; + }); + const users = data.timeSeries.map((row) => parseInt(row[1], 10)); + const sessions = data.timeSeries.map((row) => parseInt(row[2], 10)); + + timeSeriesChartRef.current = new Chart(timeSeriesCanvasRef.current, { + type: 'line', + data: { + labels, + datasets: [ + { + label: 'Users', + data: users, + borderColor: '#3b82f6', + backgroundColor: 'rgba(59,130,246,0.1)', + fill: true, + tension: 0.3, + }, + { + label: 'Sessions', + data: sessions, + borderColor: '#10b981', + backgroundColor: 'rgba(16,185,129,0.1)', + fill: true, + tension: 0.3, + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { labels: { color: '#fff' } }, + }, + scales: { + x: { + grid: { color: 'rgba(255,255,255,0.1)' }, + ticks: { color: '#fff' }, + }, + y: { + grid: { color: 'rgba(255,255,255,0.1)' }, + ticks: { color: '#fff' }, + }, + }, + }, + }); + + return () => { + if (timeSeriesChartRef.current) { + timeSeriesChartRef.current.destroy(); + timeSeriesChartRef.current = null; + } + }; + }, [chartReady, data?.timeSeries]); + + // Devices chart + useEffect(() => { + if (!chartReady || !data?.devices || !devicesCanvasRef.current) return; + + if (devicesChartRef.current) { + devicesChartRef.current.destroy(); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const Chart = (window as any).Chart; + if (!Chart) return; + + const labels = data.devices.map((row) => row[0]); + const sessions = data.devices.map((row) => parseInt(row[1], 10)); + const colors = ['#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#8b5cf6']; + + devicesChartRef.current = new Chart(devicesCanvasRef.current, { + type: 'bar', + data: { + labels, + datasets: [ + { + label: 'Sessions', + data: sessions, + backgroundColor: colors.slice(0, labels.length), + }, + ], + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { display: false }, + }, + scales: { + x: { + grid: { color: 'rgba(255,255,255,0.1)' }, + ticks: { color: '#fff' }, + }, + y: { + grid: { color: 'rgba(255,255,255,0.1)' }, + ticks: { color: '#fff' }, + }, + }, + }, + }); + + return () => { + if (devicesChartRef.current) { + devicesChartRef.current.destroy(); + devicesChartRef.current = null; + } + }; + }, [chartReady, data?.devices]); + + function handleLogin(e: React.FormEvent) { + e.preventDefault(); + if (password === 'trusttheplan') { + setAuthed(true); + sessionStorage.setItem(SESSION_KEY, 'true'); + } + } + + // Login form + if (!authed) { + return ( +
+
+

+ Analytics Dashboard +

+ setPassword(e.target.value)} + placeholder="Password" + className="w-full px-3 py-2 rounded-lg bg-stone-800 border border-stone-600 text-white placeholder-stone-400 mb-3 focus:outline-none focus:ring-2 focus:ring-blue-500" + /> + +
+
+ ); + } + + // Loading state + if (loading && !data) { + return ( +
+
+
+

Loading analytics...

+
+
+ ); + } + + // Error state + if (error && !data) { + return ( +
+
+

{error}

+ +
+
+ ); + } + + if (!data) return null; + + // Build sorted funnel data + const funnelMap = new Map( + data.funnels.map((row) => [row[0], { count: row[1], users: row[2] }]) + ); + const sortedFunnel = FUNNEL_ORDER.filter((name) => funnelMap.has(name)).map( + (name) => ({ + name, + ...funnelMap.get(name)!, + }) + ); + const maxFunnelCount = Math.max( + ...sortedFunnel.map((f) => parseInt(f.count, 10)), + 1 + ); + + return ( +
+ - - - -

plan.wtf Analytics Dashboard

-

Generated ${escapeHtml(now)} — Last 30 days

- - -
-
-
${totals ? fmt(totals.sessions) : '--'}
-
Total Sessions
-
-
-
${totals ? fmt(totals.users) : '--'}
-
Total Users
-
-
-
${totals ? fmt(totals.newUsers) : '--'}
-
New Users
-
-
-
${totals ? fmtPct(totals.bounceRate) : '--'}
-
Bounce Rate
-
-
- - -
-
-

Sessions Over Time

-
-
-
-

Traffic Sources (Top 10)

-
-
-
- - -
- ${onboardingFunnelHtml} - ${authFunnelHtml} - ${engagementFunnelHtml} -
- - -
-
-

Top Events

- ${ - eventsTableRows - ? `
- - - ${eventsTableRows} -
Event NameCountUsers
-
` - : '

No data

' - } -
-
-

Top Pages

- ${ - pagesTableRows - ? `
- - - ${pagesTableRows} -
Page PathViews
-
` - : '

No data

' - } -
-
- - -
-
-

Conversions

-
-
-
-

Device Split

-
-
-
- - -
-
-

Audiences

- ${ - audiencesTableRows - ? ` - - ${audiencesTableRows} -
NameDescriptionMembership Duration
` - : '

No custom audiences found

' - } -
-
- - - -`; -} - -// ---------- Main ---------- - -async function main() { - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - console.log('Fetching GA4 data (all requests in parallel)...'); - - // Fetch all data in parallel with error resilience - const [ - totals, - traffic, - events, - onboardingFunnel, - authFunnel, - engagementFunnel, - conversions, - timeSeries, - pages, - devices, - audiences, - ] = await Promise.all([ - fetchTotals(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch totals:', e.message); - return null; - }), - fetchTraffic(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch traffic:', e.message); - return []; - }), - fetchEvents(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch events:', e.message); - return []; - }), - fetchFunnelData(accessToken, [ - 'session_start', - 'onboarding_start', - 'onboarding_step', - 'onboarding_complete', - ]).catch((e) => { - console.warn(' Warning: failed to fetch onboarding funnel:', e.message); - return []; - }), - fetchFunnelData(accessToken, ['auth_prompt', 'auth_success']).catch((e) => { - console.warn(' Warning: failed to fetch auth funnel:', e.message); - return []; - }), - fetchFunnelData(accessToken, [ - 'session_start', - 'event_click', - 'itinerary', - 'check_in', - ]).catch((e) => { - console.warn(' Warning: failed to fetch engagement funnel:', e.message); - return []; - }), - fetchConversions(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch conversions:', e.message); - return []; - }), - fetchTimeSeries(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch time series:', e.message); - return []; - }), - fetchPages(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch pages:', e.message); - return []; - }), - fetchDevices(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch devices:', e.message); - return []; - }), - fetchAudiences(accessToken).catch((e) => { - console.warn(' Warning: failed to fetch audiences:', e.message); - return []; - }), - ]); - - console.log('Data fetched successfully.\n'); - - console.log('Generating HTML dashboard...'); - const html = buildHtml({ - totals, - traffic, - events, - onboardingFunnel, - authFunnel, - engagementFunnel, - conversions, - timeSeries, - pages, - devices, - audiences, - }); - - const outPath = join(process.cwd(), 'ga4-dashboard.html'); - writeFileSync(outPath, html); - console.log(`Dashboard written to ${outPath}`); - - // Open in browser - try { - const platform = process.platform; - if (platform === 'win32') { - execSync(`start "" "${outPath}"`, { shell: true }); - } else if (platform === 'darwin') { - execSync(`open "${outPath}"`); - } else { - execSync(`xdg-open "${outPath}"`); - } - console.log('Opened in browser.'); - } catch { - console.log('Could not open browser automatically. Open the file manually.'); - } -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/scripts/ga4-looker-setup.md b/scripts/ga4-looker-setup.md deleted file mode 100644 index e7bba145..00000000 --- a/scripts/ga4-looker-setup.md +++ /dev/null @@ -1,50 +0,0 @@ -# Looker Studio Dashboard Setup for plan.wtf - -## Quick Start - -1. Open [Looker Studio](https://lookerstudio.google.com/) -2. Click **Create → Report** -3. Add data source: **Google Analytics** → select **Sheeets.xyz** account → **property 524531628** -4. Click **Add to Report** - -## Recommended Dashboard Pages - -### Page 1: Traffic Overview -- **Scorecard**: Sessions, Users, New Users, Bounce Rate (date range: last 30 days) -- **Time series chart**: Sessions over time (daily) -- **Table**: Sessions by Source/Medium (top 10) -- **Pie chart**: Sessions by Device Category -- **Bar chart**: Top 10 Landing Pages (dimension: pagePath) - -### Page 2: Conference Performance -- **Scorecard**: Total Page Views -- **Table**: Page views by `pagePath` filtered to `/{conference-slug}` pages -- **Bar chart**: Conference select events by `conference` custom dimension -- **Time series**: Conference page views over time - -### Page 3: User Engagement -- **Funnel chart**: session_start → onboarding_start → onboarding_complete → itinerary → check_in -- **Scorecard**: Onboarding completion rate (onboarding_complete / onboarding_start) -- **Scorecard**: Auth conversion rate (auth_success / auth_prompt) -- **Table**: Top events by eventCount -- **Bar chart**: Tag toggles by `tag` custom dimension - -### Page 4: Conversions & Monetization -- **Table**: Key conversion events (auth_success, itinerary, check_in, submit_event_success, ad_click, rsvp_confirm) -- **Time series**: Conversion events over time -- **Scorecard**: Total ad clicks -- **Table**: Ad clicks by `placement` custom dimension - -### Page 5: Audiences -- **Scorecard**: Power Users count -- **Scorecard**: Onboarding Dropoffs count -- **Scorecard**: Engaged Unauthenticated count -- **Table**: User properties breakdown (conference_slug, is_authenticated) - -## Custom Dimensions Available -All 19 custom dimensions registered in GA4 are available as dimensions in Looker Studio: -- view_mode, conference, tag, action, search_term, event_name, trigger, placement, step, modal, provider, visibility, category, emoji, test_id, variant_id -- User-scoped: conference_slug, has_itinerary, is_authenticated - -## Filters -Add a **date range control** and **conference dropdown** (using `conference` dimension) to every page. diff --git a/scripts/ga4-metrics.mjs b/scripts/ga4-metrics.mjs deleted file mode 100644 index b91a81a3..00000000 --- a/scripts/ga4-metrics.mjs +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node - -/** - * GA4 Custom Metrics Script - * - * Registers custom metrics in GA4 via the Admin API. - * Uses OAuth2 refresh token stored at ~/.claude/ga4-tokens.json. - * - * Usage: node scripts/ga4-metrics.mjs - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const PROPERTY_ID = '524531628'; -const TOKEN_PATH = join(homedir(), '.claude', 'ga4-tokens.json'); - -const ADMIN_BASE = `https://analyticsadmin.googleapis.com/v1beta/properties/${PROPERTY_ID}`; - -// ---------- Custom metrics to register ---------- - -const CUSTOM_METRICS = [ - { - parameterName: 'itinerary_count', - displayName: 'Itinerary Count', - measurementUnit: 'STANDARD', - scope: 'EVENT', - }, - { - parameterName: 'tag_count', - displayName: 'Tag Count', - measurementUnit: 'STANDARD', - scope: 'EVENT', - }, - { - parameterName: 'search_count', - displayName: 'Search Count', - measurementUnit: 'STANDARD', - scope: 'EVENT', - }, -]; - -// ---------- Token refresh ---------- - -async function refreshAccessToken() { - const tokens = JSON.parse(readFileSync(TOKEN_PATH, 'utf-8')); - - const clientId = process.env.GA4_CLIENT_ID || tokens.client_id; - const clientSecret = process.env.GA4_CLIENT_SECRET || tokens.client_secret; - - if (!clientId || !clientSecret) { - throw new Error( - 'Missing client credentials. Set GA4_CLIENT_ID/GA4_CLIENT_SECRET env vars ' + - 'or add client_id/client_secret to ' + TOKEN_PATH - ); - } - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: tokens.refresh_token, - grant_type: 'refresh_token', - }), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Token refresh failed: ${res.status} ${err}`); - } - - const data = await res.json(); - const updated = { ...tokens, ...data }; - writeFileSync(TOKEN_PATH, JSON.stringify(updated, null, 2)); - return data.access_token; -} - -// ---------- API helper ---------- - -async function createCustomMetric(accessToken, metric) { - const res = await fetch(`${ADMIN_BASE}/customMetrics`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(metric), - }); - - if (res.status === 409) { - console.log(` [skip] ${metric.parameterName} (already exists)`); - return; - } - - if (!res.ok) { - const err = await res.text(); - console.error(` [error] ${metric.parameterName}: ${res.status} ${err}`); - return; - } - - const result = await res.json(); - console.log(` [created] ${metric.parameterName} (${result.name})`); -} - -// ---------- Main ---------- - -async function main() { - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - console.log(`Registering ${CUSTOM_METRICS.length} custom metrics...\n`); - for (const metric of CUSTOM_METRICS) { - await createCustomMetric(accessToken, metric); - } - - console.log('\nDone.'); -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/scripts/ga4-report.mjs b/scripts/ga4-report.mjs deleted file mode 100644 index fb418128..00000000 --- a/scripts/ga4-report.mjs +++ /dev/null @@ -1,365 +0,0 @@ -#!/usr/bin/env node - -/** - * GA4 Report CLI - * - * Query GA4 data via the Data API and Admin API. - * - * Usage: node scripts/ga4-report.mjs [traffic|events|funnels|funnel-detail|conversions|dimensions] - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const PROPERTY_ID = '524531628'; -const TOKEN_PATH = join(homedir(), '.claude', 'ga4-tokens.json'); - -// Client credentials are read from the tokens file (client_id, client_secret fields) -// or from GA4_CLIENT_ID / GA4_CLIENT_SECRET env vars. -// To keep secrets out of version control, add to ~/.claude/ga4-tokens.json: -// "client_id": "", -// "client_secret": "" - -const DATA_BASE = `https://analyticsdata.googleapis.com/v1beta/properties/${PROPERTY_ID}`; -const ADMIN_BASE = `https://analyticsadmin.googleapis.com/v1beta/properties/${PROPERTY_ID}`; - -// ---------- Token refresh ---------- - -async function refreshAccessToken() { - const tokens = JSON.parse(readFileSync(TOKEN_PATH, 'utf-8')); - - const clientId = process.env.GA4_CLIENT_ID || tokens.client_id; - const clientSecret = process.env.GA4_CLIENT_SECRET || tokens.client_secret; - - if (!clientId || !clientSecret) { - throw new Error( - 'Missing client credentials. Set GA4_CLIENT_ID/GA4_CLIENT_SECRET env vars ' + - 'or add client_id/client_secret to ' + TOKEN_PATH - ); - } - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: tokens.refresh_token, - grant_type: 'refresh_token', - }), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Token refresh failed: ${res.status} ${err}`); - } - - const data = await res.json(); - const updated = { ...tokens, ...data }; - writeFileSync(TOKEN_PATH, JSON.stringify(updated, null, 2)); - return data.access_token; -} - -// ---------- API helpers ---------- - -async function runReport(accessToken, body) { - const res = await fetch(`${DATA_BASE}:runReport`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`runReport failed: ${res.status} ${err}`); - } - - return res.json(); -} - -async function adminGet(accessToken, path) { - const res = await fetch(`${ADMIN_BASE}/${path}`, { - headers: { Authorization: `Bearer ${accessToken}` }, - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Admin API failed: ${res.status} ${err}`); - } - - return res.json(); -} - -// ---------- Formatters ---------- - -function printTable(headers, rows) { - const widths = headers.map((h, i) => - Math.max(h.length, ...rows.map((r) => String(r[i] || '').length)) - ); - - const sep = widths.map((w) => '-'.repeat(w + 2)).join('+'); - const formatRow = (row) => - row.map((cell, i) => ` ${String(cell || '').padEnd(widths[i])} `).join('|'); - - console.log(formatRow(headers)); - console.log(sep); - rows.forEach((row) => console.log(formatRow(row))); - console.log(`\n${rows.length} rows\n`); -} - -function extractRows(report) { - if (!report.rows) return []; - return report.rows.map((row) => [ - ...row.dimensionValues.map((d) => d.value), - ...row.metricValues.map((m) => m.value), - ]); -} - -// ---------- Report types ---------- - -async function reportTraffic(accessToken) { - console.log('Traffic by Source/Medium (last 30 days)\n'); - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'sessionSourceMedium' }], - metrics: [{ name: 'sessions' }, { name: 'activeUsers' }, { name: 'bounceRate' }], - orderBys: [{ metric: { metricName: 'sessions' }, desc: true }], - limit: 20, - }); - - const rows = extractRows(report); - printTable(['Source / Medium', 'Sessions', 'Users', 'Bounce Rate'], rows); -} - -async function reportEvents(accessToken) { - console.log('Event Counts (last 30 days)\n'); - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }], - limit: 50, - }); - - const rows = extractRows(report); - printTable(['Event Name', 'Count', 'Users'], rows); -} - -async function reportFunnels(accessToken) { - console.log('Key Funnel Metrics (last 30 days)\n'); - - const funnelEvents = [ - 'session_start', - 'onboarding_start', - 'onboarding_complete', - 'auth_prompt', - 'auth_success', - 'itinerary', - 'check_in', - 'submit_event_open', - 'submit_event_success', - ]; - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: funnelEvents }, - }, - }, - orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }], - }); - - const rows = extractRows(report); - - // Sort by funnel order - const orderMap = Object.fromEntries(funnelEvents.map((e, i) => [e, i])); - rows.sort((a, b) => (orderMap[a[0]] ?? 99) - (orderMap[b[0]] ?? 99)); - - printTable(['Funnel Step', 'Count', 'Users'], rows); -} - -async function reportConversions(accessToken) { - console.log('Key Conversion Events (last 30 days)\n'); - - const conversionEvents = [ - 'auth_success', - 'itinerary', - 'check_in', - 'submit_event_success', - 'onboarding_complete', - 'ad_click', - 'rsvp_confirm', - ]; - - const report = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: conversionEvents }, - }, - }, - orderBys: [{ metric: { metricName: 'eventCount' }, desc: true }], - }); - - const rows = extractRows(report); - printTable(['Conversion Event', 'Count', 'Users'], rows); -} - -async function reportDimensions(accessToken) { - console.log('Registered Custom Dimensions\n'); - - const data = await adminGet(accessToken, 'customDimensions'); - const dims = data.customDimensions || []; - - const rows = dims.map((d) => [ - d.parameterName, - d.displayName, - d.scope, - d.description || '', - ]); - - // Sort by scope then name - rows.sort((a, b) => a[2].localeCompare(b[2]) || a[0].localeCompare(b[0])); - - printTable(['Parameter', 'Display Name', 'Scope', 'Description'], rows); -} - -async function reportFunnelDetail(accessToken) { - console.log('Funnel Analysis (last 30 days)\n'); - - // Onboarding funnel - const onboardingEvents = ['session_start', 'onboarding_start', 'onboarding_step', 'onboarding_complete']; - const onboardingReport = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: onboardingEvents }, - }, - }, - }); - - console.log('=== Onboarding Funnel ==='); - const onboardingRows = extractRows(onboardingReport); - const orderMap1 = Object.fromEntries(onboardingEvents.map((e, i) => [e, i])); - onboardingRows.sort((a, b) => (orderMap1[a[0]] ?? 99) - (orderMap1[b[0]] ?? 99)); - - // Calculate drop-off rates - const onboardingWithRates = onboardingRows.map((row, i) => { - const prevUsers = i > 0 ? Number(onboardingRows[i - 1][2]) : Number(row[2]); - const currentUsers = Number(row[2]); - const rate = i === 0 ? '100%' : `${((currentUsers / prevUsers) * 100).toFixed(1)}%`; - const dropOff = i === 0 ? '-' : `${((1 - currentUsers / prevUsers) * 100).toFixed(1)}%`; - return [...row, rate, dropOff]; - }); - printTable(['Step', 'Count', 'Users', 'Conv Rate', 'Drop-off'], onboardingWithRates); - - // Auth funnel - const authEvents = ['auth_prompt', 'auth_success']; - const authReport = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: authEvents }, - }, - }, - }); - - console.log('=== Auth Funnel ==='); - const authRows = extractRows(authReport); - const orderMap2 = Object.fromEntries(authEvents.map((e, i) => [e, i])); - authRows.sort((a, b) => (orderMap2[a[0]] ?? 99) - (orderMap2[b[0]] ?? 99)); - - const authWithRates = authRows.map((row, i) => { - const prevUsers = i > 0 ? Number(authRows[i - 1][2]) : Number(row[2]); - const currentUsers = Number(row[2]); - const rate = i === 0 ? '100%' : `${((currentUsers / prevUsers) * 100).toFixed(1)}%`; - const dropOff = i === 0 ? '-' : `${((1 - currentUsers / prevUsers) * 100).toFixed(1)}%`; - return [...row, rate, dropOff]; - }); - printTable(['Step', 'Count', 'Users', 'Conv Rate', 'Drop-off'], authWithRates); - - // Engagement funnel - const engagementEvents = ['session_start', 'event_click', 'itinerary', 'check_in']; - const engReport = await runReport(accessToken, { - dateRanges: [{ startDate: '30daysAgo', endDate: 'today' }], - dimensions: [{ name: 'eventName' }], - metrics: [{ name: 'eventCount' }, { name: 'totalUsers' }], - dimensionFilter: { - filter: { - fieldName: 'eventName', - inListFilter: { values: engagementEvents }, - }, - }, - }); - - console.log('=== Engagement Funnel ==='); - const engRows = extractRows(engReport); - const orderMap3 = Object.fromEntries(engagementEvents.map((e, i) => [e, i])); - engRows.sort((a, b) => (orderMap3[a[0]] ?? 99) - (orderMap3[b[0]] ?? 99)); - - const engWithRates = engRows.map((row, i) => { - const prevUsers = i > 0 ? Number(engRows[i - 1][2]) : Number(row[2]); - const currentUsers = Number(row[2]); - const rate = i === 0 ? '100%' : `${((currentUsers / prevUsers) * 100).toFixed(1)}%`; - const dropOff = i === 0 ? '-' : `${((1 - currentUsers / prevUsers) * 100).toFixed(1)}%`; - return [...row, rate, dropOff]; - }); - printTable(['Step', 'Count', 'Users', 'Conv Rate', 'Drop-off'], engWithRates); -} - -// ---------- Main ---------- - -const REPORTS = { - traffic: reportTraffic, - events: reportEvents, - funnels: reportFunnels, - 'funnel-detail': reportFunnelDetail, - conversions: reportConversions, - dimensions: reportDimensions, -}; - -async function main() { - const reportType = process.argv[2]; - - if (!reportType || !REPORTS[reportType]) { - console.log('Usage: node scripts/ga4-report.mjs [traffic|events|funnels|funnel-detail|conversions|dimensions]'); - console.log('\nReport types:'); - console.log(' traffic Sessions by source/medium (last 30 days)'); - console.log(' events Event counts (last 30 days)'); - console.log(' funnels Key funnel metrics (onboarding, auth)'); - console.log(' funnel-detail Detailed funnel analysis with conversion/drop-off rates'); - console.log(' conversions Key conversion events'); - console.log(' dimensions List registered custom dimensions'); - process.exit(1); - } - - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - await REPORTS[reportType](accessToken); -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/scripts/ga4-setup.mjs b/scripts/ga4-setup.mjs deleted file mode 100644 index 67b18eb0..00000000 --- a/scripts/ga4-setup.mjs +++ /dev/null @@ -1,189 +0,0 @@ -#!/usr/bin/env node - -/** - * GA4 Setup Script - * - * Registers custom dimensions and key events (conversions) in GA4 via the Admin API. - * Uses OAuth2 refresh token stored at ~/.claude/ga4-tokens.json. - * - * Usage: node scripts/ga4-setup.mjs - */ - -import { readFileSync, writeFileSync } from 'fs'; -import { join } from 'path'; -import { homedir } from 'os'; - -const PROPERTY_ID = '524531628'; -const TOKEN_PATH = join(homedir(), '.claude', 'ga4-tokens.json'); - -// Client credentials are read from the tokens file (client_id, client_secret fields) -// or from GA4_CLIENT_ID / GA4_CLIENT_SECRET env vars. -// To keep secrets out of version control, add to ~/.claude/ga4-tokens.json: -// "client_id": "", -// "client_secret": "" - -const ADMIN_BASE = `https://analyticsadmin.googleapis.com/v1beta/properties/${PROPERTY_ID}`; - -// ---------- Custom dimensions to register ---------- - -const EVENT_DIMENSIONS = [ - { parameterName: 'view_mode', displayName: 'View Mode', description: 'Map/List/Table/Gallery' }, - { parameterName: 'conference', displayName: 'Conference', description: 'Conference slug' }, - { parameterName: 'tag', displayName: 'Tag', description: 'Filter tag name' }, - { parameterName: 'action', displayName: 'Action', description: 'Add/remove action' }, - { parameterName: 'search_term', displayName: 'Search Term', description: 'Search query' }, - { parameterName: 'event_name', displayName: 'Tracked Event Name', description: 'Name of event clicked' }, - { parameterName: 'trigger', displayName: 'Auth Trigger', description: 'What triggered auth prompt' }, - { parameterName: 'placement', displayName: 'Ad Placement', description: 'Ad placement location' }, - { parameterName: 'step', displayName: 'Onboarding Step', description: 'Which onboarding step' }, - { parameterName: 'modal', displayName: 'Modal Name', description: 'Which modal was dismissed' }, - { parameterName: 'provider', displayName: 'Nav Provider', description: 'Google Maps/Uber/Lyft' }, - { parameterName: 'visibility', displayName: 'Comment Visibility', description: 'Public/friends' }, - { parameterName: 'category', displayName: 'POI Category', description: 'POI category' }, - { parameterName: 'emoji', displayName: 'Reaction Emoji', description: 'Which emoji used' }, - { parameterName: 'test_id', displayName: 'AB Test ID', description: 'A/B test identifier' }, - { parameterName: 'variant_id', displayName: 'AB Variant', description: 'A/B test variant' }, -]; - -const USER_DIMENSIONS = [ - { parameterName: 'conference_slug', displayName: 'User Conference', description: 'User selected conference' }, - { parameterName: 'has_itinerary', displayName: 'Has Itinerary', description: 'Whether user has itinerary items' }, - { parameterName: 'is_authenticated', displayName: 'Is Authenticated', description: 'Whether user is logged in' }, -]; - -const KEY_EVENTS = [ - 'auth_success', - 'itinerary', - 'check_in', - 'submit_event_success', - 'onboarding_complete', - 'ad_click', - 'rsvp_confirm', -]; - -// ---------- Token refresh ---------- - -async function refreshAccessToken() { - const tokens = JSON.parse(readFileSync(TOKEN_PATH, 'utf-8')); - - const clientId = process.env.GA4_CLIENT_ID || tokens.client_id; - const clientSecret = process.env.GA4_CLIENT_SECRET || tokens.client_secret; - - if (!clientId || !clientSecret) { - throw new Error( - 'Missing client credentials. Set GA4_CLIENT_ID/GA4_CLIENT_SECRET env vars ' + - 'or add client_id/client_secret to ' + TOKEN_PATH - ); - } - - const res = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - refresh_token: tokens.refresh_token, - grant_type: 'refresh_token', - }), - }); - - if (!res.ok) { - const err = await res.text(); - throw new Error(`Token refresh failed: ${res.status} ${err}`); - } - - const data = await res.json(); - // Persist the new access token (keep existing refresh_token) - const updated = { ...tokens, ...data }; - writeFileSync(TOKEN_PATH, JSON.stringify(updated, null, 2)); - return data.access_token; -} - -// ---------- API helpers ---------- - -async function createCustomDimension(accessToken, dimension, scope) { - const body = { - parameterName: dimension.parameterName, - displayName: dimension.displayName, - description: dimension.description || '', - scope: scope, // EVENT or USER - }; - - const res = await fetch(`${ADMIN_BASE}/customDimensions`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (res.status === 409) { - console.log(` [skip] ${dimension.parameterName} (already exists)`); - return; - } - - if (!res.ok) { - const err = await res.text(); - console.error(` [error] ${dimension.parameterName}: ${res.status} ${err}`); - return; - } - - console.log(` [created] ${dimension.parameterName}`); -} - -async function createKeyEvent(accessToken, eventName) { - const body = { eventName }; - - const res = await fetch(`${ADMIN_BASE}/keyEvents`, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }); - - if (res.status === 409) { - console.log(` [skip] ${eventName} (already exists)`); - return; - } - - if (!res.ok) { - const err = await res.text(); - console.error(` [error] ${eventName}: ${res.status} ${err}`); - return; - } - - console.log(` [created] ${eventName}`); -} - -// ---------- Main ---------- - -async function main() { - console.log('Refreshing access token...'); - const accessToken = await refreshAccessToken(); - console.log('Token refreshed.\n'); - - console.log(`Registering ${EVENT_DIMENSIONS.length} event-scoped custom dimensions...`); - for (const dim of EVENT_DIMENSIONS) { - await createCustomDimension(accessToken, dim, 'EVENT'); - } - - console.log(`\nRegistering ${USER_DIMENSIONS.length} user-scoped custom dimensions...`); - for (const dim of USER_DIMENSIONS) { - await createCustomDimension(accessToken, dim, 'USER'); - } - - console.log(`\nRegistering ${KEY_EVENTS.length} key events (conversions)...`); - for (const eventName of KEY_EVENTS) { - await createKeyEvent(accessToken, eventName); - } - - console.log('\nDone.'); -} - -main().catch((err) => { - console.error('Fatal error:', err.message); - process.exit(1); -}); diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b286555c..ccb92cda 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -11,14 +11,12 @@ import type { AdminConfig, SponsorEntry, NativeAd, UpsellCopy, AdInventoryItem, import { isConferencePast, conferenceToTab } from '@/lib/conferences'; import type { TabConfig } from '@/lib/conferences'; import SponsorDataTab from '@/components/admin/SponsorDataTab'; -import SubmissionsTab from '@/components/admin/SubmissionsTab'; const SESSION_KEY = 'sheeets-admin-auth'; -type AdminTab = 'submissions' | 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics' | 'sponsorData'; +type AdminTab = 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics' | 'sponsorData'; const TAB_LABELS: { key: AdminTab; label: string }[] = [ - { key: 'submissions', label: 'Submissions' }, { key: 'featured', label: 'Featured' }, { key: 'conferences', label: 'Conferences' }, { key: 'sponsors', label: 'Sponsors' }, @@ -108,7 +106,7 @@ export default function AdminPage() { const [search, setSearch] = useState(''); const [togglingId, setTogglingId] = useState(null); - const [activeTab, setActiveTab] = useState('submissions'); + const [activeTab, setActiveTab] = useState('featured'); const [adminConfig, setAdminConfig] = useState(null); const [configLoading, setConfigLoading] = useState(false); const [saving, setSaving] = useState(false); @@ -663,11 +661,6 @@ export default function AdminPage() { {/* Content */}
- {/* Tab: Submissions */} - {activeTab === 'submissions' && ( - - )} - {/* Tab 1: Featured */} {activeTab === 'featured' && ( <> diff --git a/src/app/api/admin/submissions/route.ts b/src/app/api/admin/submissions/route.ts deleted file mode 100644 index a37834a3..00000000 --- a/src/app/api/admin/submissions/route.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { NextRequest, NextResponse } from 'next/server'; -import { createClient } from '@supabase/supabase-js'; -import { parseBody, SubmissionActionSchema } from '@/lib/api-validation'; -import { insertEventRowSorted, getSheetTitle, findReviewRow, deleteSheetRow, writeCell } from '@/lib/google-sheets'; -import { FALLBACK_TABS } from '@/lib/conferences'; -import { getConferenceTabs } from '@/lib/get-conferences'; -import { normalizeAddress } from '@/lib/utils'; - -const ADMIN_PASSWORD = 'trusttheplan'; - -function getSupabase() { - return createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ); -} - -export async function GET(req: NextRequest) { - const password = req.nextUrl.searchParams.get('password'); - if (password !== ADMIN_PASSWORD) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const status = req.nextUrl.searchParams.get('status') || 'pending'; - const conference = req.nextUrl.searchParams.get('conference') || ''; - - const supabase = getSupabase(); - let query = supabase - .from('event_submissions') - .select('*') - .order('created_at', { ascending: false }); - - if (status !== 'all') { - query = query.eq('status', status); - } - if (conference) { - query = query.eq('conference', conference); - } - - const { data, error } = await query; - - if (error) { - return NextResponse.json({ error: error.message }, { status: 500 }); - } - - return NextResponse.json({ submissions: data }); -} - -export async function POST(req: NextRequest) { - const { data, error: parseError } = await parseBody(req, SubmissionActionSchema); - if (parseError) return parseError; - - const { password, action, id, rejection_reason, edits } = data; - - if (password !== ADMIN_PASSWORD) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - } - - const supabase = getSupabase(); - - // Fetch the submission - const { data: submission, error: fetchError } = await supabase - .from('event_submissions') - .select('*') - .eq('id', id) - .eq('status', 'pending') - .single(); - - if (fetchError || !submission) { - return NextResponse.json( - { error: 'Submission not found or already reviewed' }, - { status: 404 } - ); - } - - // Resolve conference tab - let tabs = FALLBACK_TABS; - try { - tabs = await getConferenceTabs(); - } catch { /* use fallback */ } - - const tab = tabs.find((t: { name: string }) => t.name === submission.conference); - if (!tab) { - return NextResponse.json( - { error: `Conference tab not found: ${submission.conference}` }, - { status: 400 } - ); - } - - const sheetName = await getSheetTitle(tab.gid); - - if (action === 'reject') { - // 1. Find the review row by original name/date - const reviewRowIdx = await findReviewRow(sheetName, submission.event_name, submission.event_date); - - // 2. Mark column M as REJECTED (if row found) - if (reviewRowIdx !== null) { - try { - await writeCell(sheetName, `M${reviewRowIdx + 1}`, 'REJECTED'); - } catch (sheetErr) { - console.warn('Failed to mark sheet row as rejected:', sheetErr); - } - } else { - console.warn(`Review row not found for rejection: ${submission.event_name} / ${submission.event_date}`); - } - - // 3. Update Supabase - const { error: updateError } = await supabase - .from('event_submissions') - .update({ - status: 'rejected', - rejection_reason: rejection_reason || null, - reviewed_at: new Date().toISOString(), - }) - .eq('id', id) - .eq('status', 'pending'); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ success: true, action: 'rejected' }); - } - - // ---- Approve ---- - - // Merge admin edits over submission fields - const eventFields = { - event_name: edits?.event_name ?? submission.event_name, - event_date: edits?.event_date ?? submission.event_date, - start_time: edits?.start_time ?? submission.start_time, - end_time: edits?.end_time ?? submission.end_time, - organizer: edits?.organizer ?? submission.organizer, - address: edits?.address ?? submission.address, - cost: edits?.cost ?? submission.cost, - tags: edits?.tags ?? submission.tags, - link: edits?.link ?? submission.link, - has_food: edits?.has_food ?? submission.has_food, - has_bar: edits?.has_bar ?? submission.has_bar, - note: edits?.note ?? submission.note, - }; - - try { - // 1. Find the review row by ORIGINAL name/date (before edits) - const reviewRowIdx = await findReviewRow(sheetName, submission.event_name, submission.event_date); - - // 2. Insert into main section (this shifts all rows below down by 1) - const sheetRow = await insertEventRowSorted(sheetName, tab.gid, { - date: eventFields.event_date, - startTime: eventFields.start_time, - endTime: eventFields.end_time, - organizer: eventFields.organizer, - name: eventFields.event_name, - address: eventFields.address, - cost: eventFields.cost, - tags: eventFields.tags, - link: eventFields.link, - food: eventFields.has_food, - bar: eventFields.has_bar, - note: eventFields.note, - }); - - // 3. Delete from review section (+1 because insertEventRowSorted shifted rows down) - if (reviewRowIdx !== null) { - try { - await deleteSheetRow(tab.gid, reviewRowIdx + 1); - } catch (deleteErr) { - console.warn('Failed to delete review row:', deleteErr); - } - } else { - console.warn(`Review row not found for approval: ${submission.event_name} / ${submission.event_date}`); - } - - // 4. Upsert geocoded address if coords exist - if (submission.coords_lat && submission.coords_lng && eventFields.address) { - try { - await supabase.from('geocoded_addresses').upsert({ - normalized_address: normalizeAddress(eventFields.address), - lat: submission.coords_lat, - lng: submission.coords_lng, - matched_address: eventFields.address, - conference: submission.conference, - }); - } catch (geoErr) { - console.error('Failed to save geocoded address:', geoErr); - } - } - - // 5. Update Supabase status - const { error: updateError } = await supabase - .from('event_submissions') - .update({ - status: 'approved', - reviewed_at: new Date().toISOString(), - sheet_row: sheetRow, - }) - .eq('id', id) - .eq('status', 'pending'); - - if (updateError) { - return NextResponse.json({ error: updateError.message }, { status: 500 }); - } - - return NextResponse.json({ success: true, action: 'approved', sheet_row: sheetRow }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; - return NextResponse.json( - { error: `Failed to write to sheet: ${message}` }, - { status: 500 } - ); - } -} diff --git a/src/app/api/image-proxy/route.ts b/src/app/api/image-proxy/route.ts new file mode 100644 index 00000000..0949b59b --- /dev/null +++ b/src/app/api/image-proxy/route.ts @@ -0,0 +1,39 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function GET(request: NextRequest) { + const url = request.nextUrl.searchParams.get('url'); + if (!url) { + return NextResponse.json({ error: 'Missing url parameter' }, { status: 400 }); + } + + try { + new URL(url); + } catch { + return NextResponse.json({ error: 'Invalid url' }, { status: 400 }); + } + + try { + const res = await fetch(url, { + signal: AbortSignal.timeout(10000), + headers: { + 'User-Agent': 'Mozilla/5.0 (compatible; sheeets-bot/1.0)', + }, + }); + + if (!res.ok) { + return NextResponse.json({ error: 'Upstream fetch failed' }, { status: 502 }); + } + + const contentType = res.headers.get('content-type') || 'image/jpeg'; + const buffer = await res.arrayBuffer(); + + return new NextResponse(buffer, { + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=86400, s-maxage=86400', + }, + }); + } catch { + return NextResponse.json({ error: 'Proxy fetch failed' }, { status: 502 }); + } +} diff --git a/src/app/api/submit-event/route.ts b/src/app/api/submit-event/route.ts index 81c8efef..28888929 100644 --- a/src/app/api/submit-event/route.ts +++ b/src/app/api/submit-event/route.ts @@ -31,7 +31,6 @@ export async function POST(request: NextRequest) { const sheetName = await getSheetTitle(tab.gid); - // Write to sheet review section (primary — must succeed) const row = await appendEventRow(sheetName, { date: event.date.trim(), startTime: event.startTime.trim(), @@ -47,35 +46,6 @@ export async function POST(request: NextRequest) { note: event.note.trim(), }); - // Track in Supabase (non-fatal — wrapped in try/catch) - try { - const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.SUPABASE_SERVICE_ROLE_KEY! - ); - await supabase.from('event_submissions').insert({ - conference, - status: 'pending', - event_name: event.name.trim(), - event_date: event.date.trim(), - start_time: event.startTime.trim(), - end_time: event.endTime.trim(), - organizer: event.organizer.trim(), - address: event.address.trim(), - cost: event.cost.trim(), - tags: event.tags.trim(), - link: event.link.trim(), - has_food: event.food, - has_bar: event.bar, - note: event.note.trim(), - coords_lat: coords?.lat ?? null, - coords_lng: coords?.lng ?? null, - sheet_row: row, - }); - } catch (trackErr) { - console.error('Failed to track submission in Supabase:', trackErr); - } - // Upsert geocoded address to Supabase for instant map pin display if (coords && event.address.trim()) { try { @@ -91,11 +61,12 @@ export async function POST(request: NextRequest) { conference, }); } catch (geoErr) { + // Non-fatal: log but don't fail the submission console.error('Failed to save geocoded address:', geoErr); } } - return NextResponse.json({ success: true, row, pending: true }); + return NextResponse.json({ success: true, row }); } catch (err) { console.error('Submit event error:', err); const message = err instanceof Error ? err.message : 'Unknown error'; diff --git a/src/app/error.tsx b/src/app/error.tsx deleted file mode 100644 index c00c473c..00000000 --- a/src/app/error.tsx +++ /dev/null @@ -1,29 +0,0 @@ -'use client'; - -import { useEffect } from 'react'; -import { trackError } from '@/lib/analytics'; - -export default function Error({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - useEffect(() => { - trackError(error.message, 'error-boundary'); - }, [error]); - - return ( -
-

Something went wrong

-

{error.message}

- -
- ); -} diff --git a/src/app/globals.css b/src/app/globals.css index 85085715..1483e09d 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -53,42 +53,6 @@ --theme-logo-filter: invert(1); - --theme-header-bg: var(--theme-bg-primary); - --theme-header-border: var(--theme-border-secondary); - --theme-header-logo-filter: var(--theme-logo-filter); - --theme-header-text: var(--theme-text-secondary); - --theme-header-text-hover: var(--theme-text-primary); - --theme-header-control-bg: var(--theme-bg-secondary); - --theme-header-control-bg-hover: var(--theme-bg-tertiary); - --theme-header-control-border: var(--theme-border-primary); - --theme-header-accent: var(--theme-accent); - --theme-header-accent-muted: var(--theme-accent-muted); - --theme-header-badge-bg: var(--theme-accent); - --theme-header-badge-text: var(--theme-accent-text); - --theme-header-badge-border: var(--theme-accent); - - --theme-map-pin: #1c1917; - --theme-map-pin-night: rgba(255,255,255,0.4); - - --theme-bg-list: var(--theme-bg-primary); - --theme-bg-filter: var(--theme-bg-primary); - --theme-filter-text: var(--theme-text-secondary); - --theme-filter-control-bg: var(--theme-bg-secondary); - --theme-filter-control-border: var(--theme-border-primary); - --theme-filter-active: var(--theme-accent); - --theme-filter-active-bg: var(--theme-accent-muted); - --theme-filter-badge-bg: var(--theme-text-secondary); - --theme-filter-badge-text: var(--theme-bg-primary); - --theme-filter-badge-border: var(--theme-text-secondary); - --theme-table-header-bg: var(--theme-bg-secondary); - --theme-table-header-text: var(--theme-text-secondary); - --theme-landing-card-bg: var(--theme-bg-card); - --theme-landing-card-text: var(--theme-text-primary); - --theme-landing-card-secondary: var(--theme-text-secondary); - --theme-landing-card-muted: var(--theme-text-muted); - --theme-landing-card-accent: var(--theme-accent); - --theme-landing-card-border: var(--theme-border-primary); - --theme-scrollbar-track: #0c0a09; --theme-scrollbar-thumb: #292524; --theme-scrollbar-thumb-hover: #44403c; @@ -106,21 +70,6 @@ --tag-purple: #A855F7; --tag-default: #6B7280; --friend-blue: #3B82F6; - - --theme-modal-header-bg: transparent; - --theme-modal-header-text: var(--theme-text-primary); - --theme-modal-header-close: var(--theme-text-secondary); - --theme-modal-bg: var(--theme-bg-secondary); - - --theme-date-sep-bg: var(--theme-bg-primary); - --theme-date-sep-text: var(--theme-text-primary); - --theme-date-sep-muted: var(--theme-text-muted); - --theme-date-sep-border: var(--theme-border-secondary); - - --theme-ticker-bg: var(--theme-bg-primary); - --theme-ticker-text: var(--theme-text-secondary); - --theme-ticker-link: var(--theme-text-primary); - --theme-ticker-border: var(--theme-border-secondary); } /* --- Paper theme — warm parchment/cream aesthetic --- */ @@ -158,9 +107,6 @@ --theme-now-bg: #c0564a; - --theme-map-pin: #d4a32c; - --theme-map-pin-night: #6b4c0a; - --theme-logo-filter: invert(0); --theme-scrollbar-track: #f5f0e8; @@ -217,9 +163,6 @@ --theme-now-bg: #22c55e; - --theme-map-pin: #fca5a5; - --theme-map-pin-night: #dc2626; - --theme-logo-filter: invert(0); --theme-scrollbar-track: #fafafa; @@ -253,8 +196,8 @@ --theme-bg-primary: #fafafa; --theme-bg-secondary: #f5f5f5; --theme-bg-tertiary: #e5e5e5; - --theme-bg-card: #f7f7f8; - --theme-bg-card-hover: #f0f0f2; + --theme-bg-card: #ffffff; + --theme-bg-card-hover: #f5f5f5; --theme-bg-input: #ffffff; --theme-border-primary: #d4d4d4; @@ -278,33 +221,6 @@ --theme-logo-filter: brightness(0) saturate(100%) invert(22%) sepia(60%) saturate(1200%) hue-rotate(198deg) brightness(95%) contrast(95%); - --theme-header-bg: #1c4586; - --theme-header-border: #153a73; - --theme-header-logo-filter: brightness(0) invert(1); - --theme-header-text: rgba(255, 255, 255, 0.7); - --theme-header-text-hover: #ffffff; - --theme-header-control-bg: rgba(255, 255, 255, 0.1); - --theme-header-control-bg-hover: rgba(255, 255, 255, 0.2); - --theme-header-control-border: rgba(255, 255, 255, 0.2); - --theme-header-accent: #ffffff; - --theme-header-accent-muted: rgba(255, 255, 255, 0.15); - - --theme-map-pin: #93c5fd; - --theme-map-pin-night: #1c4586; - - --theme-bg-list: rgba(28, 69, 134, 0.06); - --theme-bg-filter: #1c4586; - --theme-filter-text: rgba(255, 255, 255, 0.7); - --theme-filter-control-bg: rgba(255, 255, 255, 0.1); - --theme-filter-control-border: rgba(255, 255, 255, 0.2); - --theme-filter-active: #ffffff; - --theme-filter-active-bg: rgba(255, 255, 255, 0.15); - --theme-filter-badge-bg: transparent; - --theme-filter-badge-text: #ffffff; - --theme-filter-badge-border: #ffffff; - --theme-table-header-bg: #1c4586; - --theme-table-header-text: #ffffff; - --theme-scrollbar-track: #fafafa; --theme-scrollbar-thumb: #d4d4d4; --theme-scrollbar-thumb-hover: #a3a3a3; @@ -314,40 +230,14 @@ --theme-popup-featured-border: rgba(28, 69, 134, 0.5); --theme-popup-shadow: rgba(0, 0, 0, 0.1); - --theme-landing-card-bg: #1c4586; - --theme-landing-card-text: #ffffff; - --theme-landing-card-secondary: rgba(255, 255, 255, 0.7); - --theme-landing-card-muted: rgba(255, 255, 255, 0.5); - --theme-landing-card-accent: #ffffff; - --theme-landing-card-border: rgba(255, 255, 255, 0.15); - - --tag-green: #34d399; - --tag-blue: #93c5fd; - --tag-yellow: #fbbf24; - --tag-pink: #f472b6; - --tag-teal: #5eead4; - --tag-purple: #c4b5fd; - --tag-default: #94a3b8; + --tag-green: #059669; + --tag-blue: #2563EB; + --tag-yellow: #D97706; + --tag-pink: #DB2777; + --tag-teal: #0D9488; + --tag-purple: #7C3AED; + --tag-default: #4B5563; --friend-blue: #2563EB; - - --theme-modal-header-bg: #1c4586; - --theme-modal-header-text: #ffffff; - --theme-modal-header-close: rgba(255, 255, 255, 0.7); - --theme-modal-bg: #edf0f3; - - --theme-date-sep-bg: #1c4586; - --theme-date-sep-text: #ffffff; - --theme-date-sep-muted: rgba(255, 255, 255, 0.7); - --theme-date-sep-border: rgba(255, 255, 255, 0.2); - - --theme-ticker-bg: var(--theme-bg-list); - --theme-ticker-text: #1c4586; - --theme-ticker-link: #1c4586; - --theme-ticker-border: rgba(28, 69, 134, 0.15); - - --theme-header-badge-bg: transparent; - --theme-header-badge-text: #ffffff; - --theme-header-badge-border: #ffffff; } /* Light-blue: alternating table rows */ @@ -355,7 +245,6 @@ background-color: rgba(28, 69, 134, 0.05); } - /* --- SXSW theme — neon lime on dark forest green --- */ [data-theme="sxsw"] { --background: #0a1410; diff --git a/src/app/itinerary/page.tsx b/src/app/itinerary/page.tsx index de071011..7710673d 100644 --- a/src/app/itinerary/page.tsx +++ b/src/app/itinerary/page.tsx @@ -1,553 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useMemo, useState, useRef, useCallback, useEffect } from 'react'; -import Link from 'next/link'; -import dynamic from 'next/dynamic'; -import { AddressLink } from '@/components/AddressLink'; -import { ArrowLeft, AlertTriangle, Trash2, CalendarX, Share2, Map as MapIcon, List, GripVertical, Star, ExternalLink, Eye, EyeOff, MapPinCheck, Loader2 } from 'lucide-react'; -import clsx from 'clsx'; -import { useEvents } from '@/hooks/useEvents'; -import { useItinerary } from '@/hooks/useItinerary'; -import { useAuth } from '@/contexts/AuthContext'; -import { supabase } from '@/lib/supabase'; -import { VIBE_COLORS } from '@/lib/tags'; -import { formatDateLabel } from '@/lib/utils'; -import { sortByStartTime, detectConflicts } from '@/lib/time-parse'; -import { trackItineraryClear, trackItineraryConferenceTab, trackItineraryShareLink, trackItineraryReorder, trackItineraryView } from '@/lib/analytics'; -import type { ETHDenverEvent } from '@/lib/types'; -import { Loading } from '@/components/Loading'; -import { useEventCheckIn } from '@/hooks/useEventCheckIn'; -import { passesNowFilter, getConferenceNow } from '@/lib/filters'; -import { useDragReorder } from '@/hooks/useDragReorder'; -import { useProfile } from '@/hooks/useProfile'; -import { ShareCardModal } from '@/components/ShareCardModal'; -import { GoogleCalendarButton } from '@/components/GoogleCalendarButton'; -import { getTabConfig } from '@/lib/conferences'; -import { useConferenceTabs } from '@/hooks/useConferenceTabs'; - -const MapView = dynamic( - () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })), - { - ssr: false, - loading: () => ( -
-
Loading map...
-
- ), - } -); - -type ItineraryViewMode = 'list' | 'map'; - -function generateShortCode(): string { - const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; - let code = ''; - for (let i = 0; i < 8; i++) { - code += chars[Math.floor(Math.random() * chars.length)]; - } - return code; -} - -function CheckInToast({ result, onDismiss }: { result: { ok: boolean; message: string }; onDismiss: () => void }) { - useEffect(() => { - const timer = setTimeout(onDismiss, 3000); - return () => clearTimeout(timer); - }, [onDismiss]); - - return ( -
- {result.message} -
- ); -} - -export default function ItineraryPage() { - const { events, loading } = useEvents(); - const { itinerary, toggle: toggleItinerary, clear: clearItinerary, reorder: reorderItinerary, hiddenEvents, toggleHidden } = useItinerary(); - const { user } = useAuth(); - const { profile } = useProfile(); - const { checkInToEvent, loading: checkInLoading, result: checkInResult, clearResult: clearCheckInResult } = useEventCheckIn(); - const { tabs } = useConferenceTabs(); - const [showClearConfirm, setShowClearConfirm] = useState(false); - const [viewMode, setViewMode] = useState('list'); - const [shareStatus, setShareStatus] = useState<'idle' | 'sharing' | 'copied'>('idle'); - const [showShareCard, setShowShareCard] = useState(false); - const captureRef = useRef(null); - - useEffect(() => { - trackItineraryView(); - }, []); - - // Get conferences that have itinerary events - const allItineraryEvents = useMemo( - () => events.filter((e) => itinerary.has(e.id)), - [events, itinerary] - ); - const conferences = useMemo( - () => [...new Set(allItineraryEvents.map((e) => e.conference).filter(Boolean))], - [allItineraryEvents] - ); - const [activeConference, setActiveConference] = useState(''); - - // Auto-select first conference when data loads - useMemo(() => { - if (conferences.length > 0 && !activeConference) { - setActiveConference(conferences[0]); - } - }, [conferences, activeConference]); - - const itineraryEvents = useMemo( - () => allItineraryEvents.filter((e) => !activeConference || e.conference === activeConference), - [allItineraryEvents, activeConference] - ); - - const conflicts = useMemo(() => detectConflicts(itineraryEvents), [itineraryEvents]); - - // Timezone for the active conference (used for Google Calendar export) - const conferenceTimezone = useMemo( - () => getTabConfig(activeConference, tabs).timezone, - [activeConference, tabs] - ); - - // Events eligible for Google Calendar export (exclude hidden) - const exportableEvents = useMemo( - () => itineraryEvents.filter((e) => !hiddenEvents.has(e.id)), - [itineraryEvents, hiddenEvents] - ); - - const dateGroups = useMemo(() => { - const groupMap = new Map(); - for (const event of itineraryEvents) { - const key = event.dateISO || 'unknown'; - if (!groupMap.has(key)) groupMap.set(key, []); - groupMap.get(key)!.push(event); - } - return Array.from(groupMap.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([dateISO, groupEvents]) => ({ - dateISO, - label: dateISO === 'unknown' ? 'Date TBD' : formatDateLabel(dateISO), - events: groupEvents.sort(sortByStartTime), - })); - }, [itineraryEvents]); - - // Flat ordered list of all event IDs across date groups (for drag reorder) - const flatEventIds = useMemo( - () => dateGroups.flatMap((g) => g.events.map((e) => e.id)), - [dateGroups] - ); - - const handleReorder = useCallback( - (orderedIds: string[]) => { - trackItineraryReorder(); - // The drag reorder gives us the visible (conference-filtered) IDs in new order. - // Preserve IDs from other conferences that aren't visible. - const allIds = [...itinerary]; - const visibleIdSet = new Set(flatEventIds); - const otherIds = allIds.filter((id) => !visibleIdSet.has(id)); - reorderItinerary([...orderedIds, ...otherIds]); - }, - [itinerary, flatEventIds, reorderItinerary] - ); - - const { - setOrderedIds, - registerItemRef, - getDragHandleProps, - getItemProps, - getDropIndicator, - dragId, - } = useDragReorder({ onReorder: handleReorder }); - - // Keep ordered IDs in sync - useEffect(() => { - setOrderedIds(flatEventIds); - }, [flatEventIds, setOrderedIds]); - - const handleShareLink = useCallback(async () => { - if (itineraryEvents.length === 0) return; - setShareStatus('sharing'); - try { - const shortCode = generateShortCode(); - const eventIds = itineraryEvents - .filter((e) => !hiddenEvents.has(e.id)) - .map((e) => e.id); - const { error } = await supabase.from('shared_itineraries').insert({ - short_code: shortCode, - event_ids: eventIds, - created_by: user?.id ?? null, - }); - if (error) { - console.error('Failed to create share link:', error); - setShareStatus('idle'); - return; - } - const shareUrl = `${window.location.origin}/itinerary/s/${shortCode}`; - await navigator.clipboard.writeText(shareUrl); - setShareStatus('copied'); - setTimeout(() => setShareStatus('idle'), 2500); - } catch (err) { - console.error('Share failed:', err); - setShareStatus('idle'); - } - }, [itineraryEvents, hiddenEvents, user]); - - if (loading) { - return ( -
- -
- ); - } - - return ( -
- {/* Header */} -
-
-
- - - -

- My Itinerary{' '} - - ({itineraryEvents.length} event{itineraryEvents.length !== 1 ? 's' : ''}) - -

-
- {/* Conference tabs */} - {conferences.length > 0 && ( -
- {conferences.map((conf) => ( - - ))} -
- )} -
- {itineraryEvents.length > 0 && ( - <> - {/* View toggle */} -
- {([ - { mode: 'list' as const, icon: List, label: 'List' }, - { mode: 'map' as const, icon: MapIcon, label: 'Map' }, - ]).map(({ mode, icon: Icon, label }) => ( - - ))} -
- - - - - - )} -
-
-
- - {/* Check-in result toast */} - {checkInResult && ( - - )} - - {/* Content */} - {itineraryEvents.length === 0 ? ( -
- -

No events in your itinerary yet

-

- Star events from the main page to build your schedule! -

- - Browse Events - -
- ) : viewMode === 'map' ? ( -
- -
- ) : ( -
-
-
- 📅 - sheeets.xyz - — My Itinerary -
- - {conflicts.size > 0 && ( -
- -

- {conflicts.size} event{conflicts.size !== 1 ? 's' : ''} with schedule conflicts -

-
- )} - - {dateGroups.map((group) => ( -
-
-
-

- {group.label} -

-
-
- -
- {group.events.map((event) => { - const hasConflict = conflicts.has(event.id); - const vibeColor = VIBE_COLORS[event.vibe] || VIBE_COLORS['default']; - const timeDisplay = event.isAllDay - ? 'All Day' - : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`; - const dropIndicator = getDropIndicator(event.id); - const isBeingDragged = dragId === event.id; - - return ( -
registerItemRef(event.id, el)} - {...getItemProps(event.id)} - className="relative" - > - {/* Drop indicator - above */} - {dropIndicator.showAbove && ( -
- )} - -
- {hasConflict && ( -
- - - Schedule conflict - -
- )} - -
-
- -
-

- {event.name} -

-
- {passesNowFilter(event, getConferenceNow(activeConference)) && ( - - )} - {event.link && ( - - - - )} - - -
-
- - {event.organizer && ( -

By {event.organizer}

- )} - -

{timeDisplay}

- - {event.address && ( - - {event.address} - - )} - -
- {event.vibe && ( - - {event.vibe} - - )} - {event.isFree && ( - - FREE - - )} -
-
- - {/* Drop indicator - below */} - {dropIndicator.showBelow && ( -
- )} -
- ); - })} -
-
- ))} - -
- sheeets.xyz — side event guide -
-
- - {/* Clear button */} -
- {showClearConfirm ? ( -
- Clear all events? - - -
- ) : ( - - )} -
-
- )} - - setShowShareCard(false)} - events={itineraryEvents} - conferenceName={activeConference || 'Itinerary'} - displayName={profile?.display_name ?? null} - avatarUrl={profile?.avatar_url} - hiddenEventIds={hiddenEvents} - /> -
- ); +export default function ItineraryRedirect() { + redirect('/plan'); } diff --git a/src/app/itinerary/s/[code]/page.tsx b/src/app/itinerary/s/[code]/page.tsx index 957540fc..63d72a11 100644 --- a/src/app/itinerary/s/[code]/page.tsx +++ b/src/app/itinerary/s/[code]/page.tsx @@ -1,351 +1,5 @@ -'use client'; +import { redirect } from 'next/navigation'; -import { useEffect, useMemo, useState, useCallback } from 'react'; -import { useParams } from 'next/navigation'; -import Link from 'next/link'; -import { AddressLink } from '@/components/AddressLink'; -import dynamic from 'next/dynamic'; -import { ArrowLeft, Calendar, Map as MapIcon, List, Copy, Check } from 'lucide-react'; -import clsx from 'clsx'; -import { useEvents } from '@/hooks/useEvents'; -import { useItinerary } from '@/hooks/useItinerary'; -import { useAuth } from '@/contexts/AuthContext'; -import { supabase } from '@/lib/supabase'; -import { VIBE_COLORS } from '@/lib/tags'; -import { formatDateLabel } from '@/lib/utils'; -import { sortByStartTime } from '@/lib/time-parse'; -import type { ETHDenverEvent } from '@/lib/types'; -import { Loading } from '@/components/Loading'; -import { AuthModal } from '@/components/AuthModal'; - -const MapView = dynamic( - () => import('@/components/MapView').then((mod) => ({ default: mod.MapView })), - { - ssr: false, - loading: () => ( -
-
Loading map...
-
- ), - } -); - -type SharedViewMode = 'list' | 'map'; - -export default function SharedItineraryPage() { - const params = useParams(); - const code = params.code as string; - - const { events, loading: eventsLoading } = useEvents(); - const { itinerary, addMany, toggle: toggleItinerary } = useItinerary(); - const { user } = useAuth(); - - const [sharedEventIds, setSharedEventIds] = useState(null); - const [loadingShare, setLoadingShare] = useState(true); - const [notFound, setNotFound] = useState(false); - const [viewMode, setViewMode] = useState('list'); - const [copyStatus, setCopyStatus] = useState<'idle' | 'copied'>('idle'); - const [showAuth, setShowAuth] = useState(false); - const [pendingCopy, setPendingCopy] = useState(false); - - // Fetch shared itinerary from Supabase - useEffect(() => { - async function fetchShared() { - try { - const { data, error } = await supabase - .from('shared_itineraries') - .select('event_ids') - .eq('short_code', code) - .maybeSingle(); - - if (error || !data) { - setNotFound(true); - } else { - setSharedEventIds(data.event_ids); - } - } catch { - setNotFound(true); - } - setLoadingShare(false); - } - - if (code) fetchShared(); - }, [code]); - - // Handle pending copy after auth - useEffect(() => { - if (pendingCopy && user && sharedEventIds) { - addMany(sharedEventIds); - setCopyStatus('copied'); - setPendingCopy(false); - setTimeout(() => setCopyStatus('idle'), 2500); - } - }, [pendingCopy, user, sharedEventIds, addMany]); - - const sharedEvents = useMemo(() => { - if (!sharedEventIds) return []; - const idSet = new Set(sharedEventIds); - return events.filter((e) => idSet.has(e.id)); - }, [events, sharedEventIds]); - - const dateGroups = useMemo(() => { - const groupMap = new Map(); - for (const event of sharedEvents) { - const key = event.dateISO || 'unknown'; - if (!groupMap.has(key)) groupMap.set(key, []); - groupMap.get(key)!.push(event); - } - return Array.from(groupMap.entries()) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([dateISO, groupEvents]) => ({ - dateISO, - label: dateISO === 'unknown' ? 'Date TBD' : formatDateLabel(dateISO), - events: groupEvents.sort(sortByStartTime), - })); - }, [sharedEvents]); - - const dateRange = useMemo(() => { - if (dateGroups.length === 0) return ''; - if (dateGroups.length === 1) return dateGroups[0].label; - return `${dateGroups[0].label} - ${dateGroups[dateGroups.length - 1].label}`; - }, [dateGroups]); - - const handleCopyToItinerary = useCallback(() => { - if (!sharedEventIds || sharedEventIds.length === 0) return; - - if (!user) { - setPendingCopy(true); - setShowAuth(true); - return; - } - - addMany(sharedEventIds); - setCopyStatus('copied'); - setTimeout(() => setCopyStatus('idle'), 2500); - }, [sharedEventIds, user, addMany]); - - const handleAuthClose = useCallback(() => { - setShowAuth(false); - // If user didn't sign in, cancel pending copy - if (!user) { - setPendingCopy(false); - } - }, [user]); - - const loading = eventsLoading || loadingShare; - - if (loading) { - return ( -
- -
- ); - } - - if (notFound) { - return ( -
-

Itinerary not found

-

This share link may have expired or is invalid.

- - Browse Events - -
- ); - } - - return ( -
- {/* Header */} -
-
-
- - - -
-

Shared Itinerary

-

- {sharedEvents.length} event{sharedEvents.length !== 1 ? 's' : ''} - {dateRange && ` · ${dateRange}`} -

-
-
-
- {sharedEvents.length > 0 && ( - <> - {/* View toggle */} -
- {([ - { mode: 'list' as const, icon: List, label: 'List' }, - { mode: 'map' as const, icon: MapIcon, label: 'Map' }, - ]).map(({ mode, icon: Icon, label }) => ( - - ))} -
- - )} -
-
-
- - {/* Copy to itinerary banner */} - {sharedEvents.length > 0 && ( -
-
-

- Add these events to your itinerary -

- -
-
- )} - - {/* Content */} - {sharedEvents.length === 0 ? ( -
- -

No matching events found

-

- The events in this itinerary may no longer be available. -

- - Browse Events - -
- ) : viewMode === 'map' ? ( -
- -
- ) : ( -
- {dateGroups.map((group) => ( -
-
-
-

- {group.label} -

-
-
- -
- {group.events.map((event) => { - const vibeColor = VIBE_COLORS[event.vibe] || VIBE_COLORS['default']; - const timeDisplay = event.isAllDay - ? 'All Day' - : `${event.startTime}${event.endTime ? ` - ${event.endTime}` : ''}`; - - return ( -
-

- {event.link ? ( - - {event.name} - - ) : ( - event.name - )} -

- - {event.organizer && ( -

By {event.organizer}

- )} - -

{timeDisplay}

- - {event.address && ( - - {event.address} - - )} - -
- {event.vibe && ( - - {event.vibe} - - )} - {event.isFree && ( - - FREE - - )} -
-
- ); - })} -
-
- ))} - -
- sheeets.xyz — side event guide -
-
- )} - - -
- ); +export default function SharedItineraryRedirect({ params }: { params: { code: string } }) { + redirect(`/plan/s/${params.code}`); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index e550247a..cd64c88f 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -51,11 +51,8 @@ export default function RootLayout({ const websiteJsonLd = buildWebSiteJsonLd(); return ( - + -