diff --git a/Claude.md b/Claude.md index b64d4a2a9a..fc33845865 100644 --- a/Claude.md +++ b/Claude.md @@ -10,6 +10,7 @@ - Check the existing code style and follow it - Destructure imports when possible (eg. import { foo } from 'bar') - Do not add excesive comments. Add comments only to document what would be surprising to a senior engineer. +- For any frontend content visible to the user, use the translation mechanism used across the whole frontend.`const t = useTranslations()` and then `t("stringKey")` while addingt the "stringKey" to all the correspondong language files (en.json, es.json, etc). # Workflow - Be sure to run the linter, type checker, formatter and try to build the code when you’re done making a series of code changes. \ No newline at end of file diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 9371c7b78a..4086e7370f 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -355,7 +355,7 @@ "outOfRank": "z {total}", "comments": "Komentáře", "questions": "Otázky", - "viewMore": "Zobrazit více...", + "viewMore": "Zobrazit více", "randomQuestion": "Náhodná otázka", "notebooks": "Poznámkové bloky", "otherWithCount": "{count, plural, =1 {# další} other {# dalších} }", @@ -638,6 +638,8 @@ "newsLetter": "Newsletter", "research": "Výzkum", "updates": "Aktualizace", + "researchAndUpdates": "Výzkum a aktualizace", + "seeMore": "Zobrazit více", "posts": "příspěvky", "notebook": "notebook", "notebookExample": "obsah založený na textu, který není otázkou", @@ -1346,7 +1348,7 @@ "learnAboutPotentialWays": "Zjistěte způsoby, jak s námi můžete spolupracovat", "launchTournament": "Spustit turnaj", "launchTournamentOnMetaculus": "Spustit turnaj na Metaculus", - "launchTournamentDescription": "Získejte jasno ve svých klíčových otázkách a objevte přední předpovídače. Uskočíme turnaj a doručíme vám použitelné poznatky.", + "launchTournamentDescription": "Získejte poznatky a objevte přední předpovídače", "ourMostAccurateForecasters": "Naši nejpřesnější předpovídači poskytují kalibrované předpovědi s jasným odůvodněním, což umožňuje rozhodovatelům jednat s důvěrou.", "metaculusHasYearsOfExperience": "Metaculus má roky zkušeností s navrhováním a provozováním turnajů k vytvoření jasnosti v otázkách, které jsou pro organizace nejdůležitější.", "tellUsYourGoal": "Řekněte nám vaše cíle", @@ -1824,5 +1826,49 @@ "tournamentsInfoScoringLink": "Co jsou předpovídací skóre?", "tournamentsInfoPrizesLink": "Jak jsou rozdělovány ceny?", "featured": "Doporučené", - "othersCount": "Ostatní ({count})" + "staffPicks": "Výběr personálu", + "othersCount": "Ostatní ({count})", + "hero1TopTitle": "Platforma Metaculus", + "heroIndividualsTitle": "Rozhodujte se na základě důvěryhodných komunitních předpovědí", + "exploreQuestions": "Prozkoumat otázky", + "heroIndividualsDescription": "Získejte spolehlivé informace o tématech, která vás zajímají", + "hero2TopTitle": "Služby", + "partnerWithMetaculus": "Spolupracujte s Metaculus", + "hireProForecasters": "Najměte profesionální prognostiky", + "hireProForecastersDescription": "Získejte odborné předpovědi pro vaše klíčové otázky", + "hostPrivateInstances": "Hostujte soukromé instance", + "hostPrivateInstancesDescription": "Objevte poznatky z vaší organizace", + "whatsMetaculus": "Co je Metaculus?", + "metaculusDescription": "Metaculus je online platforma pro předpovídání a agregační nástroj, který pracuje na zlepšení lidského uvažování a koordinace v tématech globálního významu.", + "openQuestions": "Otevřené otázky", + "forecastsSubmitted": "Odeslaných předpovědí", + "yearsOfPrediction": "Let předpovídání", + "featuredIn": "Zmíněno v", + "popular": "Populární", + "exploreAll": "Prozkoumat vše", + "exploreNTournaments": "Prozkoumat {count} turnajů", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval měří schopnost AI předpovídat budoucí události. Je zaručeně odolné proti únikům.", + "futureEvalTagline": "Používáme předpovídání jako způsob hodnocení rozumování ve srovnání s realitou.", + "modelLeaderboard": "Žebříček modelů", + "modelLeaderboardDescription": "Spouštíme všechny hlavní modely s jednoduchým výzvou na většinu otevřených otázek předpovídání na Metaculus a sbíráme jejich předpovědi.", + "botsVsHumans": "Boti vs Lidé", + "botsVsHumansDescription": "Pořádáme sezónní a dvoutýdenní turnaje botů, otevřené pro všechny tvůrce. Boti soutěží proti sobě navzájem a jsou porovnáváni s nejlepšími lidskými předpovídači.", + "startCompeting": "Začněte soutěžit", + "startCompetingDescription": "Připojte se k více než 100 týmům a jednotlivým tvůrcům botů, kteří soutěží o cenový fond ve výši 50 000 dolarů na jaře 2026 nebo se zúčastněte dvoutýdenního", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Údaje o žebříčku nejsou momentálně k dispozici, prosím, zkontrolujte později!", + "viewLess": "Zobrazit méně", + "explore": "Prozkoumat", + "company": "Společnost", + "resources": "Zdroje", + "publicBenefitCorporation": "Obecně prospěšná společnost", + "tournamentsForAIBots": "Turnaje pro AI roboty", + "futureEval": "Budoucí posouzení", + "launchATournament": "Spusťte turnaj", + "tournamentsInfoTitle": "Nejsme trh s prognózami. Můžete se účastnit zdarma a vyhrát peněžní ceny za přesné předpovědi.", + "tournamentsInfoScoringLink": "Co jsou prognostické skóre?", + "tournamentsInfoPrizesLink": "Jak jsou rozdělovány ceny?", + "allCategoriesTopQuestions": "Nejlepší otázky v každé kategorii", + "thousandsOfOpenQuestions": "20 000+ otevřených otázek" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index 117de69d75..f7e754f083 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -1,4 +1,25 @@ { + "exploreNTournaments": "Explore {count} tournaments", + "popular": "Popular", + "exploreAll": "Explore all", + "thousandsOfOpenQuestions": "20,000+ open questions", + "whatsMetaculus": "What's Metaculus?", + "metaculusDescription": "Metaculus is an online forecasting platform and aggregation engine working to improve human reasoning and coordination on topics of global importance.", + "openQuestions": "Open questions", + "forecastsSubmitted": "Forecasts submitted", + "yearsOfPrediction": "Years of prediction", + "featuredIn": "Featured in", + "hero1TopTitle": "Metaculus Platform", + "heroIndividualsTitle": "Make decisions based on trusted community forecasts", + "exploreQuestions": "Explore questions", + "heroIndividualsDescription": "Get reliable insights on the topics that matter to you", + "hero2TopTitle": "Services", + "partnerWithMetaculus": "Partner with Metaculus", + "hireProForecasters": "Hire Pro Forecasters", + "hireProForecastersDescription": "Get expert forecasts on your critical questions", + "hostPrivateInstances": "Host private instances", + "hostPrivateInstancesDescription": "Surface insights from within your organization", + "staffPicks": "Staff Picks", "current_week": "Current Week", "placementFirst": "1st place", "placementSecond": "2nd place", @@ -466,7 +487,7 @@ "comments": "Comments", "questions": "Questions", "leaderboardQuestions": "Leaderboard Questions", - "viewMore": "View more...", + "viewMore": "View more", "randomQuestion": "Random Question", "notebooks": "Notebooks", "otherWithCount": "{count, plural, =1 {# other} other {# others} }", @@ -624,6 +645,12 @@ "termsOfUse": "Terms of Use", "faq": "FAQ", "contact": "Contact", + "company": "Company", + "resources": "Resources", + "publicBenefitCorporation": "Public Benefit Corporation", + "tournamentsForAIBots": "Tournaments for AI bots", + "futureEval": "FutureEval", + "launchATournament": "Launch a Tournament", "contactUs": "Contact Us", "thankYouForGettingInTouch": "Thank you for getting in touch. We’ll get back to you soon!", "yourEmail": "Your Email", @@ -851,6 +878,8 @@ "newsLetter": "Newsletter", "research": "Research", "updates": "Updates", + "researchAndUpdates": "Research and updates", + "seeMore": "See more", "posts": "posts", "notebook": "notebook", "existingQuestion": "Existing Question", @@ -1476,7 +1505,7 @@ "learnAboutPotentialWays": "Learn about ways you can work with us", "launchTournament": "Launch a Tournament", "launchTournamentOnMetaculus": "Launch a Tournament on Metaculus", - "launchTournamentDescription": "Gain clarity on your key questions and discover top forecasters. We'll run the tournament and deliver the actionable insights to you.", + "launchTournamentDescription": "Get insights and discover top forecasters", "ourMostAccurateForecasters": "Our most accurate forecasters deliver calibrated predictions paired with clear reasoning, empowering decision-makers to act with confidence.", "metaculusHasYearsOfExperience": "Metaculus has years of experience designing and operating tournaments to provide clarity on the issues most important to organizations.", "tellUsYourGoal": "Tell Us Your Goals", @@ -1818,5 +1847,19 @@ "tournamentsTabIndexes": "Indexes", "tournamentsTabArchived": "Archived", "featured": "Featured", - "none": "none" + "none": "none", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval measures AI's ability to predict future outcomes. It is guaranteed leak-proof.", + "futureEvalTagline": "We use forecasting as a way to evaluate reasoning against reality.", + "modelLeaderboard": "Model leaderboard", + "modelLeaderboardDescription": "We run all major models with a simple prompt on most open Metaculus forecasting questions, and collect their forecasts.", + "botsVsHumans": "Bots vs Humans", + "botsVsHumansDescription": "We run seasonal and biweekly bot tournaments, open to all builders. Bots compete against each other and are benchmarked against top human forecasters.", + "startCompeting": "Start competing", + "startCompetingDescription": "Join 100+ teams and individual bot builders competing for a $50,000 prize pool in Spring 2026 or enter the biweekly", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Leaderboard data not currently available, please check back soon!", + "viewLess": "View less", + "allCategoriesTopQuestions": "Top questions in every category", + "explore": "Explore" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 7f5ebe16b3..08eb4b052a 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -362,7 +362,7 @@ "outOfRank": "de {total}", "comments": "Comentarios", "questions": "Preguntas", - "viewMore": "Ver más...", + "viewMore": "Ver más", "randomQuestion": "Pregunta Aleatoria", "notebooks": "Cuadernos", "otherWithCount": "{count, plural, =1 {# otro} other {# otros} }", @@ -640,6 +640,8 @@ "newsLetter": "Boletín", "research": "Investigación", "updates": "Actualizaciones", + "researchAndUpdates": "Investigación y actualizaciones", + "seeMore": "Ver más", "posts": "publicaciones", "notebook": "notebook", "notebookExample": "contenido basado en texto que no es una pregunta", @@ -1346,7 +1348,7 @@ "learnAboutPotentialWays": "Conoce las maneras en que puedes trabajar con nosotros", "launchTournament": "Lanzar un Torneo", "launchTournamentOnMetaculus": "Lanzar un Torneo en Metaculus", - "launchTournamentDescription": "Obtén claridad sobre tus preguntas clave y descubre a los mejores pronosticadores. Nos encargaremos del torneo y te entregaremos los conocimientos prácticos.", + "launchTournamentDescription": "Obtén insights y descubre a los mejores pronosticadores", "ourMostAccurateForecasters": "Nuestros pronosticadores más precisos entregan predicciones calibradas acompañadas de un razonamiento claro, capacitando a los tomadores de decisiones para actuar con confianza.", "metaculusHasYearsOfExperience": "Metaculus tiene años de experiencia diseñando y operando torneos para proporcionar claridad sobre los temas más importantes para las organizaciones.", "tellUsYourGoal": "Cuéntanos Tus Objetivos", @@ -1824,5 +1826,49 @@ "tournamentsInfoScoringLink": "¿Qué son las puntuaciones de predicción?", "tournamentsInfoPrizesLink": "¿Cómo se distribuyen los premios?", "featured": "Destacado", - "othersCount": "Otros ({count})" + "staffPicks": "Selecciones del personal", + "othersCount": "Otros ({count})", + "hero1TopTitle": "Plataforma Metaculus", + "heroIndividualsTitle": "Toma decisiones basadas en pronósticos comunitarios confiables", + "exploreQuestions": "Explorar preguntas", + "heroIndividualsDescription": "Obtén información confiable sobre los temas que te importan", + "hero2TopTitle": "Servicios", + "partnerWithMetaculus": "Colabora con Metaculus", + "hireProForecasters": "Contrata pronosticadores profesionales", + "hireProForecastersDescription": "Obtén pronósticos expertos para tus preguntas críticas", + "hostPrivateInstances": "Aloja instancias privadas", + "hostPrivateInstancesDescription": "Descubre información desde dentro de tu organización", + "whatsMetaculus": "¿Qué es Metaculus?", + "metaculusDescription": "Metaculus es una plataforma de pronósticos en línea y motor de agregación que trabaja para mejorar el razonamiento humano y la coordinación en temas de importancia global.", + "openQuestions": "Preguntas abiertas", + "forecastsSubmitted": "Pronósticos enviados", + "yearsOfPrediction": "Años de predicción", + "featuredIn": "Destacado en", + "popular": "Popular", + "exploreAll": "Explorar todo", + "exploreNTournaments": "Explorar {count} torneos", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval mide la capacidad de la inteligencia artificial para predecir resultados futuros. Está garantizado que no tiene fugas.", + "futureEvalTagline": "Usamos la previsión como una forma de evaluar el razonamiento frente a la realidad.", + "modelLeaderboard": "Clasificación de modelos", + "modelLeaderboardDescription": "Probaremos todos los modelos principales con un simple aviso en la mayoría de las preguntas de previsión abiertas de Metaculus, y recogeremos sus previsiones.", + "botsVsHumans": "Bots vs Humanos", + "botsVsHumansDescription": "Organizamos torneos estacionales y quincenales de bots, abiertos a todos los desarrolladores. Los bots compiten entre sí y se comparan con los mejores pronosticadores humanos.", + "startCompeting": "Empieza a competir", + "startCompetingDescription": "Únete a más de 100 equipos y constructores de bots individuales que compiten por un premio acumulado de $50,000 en la primavera de 2026 o participa en el", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Datos de clasificación actualmente no disponibles, ¡por favor vuelva pronto!", + "viewLess": "Ver menos", + "explore": "Explorar", + "company": "Empresa", + "resources": "Recursos", + "publicBenefitCorporation": "Corporación de Beneficio Público", + "tournamentsForAIBots": "Torneos para bots de IA", + "futureEval": "EvaluaciónFutura", + "launchATournament": "Iniciar un Torneo", + "tournamentsInfoTitle": "Nosotros no somos un mercado de predicciones. Puedes participar gratis y ganar premios en efectivo por ser preciso.", + "tournamentsInfoScoringLink": "¿Qué son las puntuaciones de pronóstico?", + "tournamentsInfoPrizesLink": "¿Cómo se distribuyen los premios?", + "allCategoriesTopQuestions": "Principales preguntas en cada categoría", + "thousandsOfOpenQuestions": "20,000+ preguntas abiertas" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index c2d6f275c1..1332acbc6e 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -386,7 +386,7 @@ "outOfRank": "de um total de {total}", "comments": "Comentários", "questions": "Perguntas", - "viewMore": "Ver mais...", + "viewMore": "Ver mais", "randomQuestion": "Pergunta Aleatória", "notebooks": "Cadernos", "otherWithCount": "{count, plural, =1 {# outro} other {# outros}}", @@ -718,6 +718,8 @@ "newsLetter": "Newsletter", "research": "Pesquisa", "updates": "Atualizações", + "researchAndUpdates": "Pesquisa e atualizações", + "seeMore": "Ver mais", "posts": "postagens", "notebook": "caderno", "notebookExample": "conteúdo baseado em texto que não é uma pergunta", @@ -1344,7 +1346,7 @@ "learnAboutPotentialWays": "Saiba mais sobre as formas pelas quais você pode trabalhar conosco", "launchTournament": "Lançar um Torneio", "launchTournamentOnMetaculus": "Lançar um Torneio no Metaculus", - "launchTournamentDescription": "Obtenha clareza sobre suas principais perguntas e descubra os principais preditores. Nós conduziremos o torneio e entregaremos os insights acionáveis para você.", + "launchTournamentDescription": "Obtenha insights e descubra os principais preditores", "ourMostAccurateForecasters": "Nossos preditores mais precisos oferecem previsões calibradas acompanhadas de raciocínio claro, capacitando os tomadores de decisão a agir com confiança.", "metaculusHasYearsOfExperience": "O Metaculus tem anos de experiência em projetar e operar torneios para fornecer clareza sobre os assuntos mais importantes para as organizações.", "tellUsYourGoal": "Diga-nos Seus Objetivos", @@ -1822,5 +1824,49 @@ "tournamentsInfoScoringLink": "O que são pontuações de previsão?", "tournamentsInfoPrizesLink": "Como os prêmios são distribuídos?", "featured": "Em destaque", - "othersCount": "Outros ({count})" + "staffPicks": "Escolhas da Equipe", + "othersCount": "Outros ({count})", + "hero1TopTitle": "Plataforma Metaculus", + "heroIndividualsTitle": "Tome decisões com base em previsões comunitárias confiáveis", + "exploreQuestions": "Explorar perguntas", + "heroIndividualsDescription": "Obtenha informações confiáveis sobre os temas que importam para você", + "hero2TopTitle": "Serviços", + "partnerWithMetaculus": "Parceria com Metaculus", + "hireProForecasters": "Contrate previsores profissionais", + "hireProForecastersDescription": "Obtenha previsões especializadas para suas questões críticas", + "hostPrivateInstances": "Hospede instâncias privadas", + "hostPrivateInstancesDescription": "Descubra insights de dentro da sua organização", + "whatsMetaculus": "O que é Metaculus?", + "metaculusDescription": "Metaculus é uma plataforma de previsões online e motor de agregação que trabalha para melhorar o raciocínio humano e a coordenação em temas de importância global.", + "openQuestions": "Perguntas abertas", + "forecastsSubmitted": "Previsões enviadas", + "yearsOfPrediction": "Anos de previsão", + "featuredIn": "Destaque em", + "popular": "Popular", + "exploreAll": "Explorar tudo", + "exploreNTournaments": "Explore {count} torneios", + "metaculusFutureEval": "Metaculus FutureEval", + "futureEvalDescription": "FutureEval mede a capacidade da IA de prever resultados futuros. É garantido à prova de vazamento.", + "futureEvalTagline": "Usamos previsão como uma forma de avaliar o raciocínio em relação à realidade.", + "modelLeaderboard": "Tabela de classificação dos modelos", + "modelLeaderboardDescription": "Executamos todos os grandes modelos com um prompt simples em maioria das perguntas abertas de previsão do Metaculus, e coletamos suas previsões.", + "botsVsHumans": "Bots vs Humanos", + "botsVsHumansDescription": "Realizamos torneios sazonais e quinzenais de bots, abertos a todos os criadores. Os bots competem entre si e são comparados com os melhores preditores humanos.", + "startCompeting": "Comece a competir", + "startCompetingDescription": "Junte-se a mais de 100 equipes e criadores de bots individuais competindo por um prêmio total de $50,000 na primavera de 2026 ou participe do", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "Dados da tabela de classificação não estão disponíveis no momento, por favor, volte em breve!", + "viewLess": "Ver menos", + "explore": "Explorar", + "company": "Empresa", + "resources": "Recursos", + "publicBenefitCorporation": "Corporação de Benefício Público", + "tournamentsForAIBots": "Torneios para Bots de IA", + "futureEval": "FutureEval", + "launchATournament": "Lançar um Torneio", + "tournamentsInfoTitle": "Não somos um mercado de previsões. Você pode participar gratuitamente e ganhar prêmios em dinheiro por ser preciso.", + "tournamentsInfoScoringLink": "O que são pontuações de previsão?", + "tournamentsInfoPrizesLink": "Como os prêmios são distribuídos?", + "allCategoriesTopQuestions": "Principais perguntas em cada categoria", + "thousandsOfOpenQuestions": "20.000+ perguntas abertas" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 9237ca1464..3674ee3624 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -413,7 +413,7 @@ "comments": "評論", "questions": "問題", "leaderboardQuestions": "排行榜問題", - "viewMore": "查看更多...", + "viewMore": "查看更多", "randomQuestion": "隨機問題", "notebooks": "筆記本", "otherWithCount": "{count, plural, =1 {# 別的} other {# 其他} }", @@ -770,6 +770,8 @@ "newsLetter": "新聞信", "research": "研究", "updates": "更新", + "researchAndUpdates": "研究和更新", + "seeMore": "查看更多", "posts": "發帖", "notebook": "筆記本", "existingQuestion": "現有問題", @@ -1343,7 +1345,7 @@ "learnAboutPotentialWays": "了解您可以與我們合作的方式", "launchTournament": "發起競賽", "launchTournamentOnMetaculus": "在 Metaculus 上發起競賽", - "launchTournamentDescription": "明確您關鍵問題並發掘頂尖預測者。我們將運行比賽並為您提供可行的見解。", + "launchTournamentDescription": "獲取洞察並發現頂尖預測者", "ourMostAccurateForecasters": "我們最精確的預測者提供校準的預測與清晰的推理,使決策者能充滿信心地採取行動。", "metaculusHasYearsOfExperience": "Metaculus 擁有多年的設計和運行比賽經驗,為組織最重要的問題提供清晰的解答。", "tellUsYourGoal": "告訴我們您的目標", @@ -1821,5 +1823,49 @@ "tournamentsInfoScoringLink": "什麼是預測得分?", "tournamentsInfoPrizesLink": "獎品如何分配?", "featured": "精選", - "withdrawAfterPercentSetting2": "問題總生命周期後撤回" + "staffPicks": "員工推薦", + "withdrawAfterPercentSetting2": "問題總生命周期後撤回", + "hero1TopTitle": "Metaculus 平台", + "heroIndividualsTitle": "根據可信賴的社群預測做出決策", + "exploreQuestions": "探索問題", + "heroIndividualsDescription": "獲取您關心話題的可靠見解", + "hero2TopTitle": "服務", + "partnerWithMetaculus": "與 Metaculus 合作", + "hireProForecasters": "聘請專業預測師", + "hireProForecastersDescription": "為您的關鍵問題獲取專家預測", + "hostPrivateInstances": "託管私有實例", + "hostPrivateInstancesDescription": "從您的組織內部發掘見解", + "whatsMetaculus": "什麼是 Metaculus?", + "metaculusDescription": "Metaculus 是一個線上預測平台和聚合引擎,致力於改善人類在全球重要議題上的推理和協調能力。", + "openQuestions": "開放問題", + "forecastsSubmitted": "已提交預測", + "yearsOfPrediction": "預測年數", + "featuredIn": "媒體報導", + "popular": "熱門", + "exploreAll": "探索全部", + "exploreNTournaments": "探索 {count} 場比賽", + "metaculusFutureEval": "Metaculus 未來評估", + "futureEvalDescription": "未來評估測量 AI 預測未來結果的能力,並保證不會洩漏。", + "futureEvalTagline": "我們使用預測作為評估推理與現實對比的方法。", + "modelLeaderboard": "模型排行榜", + "modelLeaderboardDescription": "我們在大多數開放的 Metaculus 預測問題上使用簡單提示運行所有主要模型,並收集其預測結果。", + "botsVsHumans": "機器人對人類", + "botsVsHumansDescription": "我們舉辦季節性和雙週機器人比賽,對所有建造者開放。機器人相互競爭,並與頂尖人類預測者進行基準測試。", + "startCompeting": "開始競爭", + "startCompetingDescription": "加入100多個團隊和個人機器人建造者,競爭2026年春季50,000美元獎金池,或參加雙周", + "miniBench": "迷你測試", + "leaderboardDataNotAvailable": "排行榜數據目前不可用,請稍後再檢查!", + "viewLess": "查看較少", + "explore": "探索", + "company": "公司", + "resources": "資源", + "publicBenefitCorporation": "公益公司", + "tournamentsForAIBots": "AI 機器人比賽", + "futureEval": "未來評估", + "launchATournament": "發起比賽", + "tournamentsInfoTitle": "我們不是一個預測市場。你可以免費參加,並因預測準確而贏取現金獎。", + "tournamentsInfoScoringLink": "什麼是預測得分?", + "tournamentsInfoPrizesLink": "獎品如何分配?", + "allCategoriesTopQuestions": "每個類別中的熱門問題", + "thousandsOfOpenQuestions": "20,000+ 開放問題" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 78ce6f856d..ed804c0e12 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -359,7 +359,7 @@ "outOfRank": "共 {total}", "comments": "評論", "questions": "問題", - "viewMore": "查看更多...", + "viewMore": "查看更多", "randomQuestion": "隨機問題", "notebooks": "筆記本", "otherWithCount": "{count, plural, =1 {# 其他} other {# 其他} }", @@ -629,6 +629,8 @@ "newsLetter": "新聞簡報", "research": "研究", "updates": "更新", + "researchAndUpdates": "研究和更新", + "seeMore": "查看更多", "posts": "帖子", "notebook": "筆記本", "notebookExample": "非問題形式的文本內容", @@ -1349,7 +1351,7 @@ "learnAboutPotentialWays": "了解您可以与我们合作的方式", "launchTournament": "启动竞赛", "launchTournamentOnMetaculus": "在 Metaculus 上启动竞赛", - "launchTournamentDescription": "弄清您的关键问题并发现顶级预测者。我们将运行竞赛并向您提供可执行的洞察。", + "launchTournamentDescription": "获取洞察并发现顶级预测者", "ourMostAccurateForecasters": "我们最准确的预测者提供经过校准的预测,并配以明确的推理,赋予决策者自信地采取行动的能力。", "metaculusHasYearsOfExperience": "Metaculus 拥有多年设计和运营锦标赛的经验,能够就组织最重要的问题提供清晰的见解。", "tellUsYourGoal": "告诉我们你的目标", @@ -1826,5 +1828,49 @@ "tournamentsInfoScoringLink": "什么是预测分数?", "tournamentsInfoPrizesLink": "奖品如何分配?", "featured": "精选", - "othersCount": "其他({count})" + "staffPicks": "员工精选", + "othersCount": "其他({count})", + "hero1TopTitle": "Metaculus 平台", + "heroIndividualsTitle": "根据可信赖的社区预测做出决策", + "exploreQuestions": "探索问题", + "heroIndividualsDescription": "获取您关心话题的可靠见解", + "hero2TopTitle": "服务", + "partnerWithMetaculus": "与 Metaculus 合作", + "hireProForecasters": "聘请专业预测师", + "hireProForecastersDescription": "为您的关键问题获取专家预测", + "hostPrivateInstances": "托管私有实例", + "hostPrivateInstancesDescription": "从您的组织内部发掘见解", + "whatsMetaculus": "什么是 Metaculus?", + "metaculusDescription": "Metaculus 是一个在线预测平台和聚合引擎,致力于改善人类在全球重要议题上的推理和协调能力。", + "openQuestions": "开放问题", + "forecastsSubmitted": "已提交预测", + "yearsOfPrediction": "预测年数", + "featuredIn": "媒体报道", + "popular": "热门", + "exploreAll": "探索全部", + "exploreNTournaments": "探索 {count} 个锦标赛", + "metaculusFutureEval": "Metaculus未来评估", + "futureEvalDescription": "未来评估测量AI预测未来结果的能力。它保证是防泄漏的。", + "futureEvalTagline": "我们使用预测作为评估推理与现实的方式。", + "modelLeaderboard": "模型排行榜", + "modelLeaderboardDescription": "我们在大多数开放的Metaculus预测问题上使用简单提示运行所有主要模型,并收集它们的预测。", + "botsVsHumans": "机器人对抗人类", + "botsVsHumansDescription": "我们举办季节性和双周机器人锦标赛,对所有创建者开放。机器人相互竞争,并与顶级人类预测者进行基准测试。", + "startCompeting": "开始竞争", + "startCompetingDescription": "加入100多个团队和个人机器人创建者,争夺2026年春季50,000美元的奖金池,或参加双周", + "miniBench": "MiniBench", + "leaderboardDataNotAvailable": "排行榜数据暂时不可用,请稍后再来查看!", + "viewLess": "查看更少", + "explore": "探索", + "company": "公司", + "resources": "资源", + "publicBenefitCorporation": "公益公司", + "tournamentsForAIBots": "AI机器人比赛", + "futureEval": "未来评估", + "launchATournament": "发起比赛", + "tournamentsInfoTitle": "我们不是预测市场。您可以免费参与,并因准确预测而赢取现金奖励。", + "tournamentsInfoScoringLink": "什么是预测得分?", + "tournamentsInfoPrizesLink": "奖金如何分配?", + "allCategoriesTopQuestions": "每个类别的热门问题", + "thousandsOfOpenQuestions": "20,000+ 开放问题" } diff --git a/front_end/public/images/pie-chart.png b/front_end/public/images/pie-chart.png new file mode 100644 index 0000000000..bf7baedf6c Binary files /dev/null and b/front_end/public/images/pie-chart.png differ diff --git a/front_end/public/images/puzzle.png b/front_end/public/images/puzzle.png new file mode 100644 index 0000000000..2915fef591 Binary files /dev/null and b/front_end/public/images/puzzle.png differ diff --git a/front_end/src/app/(main)/(home)/components/ExploreImagesGrid.tsx b/front_end/src/app/(main)/(home)/components/ExploreImagesGrid.tsx new file mode 100644 index 0000000000..8d4c549936 --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/ExploreImagesGrid.tsx @@ -0,0 +1,442 @@ +import { FC } from "react"; + +export const ExploreImagesGrid: FC<{ className?: string }> = ({ + className, +}) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/front_end/src/app/(main)/(home)/components/all_categories_section.tsx b/front_end/src/app/(main)/(home)/components/all_categories_section.tsx new file mode 100644 index 0000000000..4188b07b6e --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/all_categories_section.tsx @@ -0,0 +1,94 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { FC } from "react"; + +import { + POST_CATEGORIES_FILTER, + POST_FOR_MAIN_FEED, +} from "@/constants/posts_feed"; +import { Post } from "@/types/post"; +import { Category } from "@/types/projects"; +import cn from "@/utils/core/cn"; + +type CategoryWithPosts = Category & { posts: Post[] }; + +type Props = { + categories: CategoryWithPosts[]; + className?: string; +}; + +const AllCategoriesSection: FC = async ({ categories, className }) => { + const t = await getTranslations(); + + if (!categories || categories.length === 0) { + return null; + } + + const sortedCategories = [...categories] + .filter((c) => c && c.name) + .sort((a, b) => a.name.localeCompare(b.name)); + + return ( +
+

