ads + i18n: forum/newsletter banners; smarter language autodetect
Forum:
- /forum landing: switch to 2-col layout, add SidebarAdStack
(300x600 + every 300x250) on the right.
- /forum/thread/[id]: insert one 300x250 "Sponsored" tile after
every 4 replies (idx 3, 7, 11 …). Trailing-ad guard so the last
reply isn't followed by an ad with no replies after it. Cycles
through the full ADS_300X250 pool by index so every banner gets
impressions across long threads.
Newsletter:
- /newsletter/archive: drop SidebarAdStack into the existing
right rail above the signup CTA + topic browser.
LanguageSwitcher autodetect (US default for US users was failing):
1. Timezone override: if the user is in America/*, Pacific/Honolulu,
Pacific/Auckland|Wellington, Australia/*, Europe/London|Dublin,
or Atlantic/Reykjavik — always return 'en'. Fixes US-based
users whose OS locale happened to be set to a non-English value.
2. Otherwise walk navigator.languages (the full preference list,
not just navigator.language[0]) and pick the first base code we
support (en/es/pt/fr/de/zh/ja/ar/hi).
3. Fallback to 'en'.
All non-en selections still route through Google Translate's hosted
proxy on the .translate.goog subdomain — search engines and US
visitors continue to only ever see English on broadcastbeat.com.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import Link from 'next/link';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import AdImage from '@/components/AdImage';
|
||||
import SidebarAdStack from '@/components/SidebarAdStack';
|
||||
import { ADS_728X90, shuffle } from '@/lib/ads';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
@@ -274,6 +275,8 @@ export default function ForumIndexPage() {
|
||||
</div>
|
||||
|
||||
<div className="max-w-container mx-auto px-4 py-6">
|
||||
<div className="lg:grid lg:grid-cols-[1fr,300px] lg:gap-8">
|
||||
<div>
|
||||
|
||||
{/* Feed Mode Tabs */}
|
||||
<div className="flex items-center gap-1 bg-[#1a1a1a] border border-[#2a2a2a] rounded-lg p-1 mb-6 w-fit">
|
||||
@@ -576,6 +579,15 @@ export default function ForumIndexPage() {
|
||||
<AdImage ad={shuffle(ADS_728X90)[0]} page="forum" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
{/* Sidebar: 300x600 + every rotating 300x250 */}
|
||||
<aside className="mt-10 lg:mt-0" aria-label="Advertisements">
|
||||
<div className="lg:sticky lg:top-24">
|
||||
<SidebarAdStack />
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
|
||||
@@ -4,8 +4,23 @@ import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import Header from '@/components/Header';
|
||||
import Footer from '@/components/Footer';
|
||||
import AdImage from '@/components/AdImage';
|
||||
import { ADS_300X250, rotateAll } from '@/lib/ads';
|
||||
import { createClient } from '@/lib/supabase/client';
|
||||
|
||||
// Inline ad slot inserted between forum replies every N posts.
|
||||
function InThreadAd({ adIndex }: { adIndex: number }) {
|
||||
const pool = ADS_300X250;
|
||||
if (pool.length === 0) return null;
|
||||
const ad = pool[adIndex % pool.length];
|
||||
return (
|
||||
<div className="bg-[#0d1117] border border-dashed border-[#2a3a50] rounded-lg p-4 my-3 flex flex-col items-center gap-2" aria-label="Advertisement">
|
||||
<span className="text-[10px] font-mono uppercase tracking-[0.18em] text-[#6b7280]">Sponsored</span>
|
||||
<AdImage ad={ad} page="forum-thread" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ForumThread {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -362,42 +377,48 @@ export default function ForumThreadPage() {
|
||||
{replies.length > 0 && (
|
||||
<div className="space-y-3 mb-6">
|
||||
{replies.map((reply, idx) => (
|
||||
<div
|
||||
key={reply.id}
|
||||
className={`border rounded-lg p-5 ${
|
||||
reply.is_ai_response
|
||||
? 'bg-[#0d1f35] border-[#1e3a5f]'
|
||||
: 'bg-[#1a1a1a] border-[#252525]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar name={reply.author_name} isAI={reply.is_ai_response} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-white font-body font-semibold text-sm">{reply.author_name}</span>
|
||||
{reply.is_ai_response && (
|
||||
<span className="text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/30 px-2 py-0.5 rounded font-body font-semibold">
|
||||
AI Assistant
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[#555] font-body text-xs">·</span>
|
||||
<span className="text-[#555] font-body text-xs">#{idx + 1}</span>
|
||||
<span className="text-[#555] font-body text-xs">·</span>
|
||||
<span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span>
|
||||
<React.Fragment key={reply.id}>
|
||||
<div
|
||||
className={`border rounded-lg p-5 ${
|
||||
reply.is_ai_response
|
||||
? 'bg-[#0d1f35] border-[#1e3a5f]'
|
||||
: 'bg-[#1a1a1a] border-[#252525]'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<Avatar name={reply.author_name} isAI={reply.is_ai_response} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
||||
<span className="text-white font-body font-semibold text-sm">{reply.author_name}</span>
|
||||
{reply.is_ai_response && (
|
||||
<span className="text-xs bg-[#3b82f6]/20 text-[#3b82f6] border border-[#3b82f6]/30 px-2 py-0.5 rounded font-body font-semibold">
|
||||
AI Assistant
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[#555] font-body text-xs">·</span>
|
||||
<span className="text-[#555] font-body text-xs">#{idx + 1}</span>
|
||||
<span className="text-[#555] font-body text-xs">·</span>
|
||||
<span className="text-[#555] font-body text-xs">{timeAgo(reply.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed whitespace-pre-wrap">{reply.body}</p>
|
||||
<VoteButtons
|
||||
targetType="reply"
|
||||
targetId={reply.id}
|
||||
upvotes={reply.upvotes ?? 0}
|
||||
downvotes={reply.downvotes ?? 0}
|
||||
voteScore={reply.vote_score ?? 0}
|
||||
isAI={reply.is_ai_response}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[#d0d0d0] font-body text-sm leading-relaxed whitespace-pre-wrap">{reply.body}</p>
|
||||
<VoteButtons
|
||||
targetType="reply"
|
||||
targetId={reply.id}
|
||||
upvotes={reply.upvotes ?? 0}
|
||||
downvotes={reply.downvotes ?? 0}
|
||||
voteScore={reply.vote_score ?? 0}
|
||||
isAI={reply.is_ai_response}
|
||||
isAuthenticated={isAuthenticated}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* In-thread 300x250 ad after every 4 replies (idx 3, 7, 11, …),
|
||||
unless it's the last reply (avoid trailing ad). */}
|
||||
{(idx + 1) % 4 === 0 && idx < replies.length - 1 && (
|
||||
<InThreadAd adIndex={Math.floor(idx / 4)} />
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div ref={repliesEndRef} />
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback, useId } from "react";
|
||||
import Link from "next/link";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import SidebarAdStack from "@/components/SidebarAdStack";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
interface ArticleBlock {
|
||||
@@ -538,6 +539,9 @@ export default function NewsletterArchivePage() {
|
||||
|
||||
{/* ── Right: Sidebar ─────────────────────────────────────────────── */}
|
||||
<aside className="lg:col-span-4 space-y-6">
|
||||
{/* 300x600 + rotating 300x250 stack */}
|
||||
<SidebarAdStack />
|
||||
|
||||
{/* Signup CTA */}
|
||||
<SignupCTA />
|
||||
|
||||
|
||||
@@ -31,11 +31,52 @@ function setLangCookie(lang: LangCode) {
|
||||
}
|
||||
|
||||
// ─── Browser language detection ──────────────────────────────────────────────
|
||||
// Strategy (in order):
|
||||
// 1. If the user's timezone is in an English-speaking region (US/CA/UK/AU/NZ),
|
||||
// always return 'en'. This avoids the case where a US user has an OS
|
||||
// locale of pt-BR or es-MX accidentally set but is clearly browsing
|
||||
// from the US.
|
||||
// 2. Otherwise walk navigator.languages (the full preference list) and
|
||||
// return the first base code we support.
|
||||
// 3. Fallback to 'en'.
|
||||
function detectBrowserLang(): LangCode {
|
||||
if (typeof navigator === 'undefined') return 'en';
|
||||
const browserLang = navigator.language?.toLowerCase().split('-')[0] || 'en';
|
||||
const supported = SUPPORTED_LANGUAGES.map(l => l.code);
|
||||
return (supported.includes(browserLang as LangCode) ? browserLang : 'en') as LangCode;
|
||||
|
||||
const supported = new Set(SUPPORTED_LANGUAGES.map(l => l.code as string));
|
||||
|
||||
// 1. Timezone → English-region override
|
||||
try {
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone || '';
|
||||
const englishRegions = [
|
||||
'America/', // US, Canada, most of Latin America (we still bias en here — see note)
|
||||
'Pacific/Honolulu',
|
||||
'Pacific/Auckland', 'Pacific/Wellington',
|
||||
'Australia/',
|
||||
'Europe/London', 'Europe/Dublin',
|
||||
'Atlantic/Reykjavik',
|
||||
];
|
||||
// For America/* we want en specifically for US/CA/etc. Latin America also
|
||||
// matches America/* — but English is widely understood and we don't have
|
||||
// localized content; better to default to en and let the user opt into
|
||||
// another language via the pulldown.
|
||||
if (englishRegions.some(prefix => tz === prefix || tz.startsWith(prefix))) {
|
||||
return 'en';
|
||||
}
|
||||
} catch {
|
||||
// Intl unavailable — fall through
|
||||
}
|
||||
|
||||
// 2. Walk navigator.languages and pick first supported
|
||||
const langList: string[] = Array.isArray(navigator.languages) && navigator.languages.length > 0
|
||||
? Array.from(navigator.languages)
|
||||
: (navigator.language ? [navigator.language] : ['en']);
|
||||
|
||||
for (const raw of langList) {
|
||||
const base = raw.toLowerCase().split('-')[0];
|
||||
if (supported.has(base)) return base as LangCode;
|
||||
}
|
||||
|
||||
return 'en';
|
||||
}
|
||||
|
||||
// ─── Language Switcher Component ─────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user