diff --git a/authentication/serializers.py b/authentication/serializers.py index b2238f5e5e..962ba8852e 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -29,7 +29,6 @@ class Meta: "username", "email", "password", - "is_bot", "add_to_project", "campaign_key", "campaign_data", diff --git a/authentication/views/common.py b/authentication/views/common.py index 74f4b6f298..4d765d4c6e 100644 --- a/authentication/views/common.py +++ b/authentication/views/common.py @@ -74,7 +74,6 @@ def signup_api_view(request): email = serializer.validated_data["email"] username = serializer.validated_data["username"] password = serializer.validated_data["password"] - is_bot = serializer.validated_data.get("is_bot", False) project = serializer.validated_data.get("add_to_project", None) campaign_key = serializer.validated_data.get("campaign_key", None) @@ -104,7 +103,7 @@ def signup_api_view(request): email=email, password=password, is_active=is_active, - is_bot=is_bot, + is_bot=False, language=language, app_theme=app_theme, newsletter_optin=newsletter_optin, diff --git a/comments/services/common.py b/comments/services/common.py index 47ff5aab53..49f7d2ca84 100644 --- a/comments/services/common.py +++ b/comments/services/common.py @@ -15,7 +15,7 @@ ) from django.db.models.functions import Coalesce, Abs from django.utils import timezone -from rest_framework.exceptions import ValidationError +from rest_framework.exceptions import ValidationError, PermissionDenied from comments.models import ( ChangedMyMindEntry, @@ -90,6 +90,9 @@ def create_comment( if root: is_private = root.is_private + if not is_private and user.is_bot and not user.is_primary_bot: + raise PermissionDenied("Only your primary bot can post public comments.") + with transaction.atomic(): obj = Comment( author=user, diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index d1888d7f12..2eccb229a1 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -695,11 +695,8 @@ "FABHeroSubtitle": "Benchmark Series", "FABHeroDesc": "Benchmarking nejnovějších výsledků v AI předpovídání proti nejlepším lidem na reálných otázkách.", "FABGettingStarted": "Začínáme", - "FABRegisterBot": "Zaregistrujte svého bota do turnaje", "FABCreateBot": "Vytvořit účet pro bota", - "FABSeparateBotAccount": "Váš bot potřebuje samostatný účet na Metaculus. Ujistěte se, že jste se odhlásili ze svého hlavního účtu a vraťte se na tuto stránku.", "FABBotRegistered": "Váš bot byl úspěšně zaregistrován do turnaje.", - "FABShowToken": "Zobrazit můj token", "FABTournamentPage": "Otázky", "FABHowItWorks": "Začínáme", "FABCreateBotAccount": "Vytvořit účet bota", @@ -1762,5 +1759,30 @@ "privateNotes": "Soukromé poznámky", "justNow": "právě teď", "cmmButtonShort": "Mysl", + "FABRegisterBot": "Zaregistrujte se, abyste přihlásili svého robota do turnaje", + "FABRegisterBotSecondary": "Svého předpovědního robota vytvoříte po registraci.", + "FABCreateAccount": "Vytvořit účet", + "FABCreatePopupDescription": "Vytvořte si osobní účet Metaculus pro účast v turnaji.\nPo registraci budete navedeni k vytvoření svého předpovědního robota z\nNastavení → Moje předpovědní roboty.", + "FABCreateBotTitle": "Vytvořte svého prvního předpovědního robota", + "FABCreateBotDescription": "Jakmile je vytvořen, robot bude automaticky registrován do turnaje.", + "FABShowToken": "Zobrazit token robota", + "myForecastingBots": "Moje předpovědní roboty", + "myBots": "Moje roboty", + "createBot": "Vytvořit robota", + "myForecastingBotsDisclaimer": "Dlouhé komentáře robotů, které nepřidávají hodnotu, budou považovány za spam a postiženy podle našich pravidel, včetně deaktivace účtu při opakovaných přestupcích. Soukromé komentáře jsou v pořádku v rozumných mezích.", + "myBotsEmpty": "Ještě nemáte žádné roboty.\n\n Pokud potřebujete pomoc, podívejte se na naši dokumentaci o Jak nastavit roboty.", + "createBotDescription": "Tím vytvoříte robota propojeného s vaším uživatelem, což vám umožní spravovat jeho nastavení a aktivitu odsud.", + "botUsername": "Uživatelské jméno robota", + "botCreated": "Robot vytvořen", + "botCreatedDescription": "Toto je váš API klíč robota — zkopírujte a uložte ho bezpečně.\nMůžete ho později zobrazit v nastavení předpovědních robotů.", + "primaryBotEligibleDisclaimer": "Pouze váš první robot má nárok na ceny.", + "editBotDescription": "Aktualizujte detaily vašeho robota. Veškeré změny se promítnou na jeho profilové stránce.", + "revealApiKey": "Zobrazit API klíč", + "hideApiKey": "Skrýt API klíč", + "accessToken": "Přístupový token", + "copy": "kopírovat", + "switchToBotAccount": "Přepnout na účet bota", + "impersonationBannerText": "Momentálně si prohlížíte Metaculus jako váš bot.", + "stopImpersonating": "Přepnout zpět na můj účet", "othersCount": "Ostatní ({count})" } diff --git a/front_end/messages/en.json b/front_end/messages/en.json index adf8909139..f834758161 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -938,11 +938,15 @@ "FABHeroSubtitle": "Benchmark Series", "FABHeroDesc": "Benchmarking the state of the art in AI forecasting against the best humans on real-world questions.", "FABGettingStarted": "Getting Started", - "FABRegisterBot": "Register your bot for the tournament", + "FABRegisterBot": "Sign up to register your bot for the tournament", + "FABRegisterBotSecondary": "You’ll create your forecasting bot after signing up.", "FABCreateBot": "Create a Bot Account", - "FABSeparateBotAccount": "Your bot needs a separate Metaculus account. Make sure to log out of your main account and come back to this page.", + "FABCreateAccount": "Create Account", + "FABCreatePopupDescription": "Create a personal Metaculus account to participate in the tournament.\nAfter signing up, you’ll be guided to create your forecasting bot from\nSettings → My Forecasting Bots.", + "FABCreateBotTitle": "Create your first forecasting bot", + "FABCreateBotDescription": "Once created, bot will be automatically registered for the tournament.", "FABBotRegistered": "Your bot is successfully registered for the tournament.", - "FABShowToken": "Show My Token", + "FABShowToken": "Show Bot Token", "FABTournamentPage": "The Questions", "FABReadMore": "Read More", "FABHowItWorks": "Getting Started", @@ -1756,5 +1760,23 @@ "privateNotes": "Private Notes", "justNow": "just now", "cmmButtonShort": "Mind", + "myForecastingBots": "My Forecasting Bots", + "myBots": "My Bots", + "createBot": "Create Bot", + "myForecastingBotsDisclaimer": "Long bot comments that don’t add value will be considered spam and dealt with as provisioned in our guidelines, up to and including account deactivation for repeated offenses. Private comments are fine within reasonable limits.", + "myBotsEmpty": "You don’t have any bots yet.\n\n If you need help, check our documentation on How to Set up Bots.", + "createBotDescription": "This will create a bot linked to your user, enabling you to manage its settings and activity from here", + "botUsername": "Bot username", + "botCreated": "Bot created", + "botCreatedDescription": "This is your bot’s API key — copy and store it securely.\nYou can reveal it later in the forecasting bots settings.", + "primaryBotEligibleDisclaimer": "Only your first bot is eligible for prizes.", + "editBotDescription": "Update your bot’s details. All changes will be reflected on its profile page.", + "revealApiKey": "Reveal API Key", + "hideApiKey": "Hide API Key", + "accessToken": "Access Token", + "copy": "Copy", + "switchToBotAccount": "Switch to Bot Account", + "impersonationBannerText": "You are currently viewing Metaculus as your bot.", + "stopImpersonating": "Switch back to my account", "none": "none" } diff --git a/front_end/messages/es.json b/front_end/messages/es.json index ad0ee7040c..8bcc58af75 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -697,11 +697,8 @@ "FABHeroSubtitle": "Serie de Referencia", "FABHeroDesc": "Comparando el estado del arte en pronósticos de IA con los mejores humanos en preguntas del mundo real.", "FABGettingStarted": "Comenzar", - "FABRegisterBot": "Registra tu bot en el torneo", "FABCreateBot": "Crear una Cuenta de Bot", - "FABSeparateBotAccount": "Tu bot necesita una cuenta separada de Metaculus. Asegúrate de cerrar sesión en tu cuenta principal y regresar a esta página.", "FABBotRegistered": "Tu bot ha sido registrado con éxito en el torneo.", - "FABShowToken": "Mostrar Mi Token", "FABTournamentPage": "Las Preguntas", "FABHowItWorks": "Comenzar", "FABCreateBotAccount": "Crear una Cuenta de Bot", @@ -1762,5 +1759,30 @@ "privateNotes": "Notas privadas", "justNow": "justo ahora", "cmmButtonShort": "Mente", + "FABRegisterBot": "Regístrate para inscribir tu bot en el torneo", + "FABRegisterBotSecondary": "Crearás tu bot de pronóstico después de registrarte.", + "FABCreateAccount": "Crear Cuenta", + "FABCreatePopupDescription": "Crea una cuenta personal en Metaculus para participar en el torneo.\nDespués de registrarte, se te guiará para crear tu bot de pronóstico desde\nConfiguración → Mis Bots de Pronóstico.", + "FABCreateBotTitle": "Crea tu primer bot de pronóstico", + "FABCreateBotDescription": "Una vez creado, el bot será registrado automáticamente para el torneo.", + "FABShowToken": "Mostrar Token del Bot", + "myForecastingBots": "Mis Bots de Pronóstico", + "myBots": "Mis Bots", + "createBot": "Crear Bot", + "myForecastingBotsDisclaimer": "Los comentarios extensos de bots que no añadan valor serán considerados spam y se tratarán según lo dispuesto en nuestras directrices, incluyendo la desactivación de la cuenta por reincidencia. Los comentarios privados son aceptables dentro de límites razonables.", + "myBotsEmpty": "Aún no tienes ningún bot.\n\n Si necesitas ayuda, consulta nuestra documentación sobre Cómo Configurar Bots.", + "createBotDescription": "Esto creará un bot vinculado a tu usuario, permitiéndote gestionar su configuración y actividad desde aquí", + "botUsername": "Nombre de usuario del Bot", + "botCreated": "Bot creado", + "botCreatedDescription": "Esta es la clave API de tu bot — cópiala y guárdala de forma segura.\nPuedes revelarla más adelante en la configuración de bots de pronóstico.", + "primaryBotEligibleDisclaimer": "Solo tu primer bot es elegible para premios.", + "editBotDescription": "Actualiza los detalles de tu bot. Todos los cambios se reflejarán en su página de perfil.", + "revealApiKey": "Revelar Clave API", + "hideApiKey": "Ocultar Clave API", + "accessToken": "Token de Acceso", + "copy": "copiar", + "switchToBotAccount": "Cambiar a cuenta de bot", + "impersonationBannerText": "Actualmente estás viendo Metaculus como tu bot.", + "stopImpersonating": "Volver a mi cuenta", "othersCount": "Otros ({count})" } diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 59c65a41d2..f63b14ef68 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -776,11 +776,8 @@ "FABHeroSubtitle": "Série de Benchmark", "FABHeroDesc": "Comparando o estado da arte em previsão por IA com os melhores humanos em perguntas do mundo real.", "FABGettingStarted": "Primeiros Passos", - "FABRegisterBot": "Registre seu bot para o torneio", "FABCreateBot": "Criar uma Conta de Bot", - "FABSeparateBotAccount": "Seu bot precisa de uma conta separada no Metaculus. Certifique-se de sair da sua conta principal e voltar a esta página.", "FABBotRegistered": "Seu bot foi registrado com sucesso no torneio.", - "FABShowToken": "Mostrar Meu Token", "FABTournamentPage": "As Perguntas", "FABHowItWorks": "Como Funciona", "FABCreateBotAccount": "Criar uma Conta de Bot", @@ -1760,5 +1757,30 @@ "privateNotes": "Notas Privadas", "justNow": "agora mesmo", "cmmButtonShort": "Mente", + "FABRegisterBot": "Inscreva-se para registrar seu bot no torneio", + "FABRegisterBotSecondary": "Você criará seu bot de previsão após se inscrever.", + "FABCreateAccount": "Criar Conta", + "FABCreatePopupDescription": "Crie uma conta pessoal no Metaculus para participar do torneio.\nApós se inscrever, você será guiado para criar seu bot de previsão em\nConfigurações → Meus Bots de Previsão.", + "FABCreateBotTitle": "Crie seu primeiro bot de previsão", + "FABCreateBotDescription": "Uma vez criado, o bot será registrado automaticamente no torneio.", + "FABShowToken": "Mostrar Token do Bot", + "myForecastingBots": "Meus Bots de Previsão", + "myBots": "Meus Bots", + "createBot": "Criar Bot", + "myForecastingBotsDisclaimer": "Comentários longos de bots que não adicionam valor serão considerados spam e tratados conforme previsto em nossas diretrizes, podendo incluir a desativação da conta em caso de reincidência. Comentários privados são permitidos dentro de limites razoáveis.", + "myBotsEmpty": "Você ainda não tem bots.\n\nSe precisar de ajuda, consulte nossa documentação sobre Como Configurar Bots.", + "createBotDescription": "Isto criará um bot vinculado ao seu usuário, permitindo que você gerencie suas configurações e atividades daqui", + "botUsername": "Nome de usuário do Bot", + "botCreated": "Bot criado", + "botCreatedDescription": "Este é a chave da API do seu bot — copie e armazene com segurança.\nVocê pode revelá-la posteriormente nas configurações dos bots de previsão.", + "primaryBotEligibleDisclaimer": "Apenas seu primeiro bot é elegível para prêmios.", + "editBotDescription": "Atualize os detalhes do seu bot. Todas as alterações serão refletidas na página de perfil dele.", + "revealApiKey": "Revelar Chave da API", + "hideApiKey": "Ocultar Chave da API", + "accessToken": "Token de Acesso", + "copy": "copiar", + "switchToBotAccount": "Mudar para Conta de Bot", + "impersonationBannerText": "Você está visualizando o Metaculus atualmente como seu bot.", + "stopImpersonating": "Voltar para minha conta", "othersCount": "Outros ({count})" } diff --git a/front_end/messages/zh-TW.json b/front_end/messages/zh-TW.json index 8066f45bcf..3f56e705ac 100644 --- a/front_end/messages/zh-TW.json +++ b/front_end/messages/zh-TW.json @@ -838,11 +838,8 @@ "FABHeroSubtitle": "基準系列", "FABHeroDesc": "在真實世界問題上,將AI預測的最前沿與最優秀的人類進行基準對比。", "FABGettingStarted": "開始", - "FABRegisterBot": "註冊您的機器人參加比賽", "FABCreateBot": "創建一個機器人帳號", - "FABSeparateBotAccount": "您的機器人需要獨立的Metaculus帳號。請確保登出您的主帳號並返回此頁面。", "FABBotRegistered": "您的機器人已成功註冊參賽。", - "FABShowToken": "顯示我的令牌", "FABTournamentPage": "問題", "FABHowItWorks": "開始指南", "FABCreateBotAccount": "創建一個機器人帳號", @@ -1759,5 +1756,30 @@ "privateNotes": "私人筆記", "justNow": "剛剛", "cmmButtonShort": "心智", + "FABRegisterBot": "註冊以參加賽事", + "FABRegisterBotSecondary": "註冊後您將創建您的預測機器人。", + "FABCreateAccount": "創建帳號", + "FABCreatePopupDescription": "創建個人的Metaculus帳號以參加賽事。\n註冊後,您將被引導從\n設定 → 我的預測機器人創建您的預測機器人。", + "FABCreateBotTitle": "創建您的第一個預測機器人", + "FABCreateBotDescription": "一旦創建,機器人將自動註冊參加賽事。", + "FABShowToken": "顯示機器人令牌", + "myForecastingBots": "我的預測機器人", + "myBots": "我的機器人", + "createBot": "創建機器人", + "myForecastingBotsDisclaimer": "不增加價值的冗長機器人評論將被視為垃圾訊息,將按照我們的指導方針進行處理,包括在重複違規的情況下停用帳戶。私人評論在合理範圍內是可以的。", + "myBotsEmpty": "您還沒有任何機器人。\n\n如果需要幫助,請查閱我們的關於 如何設置機器人 的文檔。", + "createBotDescription": "這將創建一個鏈接到您的用戶的機器人,使您可以在此管理其設定和活動", + "botUsername": "機器人用戶名", + "botCreated": "機器人已創建", + "botCreatedDescription": "這是您的機器人的API密鑰——請複製並妥善存放。\n稍後可以在預測機器人的設置中查看。", + "primaryBotEligibleDisclaimer": "僅您的第一個機器人有資格獲得獎品。", + "editBotDescription": "更新您的機器人詳細信息。所有更改將反映在其頁面上。", + "revealApiKey": "顯示API密鑰", + "hideApiKey": "隱藏API密鑰", + "accessToken": "訪問令牌", + "copy": "複製", + "switchToBotAccount": "切換到機器人帳戶", + "impersonationBannerText": "您目前正在以您的機器人帳戶查看 Metaculus。", + "stopImpersonating": "切換回我的帳戶", "withdrawAfterPercentSetting2": "問題總生命周期後撤回" } diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index afced1e250..ef1f8fb4b7 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -686,11 +686,8 @@ "FABHeroSubtitle": "基準系列", "FABHeroDesc": "在真實世界問題上對比最先進的人工智能預測與最優秀的人類。", "FABGettingStarted": "開始", - "FABRegisterBot": "將你的機器人註冊到競賽", "FABCreateBot": "創建機器人帳戶", - "FABSeparateBotAccount": "你的機器人需要一個單獨的Metaculus帳戶。確保登出你的主帳戶並返回此頁面。", "FABBotRegistered": "你的機器人已成功註冊參加競賽。", - "FABShowToken": "顯示我的令牌", "FABTournamentPage": "问题", "FABHowItWorks": "入门", "FABCreateBotAccount": "創建機器人帳戶", @@ -1764,5 +1761,30 @@ "privateNotes": "私人筆記", "justNow": "刚刚", "cmmButtonShort": "心情", + "FABRegisterBot": "注册您的机器人以参加锦标赛", + "FABRegisterBotSecondary": "注册后,您将创建您的预测机器人。", + "FABCreateAccount": "创建账户", + "FABCreatePopupDescription": "创建一个个人 Metaculus 账户以参加锦标赛。\n注册后,您将在设置中创建您的预测机器人 → 我的预测机器人。", + "FABCreateBotTitle": "创建您的第一个预测机器人", + "FABCreateBotDescription": "一旦创建,机器人将自动注册参加锦标赛。", + "FABShowToken": "显示机器人令牌", + "myForecastingBots": "我的预测机器人", + "myBots": "我的机器人", + "createBot": "创建机器人", + "myForecastingBotsDisclaimer": "冗长的机器人评论如不具价值,将被视为垃圾信息,并按我们的指导原则处理,包括在多次违规情况下停用账户。在合理范围内,私人评论是可以的。", + "myBotsEmpty": "您还没有任何机器人。\n\n如果您需要帮助,请查看我们的文档<链接>如何设置机器人。", + "createBotDescription": "这将创建一个链接到您的用户的机器人,使您能够从这里管理其设置和活动", + "botUsername": "机器人用户名", + "botCreated": "机器人已创建", + "botCreatedDescription": "这是您机器人的 API 密钥——请妥善复制和保存。\n您可以稍后在预测机器人设置中揭示它。", + "primaryBotEligibleDisclaimer": "只有您的第一个机器人有资格获得奖品。", + "editBotDescription": "更新您机器人的详细信息。所有更改将反映在其个人资料页面上。", + "revealApiKey": "揭示 API 密钥", + "hideApiKey": "隐藏 API 密钥", + "accessToken": "访问令牌", + "copy": "复制", + "switchToBotAccount": "切换到机器人账户", + "impersonationBannerText": "您当前正在以机器人身份查看 Metaculus。", + "stopImpersonating": "切换回我的账户", "othersCount": "其他({count})" } diff --git a/front_end/next.config.mjs b/front_end/next.config.mjs index 00cc25a606..9b0b31f768 100644 --- a/front_end/next.config.mjs +++ b/front_end/next.config.mjs @@ -70,6 +70,10 @@ const nextConfig = { source: "/index/:slug", destination: "/tournament/:slug", }, + { + source: "/aib", + destination: "/aib/2026/spring/", + }, ]; }, eslint: { diff --git a/front_end/src/app/(main)/accounts/actions.ts b/front_end/src/app/(main)/accounts/actions.ts index f6deb33733..45af70bd0e 100644 --- a/front_end/src/app/(main)/accounts/actions.ts +++ b/front_end/src/app/(main)/accounts/actions.ts @@ -10,7 +10,11 @@ import { signInSchema, SignUpSchema } from "@/app/(main)/accounts/schemas"; import ServerAuthApi from "@/services/api/auth/auth.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; import { LanguageService } from "@/services/language_service"; -import { deleteServerSession, setServerSession } from "@/services/session"; +import { + deleteImpersonatorSession, + deleteServerSession, + setServerSession, +} from "@/services/session"; import { AuthResponse, SignUpResponse } from "@/types/auth"; import { CurrentUser } from "@/types/users"; import { ApiError } from "@/utils/core/errors"; @@ -165,6 +169,7 @@ export async function signUpAction( export async function LogOut() { await deleteServerSession(); + await deleteImpersonatorSession(); return redirect("/"); } diff --git a/front_end/src/app/(main)/accounts/settings/(general)/page.tsx b/front_end/src/app/(main)/accounts/settings/(general)/page.tsx index bfad0587d1..0360870aaf 100644 --- a/front_end/src/app/(main)/accounts/settings/(general)/page.tsx +++ b/front_end/src/app/(main)/accounts/settings/(general)/page.tsx @@ -5,6 +5,10 @@ import ServerProfileApi from "@/services/api/profile/profile.server"; import DisplayPreferences from "./components/display_preferences"; import PredictionPreferences from "./components/prediction_preferences"; +export const metadata = { + title: "General Settings", +}; + export default async function Settings() { const currentUser = await ServerProfileApi.getMyProfile(); invariant(currentUser); diff --git a/front_end/src/app/(main)/accounts/settings/account/page.tsx b/front_end/src/app/(main)/accounts/settings/account/page.tsx index f38221c407..711a89edea 100644 --- a/front_end/src/app/(main)/accounts/settings/account/page.tsx +++ b/front_end/src/app/(main)/accounts/settings/account/page.tsx @@ -9,6 +9,10 @@ import ChangePassword from "./components/change_password"; import EmailEdit from "./components/email_edit"; import PreferencesSection from "../components/preferences_section"; +export const metadata = { + title: "Account Settings", +}; + export default async function Settings() { const currentUser = await ServerProfileApi.getMyProfile(); const token = await getServerSession(); diff --git a/front_end/src/app/(main)/accounts/settings/actions.tsx b/front_end/src/app/(main)/accounts/settings/actions.tsx index 5fc5ef4fa7..cccd10cc35 100644 --- a/front_end/src/app/(main)/accounts/settings/actions.tsx +++ b/front_end/src/app/(main)/accounts/settings/actions.tsx @@ -1,6 +1,16 @@ "use server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + import ServerProfileApi from "@/services/api/profile/profile.server"; +import { + deleteImpersonatorSession, + getImpersonatorSession, + getServerSession, + setImpersonatorSession, + setServerSession, +} from "@/services/session"; import { ApiError } from "@/utils/core/errors"; export async function changePassword(password: string, new_password: string) { @@ -48,3 +58,94 @@ export async function emailMeMyData() { }; } } + +export async function createBot(username: string) { + try { + const data = await ServerProfileApi.createBot({ username }); + + return { + token: data.token, + }; + } catch (err) { + if (!ApiError.isApiError(err)) { + throw err; + } + + return { + errors: err.data, + }; + } +} + +export async function updateBot( + botId: number, + data: { username?: string; bio?: string; website?: string } +) { + try { + const response = await ServerProfileApi.updateBot(botId, data); + revalidatePath(`/accounts/profile/${botId}/`); + return { + data: response, + }; + } catch (err) { + if (!ApiError.isApiError(err)) { + throw err; + } + + return { + errors: err.data, + }; + } +} + +export async function getBotTokenAction(botId: number) { + try { + const data = await ServerProfileApi.getBotToken(botId); + + return { + token: data.token, + }; + } catch (err) { + if (!ApiError.isApiError(err)) { + throw err; + } + + return { + errors: err.data, + }; + } +} + +export async function stopImpersonatingAction() { + const impersonatorToken = await getImpersonatorSession(); + + if (impersonatorToken) { + await setServerSession(impersonatorToken); + await deleteImpersonatorSession(); + } + + redirect("/accounts/settings/bots/"); +} + +export async function impersonateBotAction(botId: number) { + try { + const userToken = await getServerSession(); + const { token: botToken } = await ServerProfileApi.getBotToken(botId); + + if (userToken) { + await setImpersonatorSession(userToken); + } + + await setServerSession(botToken); + + redirect("/"); + } catch (err) { + if (!ApiError.isApiError(err)) { + throw err; + } + + return { + errors: err.data, + }; + } +} diff --git a/front_end/src/app/(main)/accounts/settings/bots/components/bot_card.tsx b/front_end/src/app/(main)/accounts/settings/bots/components/bot_card.tsx new file mode 100644 index 0000000000..ceccdd87ec --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/bots/components/bot_card.tsx @@ -0,0 +1,40 @@ +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +import BotControls from "@/app/(main)/accounts/settings/bots/components/bot_controls"; +import { CurrentBot } from "@/types/users"; +import cn from "@/utils/core/cn"; +import { formatUsername } from "@/utils/formatters/users"; + +type Props = { + bot: CurrentBot; +}; + +const BotCard: FC = ({ bot }) => { + const t = useTranslations(); + const { is_primary_bot } = bot; + + return ( +
+ {is_primary_bot && ( +
+ {t("primaryBotEligibleDisclaimer")} +
+ )} +
+ {formatUsername(bot)} +
+ +
+ ); +}; + +export default BotCard; diff --git a/front_end/src/app/(main)/accounts/settings/bots/components/bot_controls.tsx b/front_end/src/app/(main)/accounts/settings/bots/components/bot_controls.tsx new file mode 100644 index 0000000000..c29cdb0e50 --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/bots/components/bot_controls.tsx @@ -0,0 +1,114 @@ +"use client"; + +import { faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { useTranslations } from "next-intl"; +import { FC, useState } from "react"; +import toast from "react-hot-toast"; + +import { + getBotTokenAction, + impersonateBotAction, +} from "@/app/(main)/accounts/settings/actions"; +import Button from "@/components/ui/button"; +import { CurrentBot } from "@/types/users"; +import { extractError } from "@/utils/core/errors"; + +import BotUpdateButton from "./update_button"; + +type Props = { + bot: CurrentBot; +}; + +const BotControls: FC = ({ bot }) => { + const t = useTranslations(); + const [apiToken, setApiToken] = useState(null); + const [isLoading, setIsLoading] = useState(false); + + const { id } = bot; + + const handleRevealKey = async () => { + // If token is already shown, hide it + if (apiToken) { + setApiToken(null); + return; + } + + setIsLoading(true); + const response = await getBotTokenAction(id); + setIsLoading(false); + + if (response.token) { + setApiToken(response.token); + } else if (response.errors) { + toast.error(extractError(response.errors)); + } + }; + + const [isImpersonating, setIsImpersonating] = useState(false); + + const handleImpersonate = async () => { + setIsImpersonating(true); + + try { + const response = await impersonateBotAction(id); + + if (response?.errors) { + toast.error(extractError(response.errors)); + } + } finally { + setIsImpersonating(false); + } + }; + + return ( +
+
+ + + + +
+ + {apiToken && ( +
+
+
{t("accessToken")}
+ {apiToken} +
+ +
+ )} +
+ ); +}; + +export default BotControls; diff --git a/front_end/src/app/(main)/accounts/settings/bots/components/bots_disclaimer.tsx b/front_end/src/app/(main)/accounts/settings/bots/components/bots_disclaimer.tsx new file mode 100644 index 0000000000..529179079d --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/bots/components/bots_disclaimer.tsx @@ -0,0 +1,14 @@ +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +const BotsDisclaimer: FC = () => { + const t = useTranslations(); + + return ( +
+ {t("myForecastingBotsDisclaimer")} +
+ ); +}; + +export default BotsDisclaimer; diff --git a/front_end/src/app/(main)/accounts/settings/bots/components/create_button.tsx b/front_end/src/app/(main)/accounts/settings/bots/components/create_button.tsx new file mode 100644 index 0000000000..c4e230b830 --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/bots/components/create_button.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { faCopy, faPlus, faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import React, { FC, useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import toast from "react-hot-toast"; +import { z } from "zod"; + +import { createBot } from "@/app/(main)/accounts/settings/actions"; +import BaseModal from "@/components/base_modal"; +import Button from "@/components/ui/button"; +import { Input } from "@/components/ui/form_field"; +import { InputContainer } from "@/components/ui/input_container"; +import { useAuth } from "@/contexts/auth_context"; +import { extractError } from "@/utils/core/errors"; + +type Props = { + disabled?: boolean; +}; + +const getZodSchema = (t: ReturnType) => + z.object({ + username: z.string().min(1, t("errorRequired")), + }); + +const BotCreateButton: FC = ({ disabled }) => { + const t = useTranslations(); + const router = useRouter(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [successToken, setSuccessToken] = useState(); + const { user } = useAuth(); + + useEffect(() => { + if (window.location.hash === "#create" && !disabled) { + setIsModalOpen(true); + } + }, [disabled]); + + const schema = getZodSchema(t); + + type FormData = z.infer; + + const { + register, + handleSubmit, + setError, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(schema), + }); + + const onSubmit = async (data: FormData) => { + const response = await createBot(data.username); + + if (response.errors) { + setError("root", { + type: "manual", + message: extractError(response.errors), + }); + } else if (response.token) { + setSuccessToken(response.token); + router.refresh(); + } + }; + + const handleClose = () => { + setIsModalOpen(false); + setSuccessToken(null); + reset(); + if (window.location.hash === "#create") { + history.replaceState( + null, + "", + window.location.pathname + window.location.search + ); + } + }; + + return ( + <> + + + {/* Creation Modal */} + +

+ {t("createBotDescription")} +

+
+ + + + + {errors.root && ( +
+ {errors.root.message} +
+ )} + +
+ + +
+
+
+ + {/* Success Modal */} + +
+

+ {t("botCreatedDescription")} +

+ + {successToken && ( +
+ +
+ +
+
+ )} + +
+ +
+
+
+ + ); +}; + +export default BotCreateButton; diff --git a/front_end/src/app/(main)/accounts/settings/bots/components/empty_placeholder.tsx b/front_end/src/app/(main)/accounts/settings/bots/components/empty_placeholder.tsx new file mode 100644 index 0000000000..713aed79ec --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/bots/components/empty_placeholder.tsx @@ -0,0 +1,17 @@ +import Link from "next/link"; +import { useTranslations } from "next-intl"; +import { FC } from "react"; + +const EmptyPlaceholder: FC = () => { + const t = useTranslations(); + + return ( +
+ {t.rich("myBotsEmpty", { + link: (chunks) => {chunks}, + })} +
+ ); +}; + +export default EmptyPlaceholder; diff --git a/front_end/src/app/(main)/accounts/settings/bots/components/update_button.tsx b/front_end/src/app/(main)/accounts/settings/bots/components/update_button.tsx new file mode 100644 index 0000000000..a3ae466bf4 --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/bots/components/update_button.tsx @@ -0,0 +1,153 @@ +"use client"; + +import { faSpinner } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import React, { FC, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; + +import { LogOut } from "@/app/(main)/accounts/actions"; +import { updateBot } from "@/app/(main)/accounts/settings/actions"; +import BaseModal from "@/components/base_modal"; +import Button from "@/components/ui/button"; +import { MarkdownEditorField, Input } from "@/components/ui/form_field"; +import { InputContainer } from "@/components/ui/input_container"; +import { CurrentBot } from "@/types/users"; +import { extractError } from "@/utils/core/errors"; + +type Props = { + bot: CurrentBot; +}; + +const getZodSchema = (t: ReturnType) => + z.object({ + username: z.string().min(1, t("errorRequired")), + bio: z.string().optional(), + website: z.string().optional(), + }); + +const BotUpdateButton: FC = ({ bot }) => { + const t = useTranslations(); + const router = useRouter(); + const [isEditModalOpen, setIsEditModalOpen] = useState(false); + + const schema = getZodSchema(t); + + type FormData = z.infer; + + const { + control, + register, + handleSubmit, + formState: { errors, isSubmitting }, + setError, + reset, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + username: bot.username, + bio: bot.bio || "", + website: bot.website || "", + }, + }); + + const onSubmit = async (data: FormData) => { + const response = await updateBot(bot.id, data); + + if (response.errors) { + if (response.errors.error_code === "SPAM_DETECTED") { + alert( + "Your account has been deactivated for detected spam. Please note that we set our links so that Google doesn't pick them up for SEO. Adding spam to the site does nothing to help your rankings. Please contact support@metaculus.com if you believe the spam detection was a mistake." + ); + LogOut(); + } + + setError("root", { + type: "manual", + message: extractError(response.errors), + }); + } else { + setIsEditModalOpen(false); + router.refresh(); // Invalidate page to show new data + } + }; + + const handleClose = () => { + setIsEditModalOpen(false); + reset(); + }; + + return ( + <> + + + +

+ {t("editBotDescription")} +

+
+ + + + + + + + + + + + + {errors.root && ( +
+ {errors.root.message} +
+ )} + +
+ + +
+
+
+ + ); +}; + +export default BotUpdateButton; diff --git a/front_end/src/app/(main)/accounts/settings/bots/page.tsx b/front_end/src/app/(main)/accounts/settings/bots/page.tsx new file mode 100644 index 0000000000..056a05c031 --- /dev/null +++ b/front_end/src/app/(main)/accounts/settings/bots/page.tsx @@ -0,0 +1,44 @@ +import { getTranslations } from "next-intl/server"; +import invariant from "ts-invariant"; + +import BotCard from "@/app/(main)/accounts/settings/bots/components/bot_card"; +import BotsDisclaimer from "@/app/(main)/accounts/settings/bots/components/bots_disclaimer"; +import BotCreateButton from "@/app/(main)/accounts/settings/bots/components/create_button"; +import EmptyPlaceholder from "@/app/(main)/accounts/settings/bots/components/empty_placeholder"; +import ServerProfileApi from "@/services/api/profile/profile.server"; +import { getServerSession } from "@/services/session"; + +import PreferencesSection from "../components/preferences_section"; + +export const metadata = { + title: "My Forecasting Bots", +}; + +export default async function Bots() { + const token = await getServerSession(); + invariant(token); + + const t = await getTranslations(); + const bots = await ServerProfileApi.getMyBots(); + + return ( +
+ + +
+

+ {t("myBots")} +

+ = 5} /> +
+
+ {bots.length > 0 ? ( + bots.map((bot) => ) + ) : ( + + )} +
+
+
+ ); +} diff --git a/front_end/src/app/(main)/accounts/settings/components/settings_header.tsx b/front_end/src/app/(main)/accounts/settings/components/settings_header.tsx index 4e3d162539..1de4861f45 100644 --- a/front_end/src/app/(main)/accounts/settings/components/settings_header.tsx +++ b/front_end/src/app/(main)/accounts/settings/components/settings_header.tsx @@ -3,6 +3,7 @@ import { faBell, faGear, + faRobot, faUser, IconDefinition, } from "@fortawesome/free-solid-svg-icons"; @@ -12,10 +13,12 @@ import { useTranslations } from "next-intl"; import { FC } from "react"; import ButtonGroup, { GroupButton } from "@/components/ui/button_group"; +import { useAuth } from "@/contexts/auth_context"; import { isPathEqual } from "@/utils/navigation"; const SettingsHeader: FC = ({}) => { const t = useTranslations(); + const { user } = useAuth(); const pathname = usePathname(); const tabsOptions: GroupButton[] = [ { @@ -28,6 +31,15 @@ const SettingsHeader: FC = ({}) => { label: , href: "/accounts/settings/notifications/", }, + ...(!user?.is_bot + ? [ + { + value: "bots", + label: , + href: "/accounts/settings/bots/", + }, + ] + : []), { value: "account", label: , @@ -46,12 +58,15 @@ const SettingsHeader: FC = ({}) => {
{t("settingsDescription")}
- {}} - variant="tertiary" - /> +
+ {}} + variant="tertiary" + containerClassName="w-max" + /> +
); }; @@ -62,7 +77,7 @@ const TabItem: FC<{ icon: IconDefinition; label: string }> = ({ }) => { return (
- + {label}
); diff --git a/front_end/src/app/(main)/accounts/settings/layout.tsx b/front_end/src/app/(main)/accounts/settings/layout.tsx index b37c9a436a..4c985696e8 100644 --- a/front_end/src/app/(main)/accounts/settings/layout.tsx +++ b/front_end/src/app/(main)/accounts/settings/layout.tsx @@ -18,7 +18,7 @@ export default async function Layout({ if (!token || !currentUser) return redirect("/"); return ( -
+
{children}
diff --git a/front_end/src/app/(main)/accounts/settings/notifications/page.tsx b/front_end/src/app/(main)/accounts/settings/notifications/page.tsx index e13927a9b3..6ce74f1bfc 100644 --- a/front_end/src/app/(main)/accounts/settings/notifications/page.tsx +++ b/front_end/src/app/(main)/accounts/settings/notifications/page.tsx @@ -5,6 +5,10 @@ import QuestionNotifications from "@/app/(main)/accounts/settings/notifications/ import ServerPostsApi from "@/services/api/posts/posts.server"; import ServerProfileApi from "@/services/api/profile/profile.server"; +export const metadata = { + title: "Notification Settings", +}; + export default async function Page() { const currentUser = await ServerProfileApi.getMyProfile(); const posts = await ServerPostsApi.getAllSubscriptions(); diff --git a/front_end/src/app/(main)/aib/2026/spring/page.tsx b/front_end/src/app/(main)/aib/2026/spring/page.tsx index b5b975e0f5..4e227c64f6 100644 --- a/front_end/src/app/(main)/aib/2026/spring/page.tsx +++ b/front_end/src/app/(main)/aib/2026/spring/page.tsx @@ -1,3 +1,4 @@ +import ServerProfileApi from "@/services/api/profile/profile.server"; import { getServerSession } from "@/services/session"; import AiBenchmarkingTournamentPage from "../../components/page-view"; @@ -8,8 +9,33 @@ export const metadata = { "Join the AI Forecasting Benchmark (AIB) tournament on Metaculus. Test your AI bot's ability to make accurate probabilistic forecasts on real-world questions. $50,000 prize pool per quarter. Register your bot and compete against the best AI forecasters.", }; -export default async function Settings() { +async function getPrimaryBotToken() { + const user = await ServerProfileApi.getMyProfile(); const token = await getServerSession(); - return ; + if (!user) { + return null; + } + + if (user.is_bot) { + return token; + } + + const bots = await ServerProfileApi.getMyBots(); + const primaryBot = bots.find((bot) => bot.is_primary_bot); + + if (primaryBot) { + const { token: botToken } = await ServerProfileApi.getBotToken( + primaryBot.id + ); + return botToken; + } + + return null; +} + +export default async function Settings() { + const primaryBotToken = await getPrimaryBotToken(); + + return ; } diff --git a/front_end/src/app/(main)/aib/components/page-view.tsx b/front_end/src/app/(main)/aib/components/page-view.tsx index a236ad34c7..c218f40401 100644 --- a/front_end/src/app/(main)/aib/components/page-view.tsx +++ b/front_end/src/app/(main)/aib/components/page-view.tsx @@ -8,7 +8,6 @@ import Link from "next/link"; import { useTranslations } from "next-intl"; import { FC, useState } from "react"; -import { LogOut } from "@/app/(main)/accounts/actions"; import { SignupForm } from "@/components/auth/signup"; import BaseModal from "@/components/base_modal"; import Button from "@/components/ui/button"; @@ -19,12 +18,13 @@ import Description from "./description"; import Hero from "./hero"; import TournamentPager, { TOURNAMENT_ITEMS } from "./tournament-pager"; -const AiBenchmarkingTournamentPage: FC<{ token: string | null }> = ({ - token, -}) => { +type Props = { + primaryBotToken?: string | null; +}; + +const AiBenchmarkingTournamentPage: FC = ({ primaryBotToken }) => { const { user } = useAuth(); const isUserAuthenticated = !!user; - const isUserBot = isUserAuthenticated && user.is_bot; const [modalOpen, setModalOpen] = useState(false); const [tokenmodalOpen, setTokenModalOpen] = useState(false); const t = useTranslations(); @@ -72,32 +72,34 @@ const AiBenchmarkingTournamentPage: FC<{ token: string | null }> = ({ size="lg" className="border-none" > - {t("FABCreateBot")} + {t("FABCreateAccount")} +
{t("FABRegisterBotSecondary")}
)} - {isUserAuthenticated && !isUserBot && ( -
+ {isUserAuthenticated && !primaryBotToken && ( +
{t("FABGettingStarted")} - - {t("FABSeparateBotAccount")} + + {t("FABCreateBotTitle")} +
+ {t("FABCreateBotDescription")} +
)} - {isUserAuthenticated && isUserBot && ( + {isUserAuthenticated && primaryBotToken && (
{t("FABBotRegistered")} @@ -159,17 +161,17 @@ const AiBenchmarkingTournamentPage: FC<{ token: string | null }> = ({ setModalOpen(false)}>

- {t("FABCreateBotAccount")} + {t("registrationHeadingSite")}

-

- {t("FABBotAlreadyCreated")} +

+ {t("FABCreatePopupDescription")}

{/* The project ID to add the user to is hardcoded here - the FAB project is changed once every quarter, and it doesn't make sense to build infrastructure to manage the ID from the UI. When the new FAB tournament starts, we'll just change the ID here. */} - +
{t.rich("registrationTerms", { @@ -194,7 +196,7 @@ const AiBenchmarkingTournamentPage: FC<{ token: string | null }> = ({
{t("FABTokenInfo")}
- {token} + {primaryBotToken}
diff --git a/front_end/src/app/(main)/aib/page.tsx b/front_end/src/app/(main)/aib/page.tsx deleted file mode 100644 index 6e3852451b..0000000000 --- a/front_end/src/app/(main)/aib/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { getServerSession } from "@/services/session"; - -import AiBenchmarkingTournamentPage from "./components/page-view"; - -export const metadata = { - title: "AI Forecasting Benchmark Tournament | Metaculus", - description: - "Join the AI Forecasting Benchmark (AIB) tournament on Metaculus. Test your AI bot's ability to make accurate probabilistic forecasts on real-world questions. $30,000 prize pool per quarter. Register your bot and compete against the best AI forecasters.", -}; - -export default async function Settings() { - const token = await getServerSession(); - - return ; -} diff --git a/front_end/src/app/(main)/components/impersonation_banner.tsx b/front_end/src/app/(main)/components/impersonation_banner.tsx new file mode 100644 index 0000000000..5cb655518a --- /dev/null +++ b/front_end/src/app/(main)/components/impersonation_banner.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useTranslations } from "next-intl"; +import { FC, useState } from "react"; + +import { stopImpersonatingAction } from "@/app/(main)/accounts/settings/actions"; +import Button from "@/components/ui/button"; +import { useAuth } from "@/contexts/auth_context"; + +const ImpersonationBanner: FC = () => { + const t = useTranslations(); + const [isLoading, setIsLoading] = useState(false); + const { user } = useAuth(); + + const handleStop = async () => { + setIsLoading(true); + stopImpersonatingAction().finally(() => setIsLoading(false)); + }; + + if (!user?.is_bot) return; + + return ( +
+ {t("impersonationBannerText")} + +
+ ); +}; + +export default ImpersonationBanner; diff --git a/front_end/src/app/(main)/layout.tsx b/front_end/src/app/(main)/layout.tsx index 6fd8043bb1..014035924c 100644 --- a/front_end/src/app/(main)/layout.tsx +++ b/front_end/src/app/(main)/layout.tsx @@ -3,6 +3,7 @@ import "@fortawesome/fontawesome-svg-core/styles.css"; import type { Metadata } from "next"; import { defaultDescription } from "@/constants/metadata"; +import { getImpersonatorSession } from "@/services/session"; import { getPublicSettings } from "@/utils/public_settings.server"; import FeedbackFloat from "./(home)/components/feedback_float"; @@ -10,6 +11,7 @@ import Bulletins from "./components/bulletins"; import CookiesBanner from "./components/cookies_banner"; import Footer from "./components/footer"; import GlobalHeader from "./components/headers/global_header"; +import ImpersonationBanner from "./components/impersonation_banner"; import VersionChecker from "./components/version_checker"; config.autoAddCss = false; @@ -26,9 +28,14 @@ export default async function RootLayout({ }: Readonly<{ children: React.ReactNode; }>) { + const impersonatorToken = await getImpersonatorSession(); + return (
+ + {impersonatorToken && } +
{children}
{!PUBLIC_MINIMAL_UI && ( diff --git a/front_end/src/components/auth/signup.tsx b/front_end/src/components/auth/signup.tsx index 8d927544d6..c943426cb5 100644 --- a/front_end/src/components/auth/signup.tsx +++ b/front_end/src/components/auth/signup.tsx @@ -42,12 +42,14 @@ export const SignupForm: FC<{ email?: string; inviteToken?: string; withNewsletterOptin?: boolean; + redirectLocation?: string; }> = ({ forceIsBot, addToProject, email, inviteToken, withNewsletterOptin, + redirectLocation, }) => { const t = useTranslations(); const { themeChoice } = useAppTheme(); @@ -72,6 +74,10 @@ export const SignupForm: FC<{ const currentLocation = usePathname(); + if (!redirectLocation) { + redirectLocation = currentLocation; + } + const { watch, setValue, formState, handleSubmit, setError, clearErrors } = methods; @@ -80,7 +86,7 @@ export const SignupForm: FC<{ const onSubmit = async (data: SignUpSchema) => { const response = await signUpAction({ ...data, - redirectUrl: currentLocation, + redirectUrl: redirectLocation, newsletterOptin: watch("newsletterOptin"), appTheme: (Object.values(AppTheme) as string[]).includes( themeChoice ?? "" @@ -103,7 +109,7 @@ export const SignupForm: FC<{ } else { sendAnalyticsEvent("register", { event_category: new URLSearchParams(window.location.search).toString(), - signupPath: currentLocation, + signupPath: redirectLocation, }); if (response?.is_active) { setCurrentModal(null); @@ -350,14 +356,14 @@ const SignUpFormFragment: FC<{
= { activeVariant?: ButtonVariant; className?: string; activeClassName?: string; + containerClassName?: string; }; const ButtonGroup = ({ @@ -28,10 +29,11 @@ const ButtonGroup = ({ variant, activeVariant = "primary", className, + containerClassName, activeClassName, }: Props) => { return ( -
+
{buttons.map((button, index) => (