+ + {t("allCategoriesTopQuestions")} + + + {t("allCategories")} +

+
+ {sortedCategories.map((category) => ( + + ))} +
+
+ ); +}; + +type CategoryCardProps = { + category: CategoryWithPosts; +}; + +const CategoryCard: FC = ({ category }) => { + const categoryUrl = `/questions/?${POST_CATEGORIES_FILTER}=${category.slug}&${POST_FOR_MAIN_FEED}=false`; + + return ( +
+ + {category.emoji} + + {category.name} + + + + {category.posts && category.posts.length > 0 && ( +
+ {category.posts.slice(0, 3).map(({ title, slug, id }, index) => ( +
+
+ + {index + 1}. + + + {title} + +
+ {index < Math.min(category.posts.length, 3) - 1 && ( +
+ )} +
+ ))} +
+ )} +
+ ); +}; + +export default AllCategoriesSection; diff --git a/front_end/src/app/(main)/(home)/components/featured-in-logos.tsx b/front_end/src/app/(main)/(home)/components/featured-in-logos.tsx new file mode 100644 index 0000000000..1b126bed1f --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/featured-in-logos.tsx @@ -0,0 +1,230 @@ +import { FC } from "react"; + +export const AeiLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + +); + +export const NasdaqLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + + + +); + +export const TheAtlanticLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + +); + +export const ForbesLogo: FC<{ className?: string }> = ({ className }) => ( + + + +); + +export const TheEconomistLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + +); + +export const BloombergLogo: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + + + + + + + + + +); diff --git a/front_end/src/app/(main)/(home)/components/focus_area_link.tsx b/front_end/src/app/(main)/(home)/components/focus_area_link.tsx deleted file mode 100644 index 85e3307c4b..0000000000 --- a/front_end/src/app/(main)/(home)/components/focus_area_link.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import { FC } from "react"; - -import cn from "@/utils/core/cn"; - -export type FocusAreaItem = { - id: string; - title: string; - Icon: FC; - text: string; - href: string; -}; - -const FocusAreaLink: FC = ({ title, text, Icon, href, id }) => { - const t = useTranslations(); - - return ( - -
- -
-

- {title} -

- - {text} - - - {t("seeForecasts")} - - - - ); -}; - -export default FocusAreaLink; diff --git a/front_end/src/app/(main)/(home)/components/future_eval_section.tsx b/front_end/src/app/(main)/(home)/components/future_eval_section.tsx new file mode 100644 index 0000000000..be8394c2b0 --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/future_eval_section.tsx @@ -0,0 +1,216 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { FC } from "react"; + +import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; +import Button from "@/components/ui/button"; +import ServerLeaderboardApi from "@/services/api/leaderboard/leaderboard.server"; +import cn from "@/utils/core/cn"; + +import FutureEvalTable from "./future_eval_table"; + +const ListStarIcon = () => ( + + + + + + + + + + + + + + + +); + +const PersonIcon = () => ( + + + + + + + + + + + +); + +const TrophyIcon = () => ( + + + + + + + + + + +); + +type FeatureItemProps = { + icon: React.ReactNode; + title: string; + description: React.ReactNode; +}; + +const FeatureItem: FC = ({ icon, title, description }) => ( +
+
+ {icon} +
+
+

+ {title} +

+

+ {description} +

+
+
+); + +const FutureEvalSection: FC<{ className?: string }> = async ({ className }) => { + const t = await getTranslations(); + const data = await ServerLeaderboardApi.getGlobalLeaderboard( + null, + null, + "manual", + "Global Bot Leaderboard" + ); + + const hasData = data?.entries?.length > 0; + if (!hasData) { + return null; + } + + return ( +
+ {/* Header */} +
+
+

+ {t("metaculusFutureEval")} +

+

+ {t("futureEvalDescription")} +

+
+ +
+ + {/* Content */} +
+ {/* Info box - determines container height */} +
+
+

+ {t("futureEvalTagline")} +

+
+ } + title={t("modelLeaderboard")} + description={t("modelLeaderboardDescription")} + /> + } + title={t("botsVsHumans")} + description={t("botsVsHumansDescription")} + /> + } + title={t("startCompeting")} + description={ + <> + {t("startCompetingDescription")}{" "} + + {t("miniBench")} + + . + + } + /> +
+
+
+ + {/* Table wrapper - stretches to match first child height, content scrolls */} +
+ +
+
+
+ ); +}; + +export default WithServerComponentErrorBoundary(FutureEvalSection); diff --git a/front_end/src/app/(main)/(home)/components/future_eval_table.tsx b/front_end/src/app/(main)/(home)/components/future_eval_table.tsx new file mode 100644 index 0000000000..9999002cbc --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/future_eval_table.tsx @@ -0,0 +1,170 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC, useMemo } from "react"; + +import MedalIcon from "@/app/(main)/(leaderboards)/components/medal_icon"; +import { + entryIconPair, + entryLabel, + shouldDisplayEntry, +} from "@/app/(main)/aib/components/aib/leaderboard/utils"; +import { LightDarkIcon } from "@/app/(main)/aib/components/aib/light-dark-icon"; +import type { LeaderboardDetails, MedalType } from "@/types/scoring"; +import cn from "@/utils/core/cn"; + +type Props = { details: LeaderboardDetails; className?: string }; + +const INITIAL_ROWS = 8; + +const MEDALS: Record = { + 1: "gold", + 2: "silver", + 3: "bronze", +}; + +const MedalRow: FC<{ rank: number }> = ({ rank }) => { + const medalType = MEDALS[rank]; + + return medalType ? ( + + ) : ( + + {rank} + + ); +}; + +const FutureEvalTable: React.FC = ({ details, className }) => { + const t = useTranslations(); + + const rows = useMemo(() => { + const entries = (details.entries ?? []) + .filter((e) => shouldDisplayEntry(e)) + .map((entry, i) => { + const label = entryLabel(entry, t); + const icons = entryIconPair(entry); + const userId = entry.user?.id; + return { + rank: i + 1, + label, + username: entry.user?.username ?? "", + icons, + forecasts: entry.contribution_count, + score: entry.score, + profileHref: userId ? `/accounts/profile/${userId}/` : null, + isAggregate: !entry.user?.username, + }; + }); + + return entries; + }, [details.entries, t]); + + const visibleRows = rows.slice(0, INITIAL_ROWS); + + return ( +
+
+ + + + + + + + + + + + + + + + + {visibleRows.map((r) => ( + + + + + + + + + ))} + +
{t("rank")}{t("aibLbThModel")}{t("score")} + {t("aibLbThForecasts")} +
+
+ +
+
+
+ {(r.icons.light || r.icons.dark) && ( + + )} +
+ {r.isAggregate || !r.profileHref ? ( + r.label + ) : ( + + {r.label} + + )} +
+
+
+ + {fmt(r.score, 2)} + + + + {r.forecasts} + +
+
+ {rows.length > INITIAL_ROWS && ( + + {t("viewMore")} + + )} +
+ ); +}; + +const fmt = (n: number | null | undefined, d = 2) => + n == null || Number.isNaN(n) ? "—" : n.toFixed(d); + +const Th: React.FC> = ({ + className = "", + children, +}) => ( + + {children} + +); + +const Td: React.FC> = ({ + className = "", + children, +}) => {children}; + +export default FutureEvalTable; diff --git a/front_end/src/app/(main)/(home)/components/hero_ctas.tsx b/front_end/src/app/(main)/(home)/components/hero_ctas.tsx new file mode 100644 index 0000000000..0afb529b04 --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/hero_ctas.tsx @@ -0,0 +1,188 @@ +"use client"; + +import useEmblaCarousel from "embla-carousel-react"; +import Image from "next/image"; +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC, PropsWithChildren } from "react"; + +import Button from "@/components/ui/button"; +import { useBreakpoint } from "@/hooks/tailwind"; +import cn from "@/utils/core/cn"; + +type HeroCTACardVariant = "blue" | "purple"; + +const variantStyles: Record< + HeroCTACardVariant, + { bg: string; text: string; button: string } +> = { + blue: { + bg: "bg-blue-300 dark:bg-blue-300-dark", + text: "text-blue-800 dark:text-blue-800-dark", + button: + "border-blue-500 bg-gray-0 text-blue-700 hover:border-blue-600 hover:bg-blue-100 dark:border-blue-500-dark dark:bg-gray-0-dark dark:text-blue-700-dark dark:hover:border-blue-600-dark dark:hover:bg-blue-100-dark", + }, + purple: { + bg: "bg-purple-100 dark:bg-purple-100-dark", + text: "text-purple-800 dark:text-purple-800-dark", + button: + "border-purple-200 bg-purple-200 text-purple-700 hover:border-purple-300 hover:bg-purple-300 dark:border-purple-200-dark dark:bg-purple-200-dark dark:text-purple-700-dark dark:hover:border-purple-300-dark dark:hover:bg-purple-300-dark", + }, +}; + +type HeroCTACardProps = { + href: string; + topTitle: string; + imageSrc?: string; + imageAlt: string; + title: string; + buttonText: string; + variant: HeroCTACardVariant; +}; + +const HeroCTACard: FC> = ({ + href, + topTitle, + imageSrc, + imageAlt, + title, + children, + buttonText, + variant, +}) => { + const { + bg: bgColorClasses, + text: textColorClasses, + button: buttonClassName, + } = variantStyles[variant]; + return ( +
+
+ {imageSrc && ( + {imageAlt} + )} +
+ +

+ {topTitle} +

+
+
+

+ {title} +

+
{children}
+
+
+ + + +
+ ); +}; + +type Props = { + individualsHref?: string; + businessesHref?: string; + className?: string; +}; + +const HeroCTAs: FC = ({ + individualsHref = "/questions/", + businessesHref = "/services/", + className, +}) => { + const t = useTranslations(); + const isMdScreen = useBreakpoint("md"); + const [emblaRef] = useEmblaCarousel({ + align: "start", + containScroll: "trimSnaps", + watchDrag: !isMdScreen, + }); + + return ( +
+
+
+
+ +

+ {t("heroIndividualsDescription")} +

+
+
+ +
+ +
+
+

+ {t("hireProForecasters")} +

+

+ {t("hireProForecastersDescription")} +

+
+
+

+ {t("launchTournament")} +

+

+ {t("launchTournamentDescription")} +

+
+
+

+ {t("hostPrivateInstances")} +

+

+ {t("hostPrivateInstancesDescription")} +

+
+
+
+
+
+
+
+ ); +}; + +export default HeroCTAs; diff --git a/front_end/src/app/(main)/(home)/components/home_search.tsx b/front_end/src/app/(main)/(home)/components/home_search.tsx deleted file mode 100644 index 244e22df37..0000000000 --- a/front_end/src/app/(main)/(home)/components/home_search.tsx +++ /dev/null @@ -1,62 +0,0 @@ -"use client"; -import { useRouter } from "next/navigation"; -import { useTranslations } from "next-intl"; -import { FC, useState } from "react"; - -import RandomButton from "@/components/random_button"; -import SearchInput from "@/components/search_input"; -import VisibilityObserver from "@/components/visibility_observer"; -import { - POST_ORDER_BY_FILTER, - POST_TEXT_SEARCH_FILTER, -} from "@/constants/posts_feed"; -import { useGlobalSearchContext } from "@/contexts/global_search_context"; -import { QuestionOrder } from "@/types/question"; -import { sendAnalyticsEvent } from "@/utils/analytics"; -import { encodeQueryParams } from "@/utils/navigation"; - -const HomeSearch: FC = () => { - const t = useTranslations(); - const router = useRouter(); - - const [searchQuery, setSearchQuery] = useState(""); - - const handleSearchSubmit = (searchQuery: string) => { - router.push( - `/questions` + - encodeQueryParams({ - [POST_TEXT_SEARCH_FILTER]: searchQuery, - [POST_ORDER_BY_FILTER]: QuestionOrder.RankDesc, - }) - ); - - sendAnalyticsEvent("feedSearch", { - event_category: "fromHomepage", - }); - }; - - const { setIsVisible } = useGlobalSearchContext(); - - return ( - { - setIsVisible(v); - }} - > -
- setSearchQuery(event.target.value)} - onErase={() => setSearchQuery("")} - onSubmit={handleSearchSubmit} - placeholder={t("questionSearchPlaceholder")} - size="lg" - className="md:max-w-xl" - /> - -
-
- ); -}; - -export default HomeSearch; diff --git a/front_end/src/app/(main)/(home)/components/homepage_filters.ts b/front_end/src/app/(main)/(home)/components/homepage_filters.ts new file mode 100644 index 0000000000..5f957315d9 --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/homepage_filters.ts @@ -0,0 +1,48 @@ +import { PostsParams } from "@/services/api/posts/posts.shared"; +import { PostForecastType } from "@/types/post"; +import { QuestionType } from "@/types/question"; + +export type TabId = "popular" | "news" | "new"; + +export const TABS: { id: TabId; label: string }[] = [ + { id: "popular", label: "Popular" }, + { id: "news", label: "In the news" }, + { id: "new", label: "New" }, +]; + +const allowedTypes = [ + QuestionType.Binary, + QuestionType.MultipleChoice, + QuestionType.Numeric, + QuestionType.Discrete, + QuestionType.Date, + PostForecastType.Group, +]; + +export const FILTERS: Record = { + popular: { + for_main_feed: "true", + for_consumer_view: "false", + order_by: "-hotness", + statuses: ["open"], + limit: 7, + forecast_type: allowedTypes, + access: "public", + }, + news: { + for_main_feed: "true", + statuses: "open", + order_by: "-news_hotness", + limit: 7, + forecast_type: allowedTypes, + access: "public", + }, + new: { + for_main_feed: "true", + for_consumer_view: "false", + order_by: "-open_time", + limit: 7, + forecast_type: allowedTypes, + access: "public", + }, +}; diff --git a/front_end/src/app/(main)/(home)/components/homepage_forecasts.tsx b/front_end/src/app/(main)/(home)/components/homepage_forecasts.tsx new file mode 100644 index 0000000000..c380914c2d --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/homepage_forecasts.tsx @@ -0,0 +1,126 @@ +"use client"; + +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC, useState, useTransition } from "react"; + +import PostCard from "@/components/post_card"; +import { useBreakpoint } from "@/hooks/tailwind"; +import ClientPostsApi from "@/services/api/posts/posts.client"; +import { PostWithForecasts } from "@/types/post"; +import cn from "@/utils/core/cn"; + +import { ExploreImagesGrid } from "./ExploreImagesGrid"; +import { FILTERS, TABS, TabId } from "./homepage_filters"; + +type Props = { + initialPopularPosts: PostWithForecasts[]; + className?: string; +}; + +const HomePageForecasts: FC = ({ initialPopularPosts, className }) => { + const t = useTranslations(); + const [activeTab, setActiveTab] = useState("popular"); + const [posts, setPosts] = useState(initialPopularPosts); + const [isPending, startTransition] = useTransition(); + const [cachedPosts, setCachedPosts] = useState< + Partial> + >({ + popular: initialPopularPosts, + }); + + const tabLabels: Record = { + popular: t("popular"), + news: t("inTheNews"), + new: t("new"), + }; + + const handleTabChange = (tabId: TabId) => { + if (tabId === activeTab) return; + + setActiveTab(tabId); + + if (cachedPosts[tabId]) { + setPosts(cachedPosts[tabId] ?? []); + return; + } + + startTransition(async () => { + const response = await ClientPostsApi.getPostsWithCPForHomepage( + FILTERS[tabId] + ); + const newPosts = response.results; + setCachedPosts((prev) => ({ ...prev, [tabId]: newPosts })); + setPosts(newPosts); + }); + }; + + const isSmallScreen = !useBreakpoint("md"); + const visiblePosts = isSmallScreen ? posts.slice(0, 3) : posts; + + return ( +
+

+ {t("forecasts")} +

+ +
+ {TABS.map((tab) => ( + + ))} +
+ +
+ {visiblePosts.map((post) => ( +
+ +
+ ))} + + +
+
+ ); +}; + +const ExploreAllCard: FC = () => { + const t = useTranslations(); + return ( + +
+
+ {t("exploreAll")} + +
+

+ {t("thousandsOfOpenQuestions")} +

+
+ +
+ +
+ + ); +}; + +export default HomePageForecasts; diff --git a/front_end/src/app/(main)/(home)/components/icons/focus_area_ai.tsx b/front_end/src/app/(main)/(home)/components/icons/focus_area_ai.tsx deleted file mode 100644 index f662ae8ee2..0000000000 --- a/front_end/src/app/(main)/(home)/components/icons/focus_area_ai.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { FC, SVGProps } from "react"; - -const FocusAreaAiIcon: FC> = (props) => { - return ( - - - - - - ); -}; - -export default FocusAreaAiIcon; diff --git a/front_end/src/app/(main)/(home)/components/icons/focus_area_biosecurity.tsx b/front_end/src/app/(main)/(home)/components/icons/focus_area_biosecurity.tsx deleted file mode 100644 index 40edf833d4..0000000000 --- a/front_end/src/app/(main)/(home)/components/icons/focus_area_biosecurity.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { FC, SVGProps } from "react"; - -const FocusAreaBiosecurityIcon: FC> = (props) => { - return ( - - - - ); -}; - -export default FocusAreaBiosecurityIcon; diff --git a/front_end/src/app/(main)/(home)/components/icons/focus_area_climate.tsx b/front_end/src/app/(main)/(home)/components/icons/focus_area_climate.tsx deleted file mode 100644 index 034cc6f944..0000000000 --- a/front_end/src/app/(main)/(home)/components/icons/focus_area_climate.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import { FC, SVGProps } from "react"; - -const FocusAreaClimateIcon: FC> = (props) => { - return ( - - - - - - ); -}; - -export default FocusAreaClimateIcon; diff --git a/front_end/src/app/(main)/(home)/components/icons/focus_area_nuclear.tsx b/front_end/src/app/(main)/(home)/components/icons/focus_area_nuclear.tsx deleted file mode 100644 index 488240b1ea..0000000000 --- a/front_end/src/app/(main)/(home)/components/icons/focus_area_nuclear.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { FC, SVGProps } from "react"; - -const FocusAreaNuclearIcon: FC> = (props) => { - return ( - - - - - - - ); -}; - -export default FocusAreaNuclearIcon; diff --git a/front_end/src/app/(main)/(home)/components/questions_carousel.tsx b/front_end/src/app/(main)/(home)/components/questions_carousel.tsx deleted file mode 100644 index d655405ac4..0000000000 --- a/front_end/src/app/(main)/(home)/components/questions_carousel.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Link from "next/link"; -import { useTranslations } from "next-intl"; -import React, { FC } from "react"; - -import Carousel, { CarouselItem } from "@/components/carousel"; -import ForecastCard from "@/components/forecast_card"; -import { POST_STATUS_FILTER } from "@/constants/posts_feed"; -import { TimelineChartZoomOption } from "@/types/charts"; -import { PostStatus, PostWithForecasts } from "@/types/post"; - -type Props = { - posts: PostWithForecasts[]; -}; - -const QuestionCarousel: FC = ({ posts }) => { - const t = useTranslations(); - - return ( - - - {t("seeAllForecasts")} - - - - } - > - {posts.map((p) => ( - - - - ))} - - ); -}; - -export default QuestionCarousel; diff --git a/front_end/src/app/(main)/(home)/components/research_and_updates.tsx b/front_end/src/app/(main)/(home)/components/research_and_updates.tsx index 6a2339c273..e10f2842bf 100644 --- a/front_end/src/app/(main)/(home)/components/research_and_updates.tsx +++ b/front_end/src/app/(main)/(home)/components/research_and_updates.tsx @@ -1,99 +1,163 @@ -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +"use server"; import { intlFormat } from "date-fns"; import Image from "next/image"; import Link from "next/link"; import { getLocale, getTranslations } from "next-intl/server"; import { FC } from "react"; -import imagePlaceholder from "@/app/assets/images/logo_placeholder.png"; import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; +import Button from "@/components/ui/button"; import { NotebookPost } from "@/types/post"; -import { getMarkdownSummary } from "@/utils/markdown"; +import cn from "@/utils/core/cn"; +import { estimateReadingTime, getMarkdownSummary } from "@/utils/markdown"; import { getPostLink } from "@/utils/navigation"; +const CARD_GRADIENTS = [ + "radial-gradient(ellipse at center, #ede28f 0%, #c5b3c2 50%, #9d83f5 100%)", + "radial-gradient(ellipse at center, #b5ed8f 0%, #d5b889 50%, #f58383 100%)", + "radial-gradient(ellipse at center, #ed8fd9 0%, #b8c2c7 50%, #83f5b4 100%)", + "radial-gradient(ellipse at center, #ed8f8f 0%, #f1bf89 50%, #f5ef83 100%)", +]; + type Props = { posts: NotebookPost[]; + className?: string; }; -const ResearchAndUpdatesBlock: FC = async ({ posts }) => { +const ResearchAndUpdates: FC = async ({ posts, className }) => { const t = await getTranslations(); const locale = await getLocale(); return ( -
-

- {t("research")} &{" "} - - {t("updates")} - -

-

- {t("partnersUseForecasts")} -

-
- {posts.map(({ title, created_at, id, notebook, slug }) => ( - - {notebook.image_url ? ( - - ) : ( - - )} +
+
+
+

+ {t("researchAndUpdates")} +

+

+ {t("partnersUseForecasts")} +

+
+ +
-
- - {intlFormat( - new Date(created_at), - { - year: "numeric", - month: "short", - }, - { locale } - )} - -

{title}

-

- {notebook.markdown_summary || - getMarkdownSummary({ - markdown: notebook.markdown, - width: 200, - height: 80, - withLinks: false, - })} -

-
- +
+ {posts.slice(0, 4).map((post, index) => ( + ))}
- - {t("seeMorePosts")} - - -
+ + ); +}; + +type PostCardProps = { + post: NotebookPost; + index: number; + locale: string; +}; + +const NotebookCard: FC = async ({ post, index, locale }) => { + const t = await getTranslations(); + const { + title, + created_at, + id, + notebook, + slug, + author_username, + comment_count = 0, + } = post; + + const readingTime = estimateReadingTime(notebook.markdown); + const summary = + notebook.markdown_summary || + getMarkdownSummary({ + markdown: notebook.markdown, + width: 280, + height: 60, + withLinks: false, + }); + + const gradient = CARD_GRADIENTS[index % CARD_GRADIENTS.length]; + + return ( + +
+ {notebook.image_url ? ( + + ) : ( +
+ )} +
+ +
+
+ + {intlFormat( + new Date(created_at), + { + year: "numeric", + month: "short", + day: "numeric", + }, + { locale } + )} + +

+ {title} +

+

+ {summary} +

+
+ +
+ + {author_username} + +
+ + {comment_count} {t("commentsWithCount", { count: comment_count })} + + + + {t("estimatedReadingTime", { minutes: readingTime })} + +
+
+
+ ); }; -export default WithServerComponentErrorBoundary(ResearchAndUpdatesBlock); +export default WithServerComponentErrorBoundary(ResearchAndUpdates); diff --git a/front_end/src/app/(main)/(home)/components/staff_picks.tsx b/front_end/src/app/(main)/(home)/components/staff_picks.tsx new file mode 100644 index 0000000000..64e093851b --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/staff_picks.tsx @@ -0,0 +1,48 @@ +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC, ReactNode } from "react"; + +import cn from "@/utils/core/cn"; + +type StaffPickItem = { + name: string; + emoji: string | ReactNode; + url: string; +}; + +type Props = { + items: StaffPickItem[]; +}; + +const StaffPicks: FC = ({ items }) => { + const t = useTranslations(); + return ( +
+

+ {t("staffPicks")} +

+ {items.map((item, idx) => ( + + + {typeof item.emoji === "string" ? ( + {item.emoji} + ) : ( + item.emoji + )} + + + {item.name} + + + ))} +
+ ); +}; + +export default StaffPicks; diff --git a/front_end/src/app/(main)/(home)/components/topic_link.tsx b/front_end/src/app/(main)/(home)/components/topic_link.tsx deleted file mode 100644 index 3a660244f6..0000000000 --- a/front_end/src/app/(main)/(home)/components/topic_link.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Link from "next/link"; -import { FC, ReactNode } from "react"; - -type Props = { - text: string; - emoji: string | ReactNode; - href: string; -}; - -const TopicLink: FC = ({ href, text, emoji }) => { - return ( - - {emoji} - - {text} - - - ); -}; - -export default TopicLink; diff --git a/front_end/src/app/(main)/(home)/components/tournaments_block.tsx b/front_end/src/app/(main)/(home)/components/tournaments_block.tsx deleted file mode 100644 index c0ae1ec8c3..0000000000 --- a/front_end/src/app/(main)/(home)/components/tournaments_block.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { faArrowRight } from "@fortawesome/free-solid-svg-icons"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Link from "next/link"; -import { getTranslations } from "next-intl/server"; -import { FC } from "react"; - -import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; -import TournamentCard from "@/components/tournament_card"; -import ServerProjectsApi from "@/services/api/projects/projects.server"; -import { TournamentType } from "@/types/projects"; - -const TournamentsBlock: FC = async () => { - const t = await getTranslations(); - const tournaments = await ServerProjectsApi.getTournaments({ - show_on_homepage: true, - }); - - return ( -
-

- {t("forecasting")}{" "} - - {t("tournaments")} - -

-

- {t("joinTournaments")} -

-
- {tournaments.map((tournament) => ( - - ))} -
- - {t("seeAllTournaments")} - - -
- ); -}; - -export default WithServerComponentErrorBoundary(TournamentsBlock); diff --git a/front_end/src/app/(main)/(home)/components/tournaments_section.tsx b/front_end/src/app/(main)/(home)/components/tournaments_section.tsx new file mode 100644 index 0000000000..f2256f9e8c --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/tournaments_section.tsx @@ -0,0 +1,49 @@ +import { getTranslations } from "next-intl/server"; +import { FC } from "react"; + +import LiveTournamentCard from "@/app/(main)/(tournaments)/tournaments/components/tournaments_grid/live_tournament_card"; +import WithServerComponentErrorBoundary from "@/components/server_component_error_boundary"; +import Button from "@/components/ui/button"; +import ServerProjectsApi from "@/services/api/projects/projects.server"; +import { TournamentType } from "@/types/projects"; +import cn from "@/utils/core/cn"; + +const TournamentsSection: FC<{ className?: string }> = async ({ + className, +}) => { + const t = await getTranslations(); + const allTournaments = (await ServerProjectsApi.getTournaments()).filter( + (t) => t.is_ongoing && t.type == TournamentType.Tournament + ); + const tournaments = allTournaments.filter((t) => t.show_on_homepage); + + return ( +
+
+
+

+ {t("forecasting")} {t("tournaments")} +

+

+ {t("joinTournaments")} +

+
+ +
+
+ {tournaments.map((tournament) => ( + + ))} +
+
+ ); +}; + +export default WithServerComponentErrorBoundary(TournamentsSection); diff --git a/front_end/src/app/(main)/(home)/components/why_metaculus.tsx b/front_end/src/app/(main)/(home)/components/why_metaculus.tsx new file mode 100644 index 0000000000..032bbea1cd --- /dev/null +++ b/front_end/src/app/(main)/(home)/components/why_metaculus.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import React, { FC, useEffect, useState } from "react"; + +import ClientMiscApi from "@/services/api/misc/misc.client"; +import cn from "@/utils/core/cn"; + +import { + NasdaqLogo, + ForbesLogo, + TheAtlanticLogo, + AeiLogo, + TheEconomistLogo, + BloombergLogo, +} from "./featured-in-logos"; + +const FEATURED_IN = [ + { + href: "https://www.nasdaq.com/articles/how-crypto-can-help-secure-ai", + label: "Nasdaq", + component: ( + + ), + }, + { + href: "https://www.forbes.com/sites/stevenwolfepereira/2025/12/08/building-a-one-person-unicorn-this-startup-just-raised-87m-to-help/", + label: "Forbes", + component: ( + + ), + }, + { + href: "https://archive.is/0O588", + label: "The Atlantic", + component: ( + + ), + }, + { + href: "https://www.aei.org/articles/the-great-ai-forecasting-divide/1", + label: "AEI", + component: ( + + ), + }, + { + href: "https://www.economist.com/finance-and-economics/2023/05/23/what-would-humans-do-in-a-world-of-super-ai", + label: "The Economist", + component: , + }, + { + href: "https://www.bloomberg.com/opinion/articles/2024-03-24/can-sam-altman-make-ai-smart-enough-to-answer-these-6-questions", + label: "Bloomberg", + component: ( + + ), + }, +]; + +const fetchSiteStats = async () => { + try { + return await ClientMiscApi.getSiteStats(); + } catch { + // silenty fail + return null; + } +}; + +const WhyMetaculus: FC<{ className?: string }> = ({ className }) => { + const t = useTranslations(); + const [siteStats, setSiteStats] = useState({ + predictions: 2133159, + questions: 17357, + years_of_predictions: 10, + }); + + useEffect(() => { + fetchSiteStats().then((stats) => { + if (!stats) { + return; + } + + setSiteStats({ + predictions: stats.predictions, + questions: stats.questions, + years_of_predictions: stats.years_of_predictions, + }); + }); + }, []); + + return ( +
+

+ {t("whatsMetaculus")} +

+ + {/* Divider */} +
+ +
+ {/* Description & Stats */} +
+

+ {t("metaculusDescription")} +

+ +
+ + + +
+
+ + {/* Divider 2 */} +
+ + {/* Featured In */} +
+ + {t("featuredIn")} + +
+ {[0, 3].map((startIdx) => ( +
+ {FEATURED_IN.slice(startIdx, startIdx + 3).map((item) => ( + + {item.component} + + ))} +
+ ))} +
+
+
+
+ ); +}; + +const Stat: FC<{ number: string; label: string }> = ({ number, label }) => ( +
+ {number} + {label} +
+); + +export default WhyMetaculus; diff --git a/front_end/src/app/(main)/(home)/page.tsx b/front_end/src/app/(main)/(home)/page.tsx index 3b133fc87c..d9f6f22dde 100644 --- a/front_end/src/app/(main)/(home)/page.tsx +++ b/front_end/src/app/(main)/(home)/page.tsx @@ -1,26 +1,25 @@ import { redirect } from "next/navigation"; -import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; import OnboardingCheck from "@/components/onboarding/onboarding_check"; +import LoadingIndicator from "@/components/ui/loading_indicator"; import serverMiscApi from "@/services/api/misc/misc.server"; import ServerPostsApi from "@/services/api/posts/posts.server"; -import { NotebookPost, PostWithForecasts } from "@/types/post"; +import ServerProjectsApi from "@/services/api/projects/projects.server"; +import { NotebookPost } from "@/types/post"; import { getPublicSettings } from "@/utils/public_settings.server"; import { convertSidebarItem } from "@/utils/sidebar"; +import AllCategoriesSection from "./components/all_categories_section"; import EmailConfirmation from "./components/email_confirmation"; -import EngageBlock from "./components/engage_block"; -import FocusAreaLink, { FocusAreaItem } from "./components/focus_area_link"; -import HomeSearch from "./components/home_search"; -import FocusAreaAiIcon from "./components/icons/focus_area_ai"; -import FocusAreaBiosecurityIcon from "./components/icons/focus_area_biosecurity"; -import FocusAreaClimateIcon from "./components/icons/focus_area_climate"; -import FocusAreaNuclearIcon from "./components/icons/focus_area_nuclear"; -import QuestionCarousel from "./components/questions_carousel"; -import ResearchAndUpdatesBlock from "./components/research_and_updates"; -import TopicLink from "./components/topic_link"; -import TournamentsBlock from "./components/tournaments_block"; +import FutureEvalSection from "./components/future_eval_section"; +import HeroCTAs from "./components/hero_ctas"; +import { FILTERS } from "./components/homepage_filters"; +import HomePageForecasts from "./components/homepage_forecasts"; +import ResearchAndUpdates from "./components/research_and_updates"; +import StaffPicks from "./components/staff_picks"; +import TournamentsSection from "./components/tournaments_section"; +import WhyMetaculus from "./components/why_metaculus"; export default async function Home() { const { PUBLIC_LANDING_PAGE_URL } = getPublicSettings(); @@ -29,121 +28,64 @@ export default async function Home() { return redirect(PUBLIC_LANDING_PAGE_URL); } - const t = await getTranslations(); - const sidebarItems = await serverMiscApi.getSidebarItems(); + const [sidebarItems, homepagePosts, categories, initialPopularPosts] = + await Promise.all([ + serverMiscApi.getSidebarItems(), + ServerPostsApi.getPostsForHomepage(), + ServerProjectsApi.getHomepageCategories(), + ServerPostsApi.getPostsWithCP(FILTERS.popular), + ]); + + const postNotebooks = homepagePosts.filter( + (post) => !!post.notebook + ) as unknown as NotebookPost[]; const hotTopics = sidebarItems .filter(({ section }) => section === "hot_topics") .map((item) => convertSidebarItem(item)); - const FOCUS_AREAS: FocusAreaItem[] = [ - { - id: "biosecurity", - title: t("biosecurity"), - Icon: FocusAreaBiosecurityIcon, - text: t("biosecurityDescription"), - href: "/questions/?categories=health-pandemics&for_main_feed=false", - }, - { - id: "ai", - title: t("aiProgress"), - Icon: FocusAreaAiIcon, - text: t("aiProgressDescription"), - href: "/questions/?categories=artificial-intelligence&for_main_feed=false", - }, - { - id: "nuclear", - title: t("nuclearSecurity"), - Icon: FocusAreaNuclearIcon, - text: t("nuclearSecurityDescription"), - href: "/questions/?categories=nuclear&for_main_feed=false", - }, - { - id: "climate", - title: t("climateChange"), - Icon: FocusAreaClimateIcon, - text: t("climateChangeDescription"), - href: "/questions/?categories=environment-climate&for_main_feed=false", - }, - ]; - - const homepagePosts = await ServerPostsApi.getPostsForHomepage(); - const postQuestions = homepagePosts.filter( - (post) => !post.notebook - ) as unknown as PostWithForecasts[]; - const postNotebooks = homepagePosts.filter( - (post) => !!post.notebook - ) as unknown as NotebookPost[]; + const contentWidthClassNames = + "2xl:max-w-[1352px] w-full md:max-2xl:px-20 mx-auto px-4"; return ( -
+
-
-
-

- {t.rich("homeTitle", { - highlight: (chunks) => ( - - {chunks} - - ), - })} -

- - {t("homeDescription")} - -
-
- -
-
- {hotTopics.map((item, idx) => ( - - ))} -
-
+ + +
+ + +
+ }> +
+
- {!!postQuestions.length && ( -
- -
- )} -
-

- {t.rich("focusAreasTitle", { - highlight: (chunks) => ( - - {chunks} - - ), - })} -

-

- {t("focusAreasDescription")} -

-
- {FOCUS_AREAS.map((focusArea) => ( - - ))} -
+ + }> + + + }> +
+
- - - - - {!!postNotebooks.length && ( - - - - )} - -
+
+ }> + +
); } diff --git a/front_end/src/app/(main)/components/MetaculusTextLogo.tsx b/front_end/src/app/(main)/components/MetaculusTextLogo.tsx new file mode 100644 index 0000000000..73a6c83766 --- /dev/null +++ b/front_end/src/app/(main)/components/MetaculusTextLogo.tsx @@ -0,0 +1,26 @@ +import { FC } from "react"; + +export const MetaculusTextLogo: FC<{ className?: string }> = ({ + className, +}) => ( + + + + + + + + + + +); diff --git a/front_end/src/app/(main)/components/footer.tsx b/front_end/src/app/(main)/components/footer.tsx index bc721e24af..d4bd0f2b8d 100644 --- a/front_end/src/app/(main)/components/footer.tsx +++ b/front_end/src/app/(main)/components/footer.tsx @@ -1,192 +1,358 @@ "use client"; -import { faTwitter, faDiscord } from "@fortawesome/free-brands-svg-icons"; +import { faXTwitter, faDiscord } from "@fortawesome/free-brands-svg-icons"; +import { faChevronDown } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import Image from "next/image"; +import { + Listbox, + ListboxButton, + ListboxOptions, + ListboxOption, +} from "@headlessui/react"; import Link from "next/link"; -import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; +import { useTranslations, useLocale } from "next-intl"; import { FC } from "react"; +import { updateLanguagePreference } from "@/app/(main)/accounts/profile/actions"; +import { APP_LANGUAGES } from "@/components/language_menu"; +import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; +import useAppTheme from "@/hooks/use_app_theme"; +import useMounted from "@/hooks/use_mounted"; +import { AppTheme } from "@/types/theme"; +import cn from "@/utils/core/cn"; +import { logError } from "@/utils/core/errors"; + +import { MetaculusTextLogo } from "./MetaculusTextLogo"; + +const ComputerIcon: FC<{ className?: string }> = ({ className }) => ( + + + + + + + + + + +); + +const LanguageIcon: FC<{ className?: string }> = ({ className }) => ( + + + + +); + +type FooterLink = + | { href: string; labelKey: string; isModal?: false; external?: false } + | { labelKey: string; isModal: true; href?: undefined; external?: false } + | { href: string; labelKey: string; external: true; isModal?: false }; + +const FOOTER_LINKS = { + explore: [ + { href: "/questions", labelKey: "questions" }, + { href: "/tournaments", labelKey: "tournaments" }, + { href: "/aib", labelKey: "tournamentsForAIBots" }, + { href: "/futureeval", labelKey: "futureEval" }, + ], + services: [ + { href: "/services#launch-a-tournament", labelKey: "launchATournament" }, + { href: "/services#private-instances", labelKey: "privateInstances" }, + { href: "/services#pro-forecasters", labelKey: "proForecasters" }, + ], + company: [ + { href: "/about/", labelKey: "about" }, + { labelKey: "contact", isModal: true }, + { + href: "https://apply.workable.com/metaculus", + labelKey: "careers", + external: true, + }, + { href: "/faq", labelKey: "faq" }, + ], + resources: [ + { href: "/help/prediction-resources", labelKey: "forecastingResources" }, + { href: "/press", labelKey: "forJournalists" }, + { href: "/api", labelKey: "api" }, + ], +} as const satisfies Record; + +const THEME_OPTIONS = [ + { value: AppTheme.System, labelKey: "settingsThemeSystemDefault" }, + { value: AppTheme.Light, labelKey: "settingsThemeLightMode" }, + { value: AppTheme.Dark, labelKey: "settingsThemeDarkMode" }, +] as const satisfies readonly { value: AppTheme; labelKey: string }[]; + +const FooterLinkColumn: FC<{ + title: string; + links: readonly FooterLink[]; + onContactClick?: () => void; +}> = ({ title, links, onContactClick }) => { + const t = useTranslations(); + + return ( +
+ + {title} + + {links.map((link, index) => { + if (link.isModal) { + return ( + + ); + } + if (link.external) { + return ( + + {t(link.labelKey as Parameters[0])} + + ); + } + return ( + + {t(link.labelKey as Parameters[0])} + + ); + })} +
+ ); +}; + +const LanguageSelector: FC = () => { + const { user } = useAuth(); + const currentLocale = useLocale(); + const router = useRouter(); + + const updateLanguage = (language: string) => { + updateLanguagePreference(language, false) + .then(() => router.refresh()) + .catch(logError); + }; + + const selectedLanguage = user?.language || currentLocale; + + return ( + +
+ + + + {APP_LANGUAGES.find((opt) => opt.locale === selectedLanguage)?.name} + + + + + {APP_LANGUAGES.map((language) => ( + + cn( + "cursor-pointer px-3 py-2 text-sm text-gray-200 hover:bg-gray-700 dark:text-gray-200-dark dark:hover:bg-gray-300", + selected && "bg-gray-700 font-medium dark:bg-gray-300" + ) + } + > + {language.name} + + ))} + +
+
+ ); +}; + +const ThemeSelector: FC = () => { + const t = useTranslations(); + const mounted = useMounted(); + const { themeChoice, setTheme } = useAppTheme(); + + const currentTheme = mounted ? themeChoice : AppTheme.System; + const currentOption = + THEME_OPTIONS.find((opt) => opt.value === currentTheme) ?? THEME_OPTIONS[0]; + + const handleThemeChange = (value: AppTheme) => { + setTheme(value); + }; + + return ( + +
+ + + {t(currentOption.labelKey as Parameters[0])} + + + + {THEME_OPTIONS.map((option) => ( + + cn( + "cursor-pointer text-nowrap px-3 py-2 text-sm text-gray-200 hover:bg-gray-700 dark:text-gray-200-dark dark:hover:bg-gray-300", + selected && "bg-gray-700 font-medium dark:bg-gray-300" + ) + } + > + {t(option.labelKey as Parameters[0])} + + ))} + +
+
+ ); +}; const Footer: FC = () => { const t = useTranslations(); const { setCurrentModal } = useModal(); + const handleContactClick = () => setCurrentModal({ type: "contactUs" }); + return ( -
-
-
    -
  • - - {t("about")} - -
  • -
  • - - {t("api")} - -
  • -
-
    -
  • - - {t("faq")} - -
  • -
  • +
    + {/* Main content */} +
    + {/* Left column - Logo, description, socials, selectors */} +
    + {/* Logo and description */} +
    + +

    + {t("publicBenefitCorporation")} +

    +

    + {t("metaculusDescription")} +

    +
    + + {/* Social icons */} +
  • -
  • - - {t("forJournalists")} - -
  • -
- -
-
+ {/* Language and Theme selectors */} +
+ + +
+
- +
+ + {/* Bottom links */} + - - ); diff --git a/front_end/src/app/(main)/components/headers/components/navbar_links.tsx b/front_end/src/app/(main)/components/headers/components/navbar_links.tsx index 49d744747c..fa179e9467 100644 --- a/front_end/src/app/(main)/components/headers/components/navbar_links.tsx +++ b/front_end/src/app/(main)/components/headers/components/navbar_links.tsx @@ -12,7 +12,7 @@ const NavbarLinks: FC = ({ links, className }) => { return (
    diff --git a/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx b/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx index 890c38a9ee..cff180eead 100644 --- a/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx +++ b/front_end/src/app/(main)/components/headers/components/navbar_logo.tsx @@ -2,8 +2,9 @@ import Link from "next/link"; import { FC } from "react"; import { usePublicSettings } from "@/contexts/public_settings_context"; +import cn from "@/utils/core/cn"; -const NavbarLogo: FC = () => { +const NavbarLogo: FC<{ className?: string }> = ({ className }) => { const { PUBLIC_MINIMAL_UI } = usePublicSettings(); if (PUBLIC_MINIMAL_UI) { @@ -13,24 +14,13 @@ const NavbarLogo: FC = () => { return ( -

    - - - - - - +

    + { return ( <>
    - +
    + - {/* Global Search */} - - - {/* Regular links */} - - - - + {/* Regular links */} + + + + -
      -
    • + {/* The More menu */} +
      { ))} -
    • +
    + + + {/* Global Search */} + + +
      {!!user && (
    • {
    • -
    • - -
    • -
    • - -
    {!user && ( diff --git a/front_end/src/components/language_menu.tsx b/front_end/src/components/language_menu.tsx index c241db2496..5f6ad9237e 100644 --- a/front_end/src/components/language_menu.tsx +++ b/front_end/src/components/language_menu.tsx @@ -38,19 +38,15 @@ export const APP_LANGUAGES = [ name: "繁體中文", locale: "zh-TW", }, + { + name: "Untranslated", + locale: "original", // Check the translations documentation why this is the case + }, ]; const LanguageMenu: FC = ({ className }) => { const locale = useLocale(); - const languageMenuItems = [ - ...APP_LANGUAGES, - { - name: "Untranslated", - locale: "original", // Check the translations documentation why this is the case - }, - ]; - return ( = ({ className }) => { anchor="bottom" className="z-[200] border border-blue-200-dark bg-blue-900 text-sm text-gray-0 md:z-100 md:mt-2" > - {languageMenuItems.map((item) => { + {APP_LANGUAGES.map((item) => { return ( = ({ forecastAvailability={forecastAvailability} canPredict={canPredict} showChart={showChart} + minimalistic={minimalistic} /> ); case QuestionType.MultipleChoice: { diff --git a/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx b/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx index 571722f47e..d38bf5a879 100644 --- a/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx +++ b/front_end/src/components/post_card/question_tile/prediction_continuous_info.tsx @@ -9,6 +9,7 @@ import ContinuousCPBar from "@/components/post_card/question_tile/continuous_cp_ import { useHideCP } from "@/contexts/cp_context"; import { QuestionStatus } from "@/types/post"; import { QuestionWithNumericForecasts, UserForecast } from "@/types/question"; +import cn from "@/utils/core/cn"; import { isForecastActive } from "@/utils/forecasts/helpers"; import { formatResolution } from "@/utils/formatters/resolution"; import { isSuccessfullyResolved } from "@/utils/questions/resolution"; @@ -18,6 +19,7 @@ type Props = { onReaffirm?: (userForecast: UserForecast) => void; canPredict?: boolean; showMyPrediction?: boolean; + className?: string; }; const PredictionContinuousInfo: FC = ({ @@ -25,6 +27,7 @@ const PredictionContinuousInfo: FC = ({ onReaffirm, canPredict, showMyPrediction, + className, }) => { const locale = useLocale(); const { hideCP } = useHideCP(); @@ -59,7 +62,12 @@ const PredictionContinuousInfo: FC = ({ } return ( -
    +
    {!hideCP && ( <> diff --git a/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx b/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx index 331ef3af44..5dccd4545e 100644 --- a/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx +++ b/front_end/src/components/post_card/question_tile/question_continuous_tile.tsx @@ -25,6 +25,7 @@ import { QuestionWithNumericForecasts, UserForecast, } from "@/types/question"; +import cn from "@/utils/core/cn"; import { isForecastActive } from "@/utils/forecasts/helpers"; import { extractPrevBinaryForecastValue } from "@/utils/forecasts/initial_values"; import { getPostDrivenTime } from "@/utils/questions/helpers"; @@ -37,6 +38,7 @@ type Props = { forecastAvailability: ForecastAvailability; canPredict?: boolean; showChart?: boolean; + minimalistic?: boolean; }; const QuestionContinuousTile: FC = ({ @@ -45,6 +47,7 @@ const QuestionContinuousTile: FC = ({ forecastAvailability, canPredict, showChart = true, + minimalistic = false, }) => { const { onReaffirm } = useCardReaffirmContext(); @@ -187,7 +190,12 @@ const QuestionContinuousTile: FC = ({ return (
    {/* Mobile: Overlay layout */} -
    +
    {/* CP values container - positioned first */}
    @@ -196,6 +204,7 @@ const QuestionContinuousTile: FC = ({ onReaffirm={onReaffirm ? handleReaffirmClick : undefined} canPredict={canPredict} showMyPrediction={true} + className={minimalistic ? "md:flex-row" : undefined} />
    @@ -220,7 +229,12 @@ const QuestionContinuousTile: FC = ({
    {/* Large screens: Side-by-side layout (like binary questions) */} -
    +
    ("/get-site-stats/"); + return await this.get("/get-site-stats/", { + next: { + revalidate: 60 * 60 * 24, // 24 hours + }, + }); } } diff --git a/front_end/src/services/api/posts/posts.shared.ts b/front_end/src/services/api/posts/posts.shared.ts index a483284cd0..a1530e1982 100644 --- a/front_end/src/services/api/posts/posts.shared.ts +++ b/front_end/src/services/api/posts/posts.shared.ts @@ -152,6 +152,26 @@ class PostsApi extends ApiService { }); } + async getPostsWithCPForHomepage( + params?: PostsParams + ): Promise> { + const queryParams = encodeQueryParams({ + ...(params ?? {}), + with_cp: true, + include_descriptions: false, + include_cp_history: true, + include_movements: true, + }); + + return await this.get>( + `/posts/${queryParams}`, + { + next: { + revalidate: 30 * 60, + }, + } + ); + } async getTournamentForecastFlowPosts( tournamentSlug: string ): Promise { diff --git a/front_end/src/services/api/projects/projects.shared.ts b/front_end/src/services/api/projects/projects.shared.ts index 7265a4f425..03c5c8f4e4 100644 --- a/front_end/src/services/api/projects/projects.shared.ts +++ b/front_end/src/services/api/projects/projects.shared.ts @@ -1,6 +1,6 @@ import { ApiService } from "@/services/api/api_service"; import { PaginatedPayload, PaginationParams } from "@/types/fetch"; -import { ProjectPermissions } from "@/types/post"; +import { Post, ProjectPermissions } from "@/types/post"; import { Category, Community, @@ -38,6 +38,12 @@ class ProjectsApi extends ApiService { return await this.get("/projects/categories/"); } + async getHomepageCategories(): Promise<(Category & { posts: Post[] })[]> { + return await this.get<(Category & { posts: Post[] })[]>( + `/projects/homepage_categories/` + ); + } + async getNewsCategories(): Promise { return await this.get("/projects/news-categories/"); } diff --git a/front_end/src/types/projects.ts b/front_end/src/types/projects.ts index 3052784faf..a75dbafd42 100644 --- a/front_end/src/types/projects.ts +++ b/front_end/src/types/projects.ts @@ -56,6 +56,7 @@ export type TournamentMember = { export type TournamentPreview = Project & { type: TournamentType; + show_on_homepage: boolean; header_image: string; forecasts_count: number; forecasters_count: number; diff --git a/front_end/src/utils/posthog.server.ts b/front_end/src/utils/posthog.server.ts new file mode 100644 index 0000000000..9b5a637d5f --- /dev/null +++ b/front_end/src/utils/posthog.server.ts @@ -0,0 +1,48 @@ +import "server-only"; + +import { cookies } from "next/headers"; +import { PostHog } from "posthog-node"; + +import { getPublicSettings } from "./public_settings.server"; + +let posthogClient: PostHog | null = null; + +function getPostHogClient(): PostHog | null { + const { PUBLIC_POSTHOG_KEY, PUBLIC_POSTHOG_BASE_URL } = getPublicSettings(); + const apiKey = PUBLIC_POSTHOG_KEY; + if (!apiKey) return null; + + if (!posthogClient) { + posthogClient = new PostHog(apiKey, { + host: PUBLIC_POSTHOG_BASE_URL || "https://us.i.posthog.com", + flushAt: 1, + flushInterval: 0, + }); + } + return posthogClient; +} + +export async function getFeatureFlag( + flagName: string, + defaultValue: boolean | string = false +): Promise { + const client = getPostHogClient(); + if (!client) return defaultValue; + + const { PUBLIC_POSTHOG_KEY } = getPublicSettings(); + + const cookieStore = await cookies(); + const cookieName = "ph_" + PUBLIC_POSTHOG_KEY + "_posthog"; + const cookieValue = cookieStore.get(cookieName)?.value; + const distinctId = cookieValue + ? JSON.parse(cookieValue).distinct_id + : "anonymous"; + + try { + const flag = await client.getFeatureFlag(flagName, distinctId); + + return flag !== undefined ? flag : defaultValue; + } catch { + return defaultValue; + } +} diff --git a/projects/models.py b/projects/models.py index 5dd724be02..ce8effb058 100644 --- a/projects/models.py +++ b/projects/models.py @@ -12,6 +12,7 @@ from django.db.models.functions import Coalesce from django.utils import timezone as django_timezone from sql_util.aggregates import SubqueryAggregate +from django.contrib.postgres.expressions import ArraySubquery from projects.permissions import ObjectPermission from questions.constants import UnsuccessfulResolutionType @@ -45,6 +46,34 @@ def filter_leaderboard_tags(self): def filter_communities(self): return self.filter(type=Project.ProjectTypes.COMMUNITY) + def annotate_categories_with_top_n_posts_ids(self, n: int = 3): + from posts.models import Post + + now = django_timezone.now() + # We must query the M2M through table directly instead of Post.objects.filter(projects=OuterRef("pk")) + # When filtering Post with an M2M relation using OuterRef, Django generates a JOIN that + # includes extra columns in the SELECT. ArraySubquery wraps the query in PostgreSQL's ARRAY() + # which requires exactly one column. Querying the through table with a direct FK lookup + # generates a clean single-column SELECT. + ThroughModel = Post.projects.through + subquery = ( + ThroughModel.objects.filter( + project_id=OuterRef("pk"), + post__curation_status=Post.CurationStatus.APPROVED, + post__open_time__lte=now, + post__notebook__isnull=True, + post__default_project__default_permission__isnull=False, + post__default_project__visibility=Project.Visibility.NORMAL, + ) + .filter( + Q(post__actual_close_time__isnull=True) + | Q(post__actual_close_time__gt=now) + ) + .order_by("-post__hotness") + .values("post_id")[:n] + ) + return self.annotate(top_n_post_ids=ArraySubquery(subquery)) + def annotate_posts_count(self): from posts.models import Post diff --git a/projects/serializers/common.py b/projects/serializers/common.py index 03c855ba35..6b9f6e58a8 100644 --- a/projects/serializers/common.py +++ b/projects/serializers/common.py @@ -101,6 +101,7 @@ class Meta: "score_type", "default_permission", "visibility", + "show_on_homepage", "is_current_content_translated", "bot_leaderboard_status", "description_preview", diff --git a/projects/urls.py b/projects/urls.py index fb4a6720c4..9f8c91239a 100644 --- a/projects/urls.py +++ b/projects/urls.py @@ -6,6 +6,7 @@ path("projects/topics/", views.topics_list_api_view), path("projects/news-categories/", views.news_categories_list_api_view), path("projects/categories/", views.categories_list_api_view), + path("projects/homepage_categories/", views.homepage_categories_list_api_view), path("projects/leaderboard-tags/", views.leaderboard_tags_list_api_view), path("projects/tournaments/", views.tournaments_list_api_view), path("projects/minibenches/", views.minibench_tournaments_api_view), diff --git a/projects/views/common.py b/projects/views/common.py index ea9295f84e..dc92612d13 100644 --- a/projects/views/common.py +++ b/projects/views/common.py @@ -1,4 +1,5 @@ from django.http import HttpResponse +from django.views.decorators.cache import cache_page from rest_framework import serializers, status from rest_framework.decorators import api_view, permission_classes from rest_framework.generics import get_object_or_404 @@ -7,7 +8,7 @@ from rest_framework.response import Response from posts.models import Post -from posts.serializers import serialize_posts_many_forecast_flow +from posts.serializers import serialize_posts_many_forecast_flow, serialize_post from projects.models import Project from projects.permissions import ObjectPermission from projects.serializers.common import ( @@ -79,6 +80,38 @@ def news_categories_list_api_view(request: Request): return Response(data) +@cache_page(60 * 30) +@api_view(["GET"]) +@permission_classes([AllowAny]) +def homepage_categories_list_api_view(request: Request): + qs = ( + get_projects_qs(user=request.user) + .filter_category() + .annotate_categories_with_top_n_posts_ids() + ) + + categories = list(qs.all()) + all_post_ids = set() + for cat in categories: + all_post_ids.update(cat.top_n_post_ids or []) + + posts = Post.objects.filter(id__in=all_post_ids) + posts_map = {p.id: serialize_post(p) for p in posts} + + data = [ + { + **CategorySerializer(obj).data, + "posts": [ + posts_map[pid] for pid in (obj.top_n_post_ids or []) if pid in posts_map + ], + } + for obj in categories + if len(obj.top_n_post_ids) > 0 + ] + + return Response(data) + + @api_view(["GET"]) @permission_classes([AllowAny]) def categories_list_api_view(request: Request): diff --git a/tests/integration/test_simple.py b/tests/integration/test_simple.py index 5b01c8b027..04fec89f2a 100644 --- a/tests/integration/test_simple.py +++ b/tests/integration/test_simple.py @@ -45,7 +45,7 @@ def test_login(cls): page.get_by_placeholder("password").fill("Test1234") page.get_by_role("button", name="Log in", exact=True).last.click() time.sleep(1) - page.get_by_role("link", name="Questions", exact=True).click() + page.get_by_role("banner").get_by_role("link", name="Questions").click() page.get_by_role("button", name="Filter").click() page.get_by_role("button", name="Binary").click() page.get_by_role("button", name="Open").click()