diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index ca9b872f..03fd275c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -7,19 +7,20 @@ import { fetchEvents } from '@/lib/fetch-events'; import { FALLBACK_TABS } from '@/lib/constants'; import { THEME_OPTIONS, type ThemeId } from '@/lib/themes'; import type { ETHDenverEvent } from '@/lib/types'; -import type { AdminConfig, SponsorEntry, NativeAd, UpsellCopy, AdInventoryItem, AdvertisePageConfig, ABTest, ABTestVariant, ABTestStatus, ABVariantResult, ConferenceConfig } from '@/lib/types'; +import type { AdminConfig, SponsorEntry, NativeAd, SidebarAd, UpsellCopy, AdInventoryItem, AdvertisePageConfig, ABTest, ABTestVariant, ABTestStatus, ABVariantResult, ConferenceConfig } from '@/lib/types'; import { isConferencePast, conferenceToTab } from '@/lib/conferences'; import type { TabConfig } from '@/lib/conferences'; const SESSION_KEY = 'sheeets-admin-auth'; -type AdminTab = 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics'; +type AdminTab = 'featured' | 'conferences' | 'sponsors' | 'nativeAds' | 'sidebarAds' | 'upsell' | 'adInventory' | 'theme' | 'abTests' | 'adReports' | 'eventAnalytics'; const TAB_LABELS: { key: AdminTab; label: string }[] = [ { key: 'featured', label: 'Featured' }, { key: 'conferences', label: 'Conferences' }, { key: 'sponsors', label: 'Sponsors' }, { key: 'nativeAds', label: 'Native Ads' }, + { key: 'sidebarAds', label: 'Sidebar Ads' }, { key: 'upsell', label: 'Upsell Copy' }, { key: 'adInventory', label: 'Ad Inventory' }, { key: 'theme', label: 'Theme' }, @@ -122,6 +123,12 @@ export default function AdminPage() { const [adDragIndex, setAdDragIndex] = useState(null); const [adDragOverIndex, setAdDragOverIndex] = useState(null); + // Sidebar Ads state + const [sidebarAds, setSidebarAds] = useState([]); + const [editingSidebarAdId, setEditingSidebarAdId] = useState(null); + const [sidebarAdDragIndex, setSidebarAdDragIndex] = useState(null); + const [sidebarAdDragOverIndex, setSidebarAdDragOverIndex] = useState(null); + // Per-card A/B variant view state const [abVariantView, setAbVariantView] = useState>({}); const [adAbVariantView, setAdAbVariantView] = useState>({}); @@ -256,6 +263,7 @@ export default function AdminPage() { setAdminConfig(data); setSponsors(data.sponsors || []); setNativeAds(data.native_ads || []); + setSidebarAds((data.sidebar_ads as SidebarAd[]) || []); setUpsellCopy(data.upsell_copy || { heading: '', body: '', cta_text: '', cta_url: '' }); setAbTests((data.ab_tests as ABTest[]) || []); const savedConfs = (data.conferences as ConferenceConfig[]) || []; @@ -1667,6 +1675,223 @@ export default function AdminPage() { )} + {/* Tab: Sidebar Ads */} + {activeTab === 'sidebarAds' && ( +
+ {configLoading ? ( +
+ + Loading config... +
+ ) : ( + <> +
+

Sidebar Ads ({sidebarAds.length})

+ +
+ + {sidebarAds.length === 0 && ( +

No sidebar ads configured

+ )} + +
+ {sidebarAds.map((ad, adIdx) => ( +
{ + setSidebarAdDragIndex(adIdx); + e.dataTransfer.effectAllowed = 'move'; + }} + onDragOver={(e) => { + e.preventDefault(); + if (sidebarAdDragIndex !== null && sidebarAdDragIndex !== adIdx) { + setSidebarAdDragOverIndex(adIdx); + } + }} + onDragLeave={() => { + if (sidebarAdDragOverIndex === adIdx) setSidebarAdDragOverIndex(null); + }} + onDrop={(e) => { + e.preventDefault(); + if (sidebarAdDragIndex !== null && sidebarAdDragIndex !== adIdx) { + const updated = [...sidebarAds]; + const [moved] = updated.splice(sidebarAdDragIndex, 1); + updated.splice(adIdx, 0, moved); + // Re-assign sortOrder after drag + updated.forEach((a, i) => { a.sortOrder = i + 1; }); + setSidebarAds(updated); + } + setSidebarAdDragIndex(null); + setSidebarAdDragOverIndex(null); + }} + onDragEnd={() => { + setSidebarAdDragIndex(null); + setSidebarAdDragOverIndex(null); + }} + > + {editingSidebarAdId === ad.id ? ( +
+
+ {ad.id.slice(0, 8)}... + +
+
+ + setSidebarAds(sidebarAds.map(a => a.id === ad.id ? { ...a, title: e.target.value } : a))} + className={inputClass} + placeholder="Ad title / alt text" + /> +
+
+ + setSidebarAds(sidebarAds.map(a => a.id === ad.id ? { ...a, imageUrl: e.target.value } : a))} + className={inputClass} + placeholder="https://..." + /> +
+ {ad.imageUrl && ( +
+ {ad.title} +
+ )} +
+ + setSidebarAds(sidebarAds.map(a => a.id === ad.id ? { ...a, link: e.target.value } : a))} + className={inputClass} + placeholder="https://..." + /> +
+
+
+ + +
+
+ + setSidebarAds(sidebarAds.map(a => a.id === ad.id ? { ...a, sortOrder: Number(e.target.value) } : a))} + className={inputClass} + /> +
+
+
+ +
+
+ ) : ( +
+ + {ad.imageUrl && ( +
+ {ad.title} +
+ )} +
+
+ {ad.title || '(untitled)'} + + {ad.active ? 'Active' : 'Inactive'} +
+

{ad.link || '(no link)'}

+

{ad.conference || 'All'} · Sort: {ad.sortOrder}

+
+
+ + +
+
+ )} +
+ ))} +
+ + + + + )} +
+ )} + {/* Tab 4: Upsell Copy */} {activeTab === 'upsell' && (
diff --git a/src/app/ads/AdvertiseContent.tsx b/src/app/ads/AdvertiseContent.tsx index f4f37435..810827bb 100644 --- a/src/app/ads/AdvertiseContent.tsx +++ b/src/app/ads/AdvertiseContent.tsx @@ -130,6 +130,24 @@ const DEFAULT_INVENTORY: AdInventoryItem[] = [ available: true, sortOrder: 4, }, + { + id: 'sidebar-image-ad', + title: 'Sidebar Image Ad', + slug: 'sidebar-image-ad', + description: + 'Fixed 300px image ad in the right sidebar of the table view on desktop. Always visible while users browse events -- premium visibility with IAB-standard sizing.', + price: 'From $400', + priceNote: 'per conference', + stats: 'Desktop xl+ breakpoint', + features: [ + '300px wide image creative', + 'Sticky sidebar placement', + 'Impression & click tracking', + 'Per-conference targeting', + ], + available: true, + sortOrder: 5, + }, { id: 'itinerary-banner', title: 'Itinerary Sponsor Banner', @@ -145,7 +163,7 @@ const DEFAULT_INVENTORY: AdInventoryItem[] = [ 'Full-width banner format', ], available: true, - sortOrder: 5, + sortOrder: 6, }, { id: 'custom-package', diff --git a/src/components/EventApp.tsx b/src/components/EventApp.tsx index 72b316f4..2ed014d4 100644 --- a/src/components/EventApp.tsx +++ b/src/components/EventApp.tsx @@ -32,9 +32,11 @@ import { AuthModal } from './AuthModal'; import { SubmitEventModal } from './SubmitEventModal'; import { FriendsPanel } from './FriendsPanel'; import { SponsorsTicker } from './SponsorsTicker'; +import SidebarAdColumn from './SidebarAdColumn'; import { OnboardingWizard } from './OnboardingWizard'; import { STORAGE_KEYS } from '@/lib/storage-keys'; import { getTabConfig } from '@/lib/conferences'; +import type { SidebarAd } from '@/lib/types'; export function EventApp({ initialConference }: { initialConference?: string }) { const { config } = useAdminConfig(); @@ -171,6 +173,14 @@ export function EventApp({ initialConference }: { initialConference?: string }) [config?.native_ads, visitorId] ); + // Sidebar ads: filter by current conference and active status + const filteredSidebarAds = useMemo(() => { + const allSidebarAds = (config?.sidebar_ads || []) as SidebarAd[]; + return allSidebarAds + .filter(ad => ad.active && (!ad.conference || ad.conference === '' || ad.conference === 'all' || ad.conference === filters.conference)) + .sort((a, b) => a.sortOrder - b.sortOrder); + }, [config?.sidebar_ads, filters.conference]); + // A/B Testing: find running tests by placement const abTests = useMemo(() => { const tests = (config as Record)?.ab_tests as ABTest[] | undefined; @@ -396,22 +406,25 @@ export function EventApp({ initialConference }: { initialConference?: string }) /> ) : viewMode === 'table' ? ( -
- +
+
+ +
+
) : (
diff --git a/src/components/SidebarAdColumn.tsx b/src/components/SidebarAdColumn.tsx new file mode 100644 index 00000000..75521dc9 --- /dev/null +++ b/src/components/SidebarAdColumn.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { useEffect, useRef, useCallback } from 'react'; +import type { SidebarAd } from '@/lib/types'; +import { trackAdClick, trackAdImpression } from '@/lib/analytics'; +import { trackAdEvent } from '@/lib/ad-tracking'; + +interface SidebarAdColumnProps { + ads: SidebarAd[]; + conference?: string; +} + +function SidebarAdItem({ ad, conference }: { ad: SidebarAd; conference?: string }) { + const adRef = useRef(null); + const impressionTracked = useRef(false); + + // IntersectionObserver for impression tracking + useEffect(() => { + const el = adRef.current; + if (!el || impressionTracked.current) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !impressionTracked.current) { + impressionTracked.current = true; + // GA4 tracking + trackAdImpression('sidebar'); + // Supabase per-ad tracking + trackAdEvent({ + ad_id: ad.id, + ad_name: ad.title, + placement: 'sidebar', + event_type: 'impression', + conference, + }); + observer.disconnect(); + } + }, + { threshold: 0.5 } + ); + + observer.observe(el); + return () => observer.disconnect(); + }, [ad.id, ad.title, conference]); + + const handleClick = useCallback(() => { + // GA4 tracking + trackAdClick('sidebar', ad.link); + // Supabase per-ad tracking + trackAdEvent({ + ad_id: ad.id, + ad_name: ad.title, + placement: 'sidebar', + event_type: 'click', + url: ad.link, + conference, + }); + }, [ad.id, ad.title, ad.link, conference]); + + return ( + + {ad.title} + + Sponsored + + + ); +} + +export default function SidebarAdColumn({ ads, conference }: SidebarAdColumnProps) { + if (!ads || ads.length === 0) return null; + + return ( + + ); +} diff --git a/src/lib/ad-tracking.ts b/src/lib/ad-tracking.ts index 8e9e9121..83655586 100644 --- a/src/lib/ad-tracking.ts +++ b/src/lib/ad-tracking.ts @@ -15,7 +15,7 @@ import { getVisitorId } from './ab-testing'; export interface AdTrackParams { ad_id: string; ad_name?: string; - placement: 'native-ad' | 'sponsor-ticker' | 'featured-event' | 'profile'; + placement: 'native-ad' | 'sponsor-ticker' | 'featured-event' | 'profile' | 'sidebar'; event_type: 'impression' | 'click'; conference?: string; url?: string; diff --git a/src/lib/types.ts b/src/lib/types.ts index 6cb7ac8d..d46b5e32 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -165,6 +165,16 @@ export interface NativeAd { }; } +export interface SidebarAd { + id: string; + title: string; // Alt text / tooltip + imageUrl: string; // Full image URL (300px wide creative) + link: string; // Click-through URL + conference: string; // Per-conference targeting (or 'all') + active: boolean; + sortOrder: number; // For manual ordering +} + export interface UpsellCopy { heading: string; body: string; @@ -211,6 +221,7 @@ export interface AdminConfig { sponsors: SponsorEntry[]; sponsors_cta: { text: string }; native_ads: NativeAd[]; + sidebar_ads?: SidebarAd[]; upsell_copy: UpsellCopy; ad_inventory?: AdInventoryItem[]; advertise_page?: AdvertisePageConfig;