feat(i18n): migrate from react-i18next to next-intl

- Install next-intl package
- Configure next-intl middleware for cookie-based locale detection
- Set up routing config with supported locales (en, de, ja, hi, es, la)
- Create request config for server-side locale resolution
- Migrate Rithmomachia guide sections to next-intl
- Update translation calls to use next-intl's useTranslations hook
- Remove old react-i18next config

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-11-01 14:45:47 -05:00
parent 5acb928314
commit 9016b76024
13 changed files with 381 additions and 432 deletions

View File

@ -1,3 +1,7 @@
const createNextIntlPlugin = require('next-intl/plugin')
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
/** @type {import('next').NextConfig} */
const nextConfig = {
eslint: {
@ -63,4 +67,4 @@ const nextConfig = {
},
}
module.exports = nextConfig
module.exports = withNextIntl(nextConfig)

View File

@ -58,7 +58,6 @@
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.6",
"emojibase-data": "^16.0.3",
"i18next": "^25.6.0",
"jose": "^6.1.0",
"js-yaml": "^4.1.0",
"lib0": "^0.2.114",
@ -67,11 +66,11 @@
"nanoid": "^5.1.6",
"next": "^14.2.32",
"next-auth": "5.0.0-beta.29",
"next-intl": "^4.4.0",
"python-bridge": "^1.1.0",
"qrcode.react": "^4.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-i18next": "^16.2.3",
"react-resizable-layout": "^0.7.3",
"socket.io": "^4.8.1",
"socket.io-client": "^4.8.1",

View File

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
import { RithmomachiaBoard, type ExamplePiece } from '../RithmomachiaBoard'
@ -16,7 +16,7 @@ function squaresToCropArea(topLeft: string, bottomRight: string) {
}
export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
const { t } = useTranslation()
const t = useTranslations('rithmomachia.guide')
// Example board positions for captures
const equalityExample: ExamplePiece[] = [
@ -63,13 +63,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '16px',
})}
>
{t('guide.capture.title', 'How to Capture')}
{t('capture.title')}
</h3>
<p className={css({ fontSize: '15px', lineHeight: '1.6', mb: '24px', color: '#374151' })}>
{t(
'guide.capture.description',
'You can only capture an enemy piece if your piece value has a mathematical relation to theirs:'
)}
{t('capture.description')}
</p>
<h4
@ -81,7 +78,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mt: '20px',
})}
>
{t('guide.capture.simpleTitle', 'Simple Relations (no helper needed)')}
{t('capture.simpleTitle')}
</h4>
{/* Equality */}
@ -95,10 +92,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('guide.capture.equality', 'Equal')}
{t('capture.equality')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('guide.capture.equalityExample', 'Your 25 captures their 25')}
{t('capture.equalityExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
@ -119,10 +116,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
fontStyle: 'italic',
})}
>
{t(
'guide.capture.equalityCaption',
'White Circle (25) can capture Black Circle (25) by equality'
)}
{t('capture.equalityCaption')}
</p>
</div>
@ -137,10 +131,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('guide.capture.multiple', 'Multiple / Divisor')}
{t('capture.multiple')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('guide.capture.multipleExample', 'Your 64 captures their 16 (64 ÷ 16 = 4)')}
{t('capture.multipleExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
@ -161,10 +155,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
fontStyle: 'italic',
})}
>
{t(
'guide.capture.multipleCaption',
'White Square (64) can capture Black Triangle (16) because 64 ÷ 16 = 4'
)}
{t('capture.multipleCaption')}
</p>
</div>
@ -177,7 +168,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mt: '24px',
})}
>
{t('guide.capture.advancedTitle', 'Advanced Relations (need one helper piece)')}
{t('capture.advancedTitle')}
</h4>
{/* Sum */}
@ -191,10 +182,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('guide.capture.sum', 'Sum')}
{t('capture.sum')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('guide.capture.sumExample', 'Your 9 + helper 16 = enemy 25')}
{t('capture.sumExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
@ -215,10 +206,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
fontStyle: 'italic',
})}
>
{t(
'guide.capture.sumCaption',
'White Circle (9) can capture Black Circle (25) using helper Triangle (16): 9 + 16 = 25'
)}
{t('capture.sumCaption')}
</p>
</div>
@ -233,10 +221,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('guide.capture.difference', 'Difference')}
{t('capture.difference')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('guide.capture.differenceExample', 'Your 30 - helper 10 = enemy 20')}
{t('capture.differenceExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
@ -257,10 +245,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
fontStyle: 'italic',
})}
>
{t(
'guide.capture.differenceCaption',
'White Triangle (30) can capture Black Triangle (20) using helper Circle (10): 30 - 10 = 20'
)}
{t('capture.differenceCaption')}
</p>
</div>
@ -275,10 +260,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('guide.capture.product', 'Product')}
{t('capture.product')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('guide.capture.productExample', 'Your 5 × helper 5 = enemy 25')}
{t('capture.productExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
@ -299,10 +284,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
fontStyle: 'italic',
})}
>
{t(
'guide.capture.productCaption',
'White Circle (5) can capture Black Circle (25) using helper Circle (5): 5 × 5 = 25'
)}
{t('capture.productCaption')}
</p>
</div>
@ -317,10 +299,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#111827', mb: '8px' })}>
{t('guide.capture.ratio', 'Ratio')}
{t('capture.ratio')}
</p>
<p className={css({ fontSize: '13px', color: '#6b7280', mb: '12px' })}>
{t('guide.capture.ratioExample', 'Your 20 ÷ helper 4 = enemy 5')}
{t('capture.ratioExample')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
@ -341,10 +323,7 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
fontStyle: 'italic',
})}
>
{t(
'guide.capture.ratioCaption',
'White Triangle (20) can capture Black Circle (5) using helper Circle (4): 20 ÷ 4 = 5'
)}
{t('capture.ratioCaption')}
</p>
</div>
@ -358,13 +337,10 @@ export function CaptureSection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '14px', fontWeight: 'bold', color: '#1e40af', mb: '8px' })}>
{t('guide.capture.helpersTitle', '💡 What are helpers?')}
{t('capture.helpersTitle')}
</p>
<p className={css({ fontSize: '14px', color: '#1e3a8a', lineHeight: '1.6' })}>
{t(
'guide.capture.helpersDescription',
'Helpers are your other pieces still on the board. They stay where they are and just provide their value for the math. The game shows you valid captures when you select a piece.'
)}
{t('capture.helpersDescription')}
</p>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
import { RithmomachiaBoard, type ExamplePiece } from '../RithmomachiaBoard'
@ -16,7 +16,7 @@ function squaresToCropArea(topLeft: string, bottomRight: string) {
}
export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
const { t } = useTranslation()
const t = useTranslations('rithmomachia.guide')
// Example board positions for harmonies (White pieces in Black's territory: rows 5-8)
const arithmeticExample: ExamplePiece[] = [
@ -47,19 +47,13 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '16px',
})}
>
{t('guide.harmony.title', 'Harmonies: The Elegant Victory')}
{t('harmony.title')}
</h3>
<p className={css({ fontSize: '15px', lineHeight: '1.6', mb: '8px', color: '#374151' })}>
{t(
'guide.harmony.intro',
'A Harmony (also called a "Proper Victory") is the most sophisticated way to win. Get 3 of your pieces into enemy territory arranged in a straight line where their values form a mathematical pattern.'
)}
{t('harmony.intro')}
</p>
<p className={css({ fontSize: '14px', lineHeight: '1.6', mb: '24px', color: '#6b7280' })}>
{t(
'guide.harmony.introDetail',
'Think of it like getting three numbers in a sequence—but the sequences follow special mathematical rules from ancient philosophy and music theory.'
)}
{t('harmony.introDetail')}
</p>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '24px' })}>
@ -75,13 +69,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '18px', fontWeight: 'bold', color: '#15803d', mb: '8px' })}
>
{t('guide.harmony.arithmetic', '1. Arithmetic Progression (Easiest to Understand)')}
{t('harmony.arithmetic')}
</h4>
<p className={css({ fontSize: '14px', color: '#166534', mb: '12px', lineHeight: '1.6' })}>
{t(
'guide.harmony.arithmeticDesc',
'The middle number is exactly halfway between the other two. In other words, the differences are equal.'
)}
{t('harmony.arithmeticDesc')}
</p>
<div
@ -101,7 +92,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '8px',
})}
>
{t('guide.harmony.howToCheck', 'How to check:')}
{t('harmony.howToCheck')}
</p>
<p
className={css({
@ -112,18 +103,17 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '12px',
})}
>
{t('guide.harmony.arithmeticFormula', 'Middle × 2 = First + Last')}
{t('harmony.arithmeticFormula')}
</p>
<div className={css({ fontSize: '13px', color: '#166534', lineHeight: '1.8' })}>
<p className={css({ fontWeight: 'bold', mb: '4px' })}>
{t('guide.harmony.example', 'Example:')} 6, 9, 12
{t('harmony.example')} 6, 9, 12
</p>
<p>
{t('guide.harmony.differences', 'Differences:')} 96=3, 129=3{' '}
{t('guide.harmony.equal', '(equal!)')}
{t('harmony.differences')} 96=3, 129=3 {t('harmony.equal')}
</p>
<p>{t('guide.harmony.check', 'Check:')} 9×2 = 18 = 6+12 </p>
<p>{t('harmony.check')} 9×2 = 18 = 6+12 </p>
</div>
</div>
@ -146,10 +136,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '12px',
})}
>
{t(
'guide.harmony.arithmeticCaption',
'White pieces 6, 9, 12 in a row in enemy territory form an arithmetic progression'
)}
{t('harmony.arithmeticCaption')}
</p>
<div
@ -161,11 +148,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '13px', color: '#15803d' })}>
<strong>{t('guide.harmony.strategyTip', 'Strategy tip:')}</strong>{' '}
{t(
'guide.harmony.arithmeticTip',
'Your small circles (2-9) and many triangles naturally form arithmetic progressions. Look for three pieces where the gaps are equal!'
)}
<strong>{t('harmony.strategyTip')}</strong> {t('harmony.arithmeticTip')}
</p>
</div>
</div>
@ -182,13 +165,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '18px', fontWeight: 'bold', color: '#92400e', mb: '8px' })}
>
{t('guide.harmony.geometric', '2. Geometric Progression (Powers and Multiples)')}
{t('harmony.geometric')}
</h4>
<p className={css({ fontSize: '14px', color: '#78350f', mb: '12px', lineHeight: '1.6' })}>
{t(
'guide.harmony.geometricDesc',
'Each number is multiplied by the same amount to get the next. The ratios are equal.'
)}
{t('harmony.geometricDesc')}
</p>
<div
@ -208,7 +188,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '8px',
})}
>
{t('guide.harmony.howToCheck', 'How to check:')}
{t('harmony.howToCheck')}
</p>
<p
className={css({
@ -219,18 +199,17 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '12px',
})}
>
{t('guide.harmony.geometricFormula', 'Middle² = First × Last')}
{t('harmony.geometricFormula')}
</p>
<div className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.8' })}>
<p className={css({ fontWeight: 'bold', mb: '4px' })}>
{t('guide.harmony.example', 'Example:')} 4, 8, 16
{t('harmony.example')} 4, 8, 16
</p>
<p>
{t('guide.harmony.ratios', 'Ratios:')} 8÷4=2, 16÷8=2{' '}
{t('guide.harmony.equal', '(equal!)')}
{t('harmony.ratios')} 8÷4=2, 16÷8=2 {t('harmony.equal')}
</p>
<p>{t('guide.harmony.check', 'Check:')} 8² = 64 = 4×16 </p>
<p>{t('harmony.check')} 8² = 64 = 4×16 </p>
</div>
</div>
@ -253,10 +232,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '12px',
})}
>
{t(
'guide.harmony.geometricCaption',
'White pieces 4, 8, 16 in a row in enemy territory form a geometric progression'
)}
{t('harmony.geometricCaption')}
</p>
<div
@ -268,11 +244,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '13px', color: '#92400e' })}>
<strong>{t('guide.harmony.strategyTip', 'Strategy tip:')}</strong>{' '}
{t(
'guide.harmony.geometricTip',
'Square values (4, 9, 16, 25, 36, 49, 64, 81) work great here! For example, 4-16-64 (squares of 2, 4, 8).'
)}
<strong>{t('harmony.strategyTip')}</strong> {t('harmony.geometricTip')}
</p>
</div>
</div>
@ -289,13 +261,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '18px', fontWeight: 'bold', color: '#1e40af', mb: '8px' })}
>
{t('guide.harmony.harmonic', '3. Harmonic Progression (Music-Based, Trickiest)')}
{t('harmony.harmonic')}
</h4>
<p className={css({ fontSize: '14px', color: '#1e3a8a', mb: '12px', lineHeight: '1.6' })}>
{t(
'guide.harmony.harmonicDesc',
'Named after musical harmonies. The pattern is: the ratio of the outer numbers equals the ratio of their differences from the middle.'
)}
{t('harmony.harmonicDesc')}
</p>
<div
@ -315,7 +284,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '8px',
})}
>
{t('guide.harmony.howToCheck', 'How to check:')}
{t('harmony.howToCheck')}
</p>
<p
className={css({
@ -326,14 +295,14 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '12px',
})}
>
{t('guide.harmony.harmonicFormula', '2 × First × Last = Middle × (First + Last)')}
{t('harmony.harmonicFormula')}
</p>
<div className={css({ fontSize: '13px', color: '#1e3a8a', lineHeight: '1.8' })}>
<p className={css({ fontWeight: 'bold', mb: '4px' })}>
{t('guide.harmony.example', 'Example:')} 6, 8, 12
{t('harmony.example')} 6, 8, 12
</p>
<p>{t('guide.harmony.check', 'Check:')} 2×6×12 = 144 = 8×(6+12) = 8×18 </p>
<p>{t('harmony.check')} 2×6×12 = 144 = 8×(6+12) = 8×18 </p>
</div>
</div>
@ -356,10 +325,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '12px',
})}
>
{t(
'guide.harmony.harmonicCaption',
'White pieces 6, 8, 12 in a row in enemy territory form a harmonic progression'
)}
{t('harmony.harmonicCaption')}
</p>
<div
@ -371,11 +337,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '13px', color: '#1e40af' })}>
<strong>{t('guide.harmony.strategyTip', 'Strategy tip:')}</strong>{' '}
{t(
'guide.harmony.harmonicTip',
'Harmonic progressions are rarer. Memorize common triads: (3,4,6), (4,6,12), (6,8,12), (6,10,15), (8,12,24).'
)}
<strong>{t('harmony.strategyTip')}</strong> {t('harmony.harmonicTip')}
</p>
</div>
</div>
@ -392,43 +354,23 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '16px', fontWeight: 'bold', color: '#991b1b', mb: '12px' })}>
{t('guide.harmony.rulesTitle', '⚠️ Harmony Rules You Must Follow')}
{t('harmony.rulesTitle')}
</p>
<ul className={css({ fontSize: '14px', color: '#7f1d1d', lineHeight: '1.8', pl: '20px' })}>
<li>
<strong>{t('guide.harmony.enemyTerritoryTitle', 'Enemy Territory Only:')}</strong>{' '}
{t(
'guide.harmony.enemyTerritory',
"All 3 pieces must be in your opponent's half (White needs rows 5-8, Black needs rows 1-4)"
)}
<strong>{t('harmony.enemyTerritoryTitle')}</strong> {t('harmony.enemyTerritory')}
</li>
<li>
<strong>{t('guide.harmony.straightLineTitle', 'Straight Line:')}</strong>{' '}
{t(
'guide.harmony.straightLine',
'The 3 pieces must form a row, column, or diagonal—no scattered formations'
)}
<strong>{t('harmony.straightLineTitle')}</strong> {t('harmony.straightLine')}
</li>
<li>
<strong>{t('guide.harmony.adjacentTitle', 'Adjacent Placement:')}</strong>{' '}
{t(
'guide.harmony.adjacent',
'In this implementation, the 3 pieces must be next to each other (no gaps)'
)}
<strong>{t('harmony.adjacentTitle')}</strong> {t('harmony.adjacent')}
</li>
<li>
<strong>{t('guide.harmony.survivalTitle', 'Survival Rule:')}</strong>{' '}
{t(
'guide.harmony.survival',
'When you declare a harmony, your opponent gets ONE turn to break it by capturing or moving a piece'
)}
<strong>{t('harmony.survivalTitle')}</strong> {t('harmony.survival')}
</li>
<li>
<strong>{t('guide.harmony.victoryTitle', 'Victory:')}</strong>{' '}
{t(
'guide.harmony.victoryRule',
'If your harmony survives until your next turn starts—you win!'
)}
<strong>{t('harmony.victoryTitle')}</strong> {t('harmony.victoryRule')}
</li>
</ul>
</div>
@ -443,7 +385,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '16px',
})}
>
{t('guide.harmony.strategyTitle', 'Strategy: How to Build Harmonies')}
{t('harmony.strategyTitle')}
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '16px' })}>
@ -452,13 +394,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
>
{t('guide.harmony.startWith2Title', 'Start with 2, Add the Third')}
{t('harmony.startWith2Title')}
</h4>
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
{t(
'guide.harmony.startWith2',
"Get two pieces into enemy territory first. Calculate which third piece would complete a progression, then advance that piece. Your opponent may not notice the threat until it's too late!"
)}
{t('harmony.startWith2')}
</p>
</div>
@ -467,13 +406,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
>
{t('guide.harmony.useCommonTitle', 'Use Common Values')}
{t('harmony.useCommonTitle')}
</h4>
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
{t(
'guide.harmony.useCommon',
'Pieces like 6, 8, 9, 12, 16 appear in multiple progressions. If you have these in enemy territory, calculate all possible third pieces that would complete a pattern.'
)}
{t('harmony.useCommon')}
</p>
</div>
@ -482,13 +418,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
>
{t('guide.harmony.protectTitle', 'Protect the Line')}
{t('harmony.protectTitle')}
</h4>
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
{t(
'guide.harmony.protect',
'While building your harmony, position other pieces to defend your advancing pieces. One capture breaks the progression!'
)}
{t('harmony.protect')}
</p>
</div>
@ -497,13 +430,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
>
{t('guide.harmony.blockTitle', "Block Opponent's Harmonies")}
{t('harmony.blockTitle')}
</h4>
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
{t(
'guide.harmony.block',
'If your opponent has 2 pieces in your territory forming part of a progression, identify which third piece would complete it. Block that square or capture one of the two pieces immediately.'
)}
{t('harmony.block')}
</p>
</div>
@ -512,13 +442,10 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
<h4
className={css({ fontSize: '15px', fontWeight: 'bold', color: '#374151', mb: '8px' })}
>
{t('guide.harmony.calculateTitle', 'Calculate Before You Declare')}
{t('harmony.calculateTitle')}
</h4>
<p className={css({ fontSize: '14px', color: '#6b7280', lineHeight: '1.6' })}>
{t(
'guide.harmony.calculate',
'Before declaring harmony, examine if your opponent can capture any of the 3 pieces on their turn. If they can, either protect those pieces first or wait for a safer moment.'
)}
{t('harmony.calculate')}
</p>
</div>
</div>
@ -534,7 +461,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '16px',
})}
>
{t('guide.harmony.quickRefTitle', '💡 Quick Reference: Common Harmonies in Your Army')}
{t('harmony.quickRefTitle')}
</h3>
<div
@ -561,7 +488,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '8px',
})}
>
{t('guide.harmony.arithmetic', 'Arithmetic')}
{t('harmony.arithmetic')}
</h4>
<ul
className={css({
@ -598,7 +525,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '8px',
})}
>
{t('guide.harmony.geometric', 'Geometric')}
{t('harmony.geometric')}
</h4>
<ul
className={css({
@ -635,7 +562,7 @@ export function HarmonySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '8px',
})}
>
{t('guide.harmony.harmonic', 'Harmonic')}
{t('harmony.harmonic')}
</h4>
<ul
className={css({

View File

@ -1,9 +1,9 @@
import { useTranslation } from 'react-i18next'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
import { RithmomachiaBoard, type ExamplePiece } from '../RithmomachiaBoard'
export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
const { t } = useTranslation()
const t = useTranslations('rithmomachia.guide')
// Initial board setup - full starting position
const initialSetup: ExamplePiece[] = [
@ -75,13 +75,10 @@ export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNum
mb: '16px',
})}
>
{t('guide.overview.goalTitle', 'Goal of the Game')}
{t('overview.goalTitle')}
</h3>
<p className={css({ fontSize: '16px', lineHeight: '1.6', mb: '20px', color: '#374151' })}>
{t(
'guide.overview.goal',
'Arrange 3 of your pieces in enemy territory to form a mathematical progression, survive one opponent turn, and win.'
)}
{t('overview.goal')}
</p>
<h3
@ -93,7 +90,7 @@ export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNum
mt: '24px',
})}
>
{t('guide.overview.boardTitle', 'The Board')}
{t('overview.boardTitle')}
</h3>
<div className={css({ mb: '20px' })}>
@ -106,10 +103,7 @@ export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNum
</div>
<p className={css({ fontSize: '14px', color: '#6b7280', mb: '20px', fontStyle: 'italic' })}>
{t(
'guide.overview.boardCaption',
'The starting position - Black on the left, White on the right'
)}
{t('overview.boardCaption')}
</p>
<ul
@ -121,19 +115,9 @@ export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNum
color: '#374151',
})}
>
<li>{t('guide.overview.boardSize', '8 rows × 16 columns (columns A-P, rows 1-8)')}</li>
<li>
{t(
'guide.overview.territory',
'Your half: Black controls rows 5-8, White controls rows 1-4'
)}
</li>
<li>
{t(
'guide.overview.enemyTerritory',
'Enemy territory: Where you need to build your winning progression'
)}
</li>
<li>{t('overview.boardSize')}</li>
<li>{t('overview.territory')}</li>
<li>{t('overview.enemyTerritory')}</li>
</ul>
<h3
@ -145,7 +129,7 @@ export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNum
mt: '24px',
})}
>
{t('guide.overview.howToPlayTitle', 'How to Play')}
{t('overview.howToPlayTitle')}
</h3>
<ol
className={css({
@ -155,11 +139,11 @@ export function OverviewSection({ useNativeAbacusNumbers }: { useNativeAbacusNum
color: '#374151',
})}
>
<li>{t('guide.overview.step1', 'Move your pieces toward the center')}</li>
<li>{t('guide.overview.step2', 'Look for ways to capture using math')}</li>
<li>{t('guide.overview.step3', 'Push into enemy territory')}</li>
<li>{t('guide.overview.step4', 'Watch for chances to make a progression')}</li>
<li>{t('guide.overview.step5', 'Win by forming a progression that survives one turn!')}</li>
<li>{t('overview.step1')}</li>
<li>{t('overview.step2')}</li>
<li>{t('overview.step3')}</li>
<li>{t('overview.step4')}</li>
<li>{t('overview.step5')}</li>
</ol>
</div>
)

View File

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
import { PieceRenderer } from '../PieceRenderer'
import { RithmomachiaBoard } from '../RithmomachiaBoard'
@ -18,7 +18,7 @@ function squaresToCropArea(topLeft: string, bottomRight: string) {
}
export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
const { t } = useTranslation()
const t = useTranslations('rithmomachia.guide')
const pieces: {
type: PieceType
name: string
@ -28,22 +28,22 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
}[] = [
{
type: 'C',
name: t('guide.pieces.circle', 'Circle'),
movement: t('guide.pieces.circleMove', 'Moves diagonally'),
name: t('pieces.circle'),
movement: t('pieces.circleMove'),
count: 12,
exampleValues: [3, 5, 7, 9],
},
{
type: 'T',
name: t('guide.pieces.triangle', 'Triangle'),
movement: t('guide.pieces.triangleMove', 'Moves in straight lines'),
name: t('pieces.triangle'),
movement: t('pieces.triangleMove'),
count: 6,
exampleValues: [12, 16, 20, 30],
},
{
type: 'S',
name: t('guide.pieces.square', 'Square'),
movement: t('guide.pieces.squareMove', 'Moves in any direction'),
name: t('pieces.square'),
movement: t('pieces.squareMove'),
count: 6,
exampleValues: [25, 28, 45, 66],
},
@ -59,13 +59,10 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
mb: '16px',
})}
>
{t('guide.pieces.title', 'Your Pieces (25 total)')}
{t('pieces.title')}
</h3>
<p className={css({ fontSize: '15px', mb: '24px', color: '#374151' })}>
{t(
'guide.pieces.description',
'Each side has 25 pieces with different movement patterns. The shape tells you how it moves:'
)}
{t('pieces.description')}
</p>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '24px' })}>
@ -122,7 +119,7 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
fontStyle: 'italic',
})}
>
{t('guide.pieces.exampleValues', 'Example values')}:
{t('pieces.exampleValues')}:
</p>
<div className={css({ display: 'flex', gap: '12px', flexWrap: 'wrap' })}>
{piece.exampleValues.map((value) => (
@ -159,13 +156,10 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
})}
>
<h4 className={css({ fontSize: '18px', fontWeight: 'bold', color: '#92400e', mb: '12px' })}>
{t('guide.pieces.pyramidTitle', '⭐ Pyramids: The Multi-Faced Pieces')}
{t('pieces.pyramidTitle')}
</h4>
<p className={css({ fontSize: '14px', color: '#78350f', lineHeight: '1.6', mb: '16px' })}>
{t(
'guide.pieces.pyramidIntro',
'Unlike other pieces with a single value, Pyramids contain 4 face values representing perfect squares. When capturing an enemy piece, you choose which face to use for the mathematical relation.'
)}
{t('pieces.pyramidIntro')}
</p>
<div
@ -188,7 +182,7 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
textAlign: 'center',
})}
>
{t('guide.pieces.blackPyramid', 'Black Pyramid Faces')}:
{t('pieces.blackPyramid')}:
</p>
<div className={css({ width: '80px', height: '80px', margin: '0 auto' })}>
<PieceRenderer
@ -208,7 +202,7 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
fontWeight: 'bold',
})}
>
{t('guide.pieces.blackPyramidValues', '36 (6²), 25 (5²), 16 (4²), 4 (2²)')}
{t('pieces.blackPyramidValues')}
</p>
</div>
@ -223,7 +217,7 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
textAlign: 'center',
})}
>
{t('guide.pieces.whitePyramid', 'White Pyramid Faces')}:
{t('pieces.whitePyramid')}:
</p>
<div className={css({ width: '80px', height: '80px', margin: '0 auto' })}>
<PieceRenderer
@ -243,7 +237,7 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
fontWeight: 'bold',
})}
>
{t('guide.pieces.whitePyramidValues', '64 (8²), 49 (7²), 36 (6²), 25 (5²)')}
{t('pieces.whitePyramidValues')}
</p>
</div>
</div>
@ -266,35 +260,15 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
mb: '12px',
})}
>
{t('guide.pieces.pyramidHowItWorks', 'How face selection works:')}
{t('pieces.pyramidHowItWorks')}
</p>
<ul
className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.8', pl: '20px' })}
>
<li>
{t(
'guide.pieces.pyramidRule1',
"When your Pyramid attempts a capture, you must declare which face value you're using before the relation is checked"
)}
</li>
<li>
{t(
'guide.pieces.pyramidRule2',
'The chosen face value becomes "your piece\'s value" for all mathematical relations (equality, multiple/divisor, sum, difference, product, ratio)'
)}
</li>
<li>
{t(
'guide.pieces.pyramidRule3',
'You can choose different faces for different captures—the Pyramid doesn\'t "lock in" to one value'
)}
</li>
<li>
{t(
'guide.pieces.pyramidRule4',
'This flexibility makes Pyramids excellent for creating unexpected capture opportunities and versatile helpers'
)}
</li>
<li>{t('pieces.pyramidRule1')}</li>
<li>{t('pieces.pyramidRule2')}</li>
<li>{t('pieces.pyramidRule3')}</li>
<li>{t('pieces.pyramidRule4')}</li>
</ul>
</div>
@ -308,11 +282,7 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
})}
>
<p className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.6' })}>
<strong>{t('guide.pieces.example', 'Example:')}</strong>{' '}
{t(
'guide.pieces.pyramidExample',
"White's Pyramid can capture Black's 16 using face 64 (multiple: 64÷16=4), face 36 (multiple: 36÷9=4, with Black's 9), or face 25 with equality if capturing Black's 25."
)}
<strong>{t('pieces.example')}</strong> {t('pieces.pyramidExample')}
</p>
</div>
@ -326,16 +296,10 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
mb: '12px',
})}
>
{t(
'guide.pieces.pyramidVisualTitle',
"Visual Example: Pyramid's Multiple Capture Options"
)}
{t('pieces.pyramidVisualTitle')}
</h5>
<p className={css({ fontSize: '13px', color: '#78350f', mb: '12px', lineHeight: '1.6' })}>
{t(
'guide.pieces.pyramidVisualDesc',
"White's Pyramid (faces: 64, 49, 36, 25) is positioned to capture Black pieces. Notice the flexibility:"
)}
{t('pieces.pyramidVisualDesc')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center', mb: '12px' })}>
@ -372,29 +336,14 @@ export function PiecesSection({ useNativeAbacusNumbers }: { useNativeAbacusNumbe
mb: '8px',
})}
>
{t('guide.pieces.pyramidCaptureOptions', 'Capture options from H5:')}
{t('pieces.pyramidCaptureOptions')}
</p>
<ul
className={css({ fontSize: '13px', color: '#78350f', lineHeight: '1.8', pl: '20px' })}
>
<li>
{t(
'guide.pieces.pyramidOption1',
'Move to I5: Choose face 64 → captures 16 by multiple (64÷16=4)'
)}
</li>
<li>
{t(
'guide.pieces.pyramidOption2',
'Move to H6: Choose face 49 → captures 49 by equality (49=49)'
)}
</li>
<li>
{t(
'guide.pieces.pyramidOption3',
'Move to G5: Choose face 25 → captures 25 by equality (25=25)'
)}
</li>
<li>{t('pieces.pyramidOption1')}</li>
<li>{t('pieces.pyramidOption2')}</li>
<li>{t('pieces.pyramidOption3')}</li>
</ul>
</div>
</div>

View File

@ -1,4 +1,4 @@
import { useTranslation } from 'react-i18next'
import { useTranslations } from 'next-intl'
import { css } from '../../../../../styled-system/css'
import { RithmomachiaBoard, type ExamplePiece } from '../RithmomachiaBoard'
@ -16,7 +16,7 @@ function squaresToCropArea(topLeft: string, bottomRight: string) {
}
export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumbers: boolean }) {
const { t } = useTranslation()
const t = useTranslations('rithmomachia.guide')
// Example winning position: White has formed a geometric progression in Black's territory
const winningExample: ExamplePiece[] = [
@ -39,7 +39,7 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mb: '16px',
})}
>
{t('guide.victory.title', 'How to Win')}
{t('victory.title')}
</h3>
<div className={css({ display: 'flex', flexDirection: 'column', gap: '24px' })}>
@ -56,13 +56,10 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<span>👑</span>
<span>{t('guide.victory.harmony', 'Victory #1: Harmony (Progression)')}</span>
<span>{t('victory.harmony')}</span>
</h4>
<p className={css({ fontSize: '15px', lineHeight: '1.6', color: '#374151', mb: '12px' })}>
{t(
'guide.victory.harmonyDesc',
"Form a mathematical progression with 3 pieces in enemy territory. If it survives your opponent's next turn, you win!"
)}
{t('victory.harmonyDesc')}
</p>
{/* Visual example of winning harmony */}
@ -84,7 +81,7 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
textAlign: 'center',
})}
>
{t('guide.victory.exampleTitle', 'Example: White Wins!')}
{t('victory.exampleTitle')}
</p>
<div className={css({ display: 'flex', justifyContent: 'center' })}>
<RithmomachiaBoard
@ -105,10 +102,7 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
fontStyle: 'italic',
})}
>
{t(
'guide.victory.exampleCaption',
'White pieces 4, 8, 16 form a geometric progression in enemy territory. Black cannot break it - White wins!'
)}
{t('victory.exampleCaption')}
</p>
</div>
@ -121,10 +115,7 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<p className={css({ fontSize: '13px', color: '#15803d' })}>
{t(
'guide.victory.harmonyNote',
'This is the primary victory condition in Rithmomachia'
)}
{t('victory.harmonyNote')}
</p>
</div>
</div>
@ -142,13 +133,10 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
})}
>
<span>🚫</span>
<span>{t('guide.victory.exhaustion', 'Victory #2: Exhaustion')}</span>
<span>{t('victory.exhaustion')}</span>
</h4>
<p className={css({ fontSize: '15px', lineHeight: '1.6', color: '#374151' })}>
{t(
'guide.victory.exhaustionDesc',
'If your opponent has no legal moves at the start of their turn, they lose.'
)}
{t('victory.exhaustionDesc')}
</p>
</div>
</div>
@ -162,7 +150,7 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
mt: '32px',
})}
>
{t('guide.victory.strategyTitle', 'Quick Strategy Tips')}
{t('victory.strategyTitle')}
</h3>
<ul
className={css({
@ -172,31 +160,11 @@ export function VictorySection({ useNativeAbacusNumbers }: { useNativeAbacusNumb
color: '#374151',
})}
>
<li>{t('guide.victory.tip1', 'Control the center — easier to invade enemy territory')}</li>
<li>
{t(
'guide.victory.tip2',
'Small pieces are fast — circles (3, 5, 7, 9) can slip into enemy half quickly'
)}
</li>
<li>
{t(
'guide.victory.tip3',
'Large pieces are powerful — harder to capture due to their size'
)}
</li>
<li>
{t(
'guide.victory.tip4',
"Watch for harmony threats — don't let opponent get 3 pieces deep in your territory"
)}
</li>
<li>
{t(
'guide.victory.tip5',
'Pyramids are flexible — choose the right face value for each situation'
)}
</li>
<li>{t('victory.tip1')}</li>
<li>{t('victory.tip2')}</li>
<li>{t('victory.tip3')}</li>
<li>{t('victory.tip4')}</li>
<li>{t('victory.tip5')}</li>
</ul>
</div>
)

View File

@ -1,30 +0,0 @@
import i18n from 'i18next'
import { initReactI18next } from 'react-i18next'
import en from './locales/en.json'
import de from './locales/de.json'
import ja from './locales/ja.json'
import hi from './locales/hi.json'
import es from './locales/es.json'
import la from './locales/la.json'
export const defaultNS = 'translation'
export const resources = {
en: { translation: en },
de: { translation: de },
ja: { translation: ja },
hi: { translation: hi },
es: { translation: es },
la: { translation: la },
} as const
i18n.use(initReactI18next).init({
lng: 'en',
fallbackLng: 'en',
defaultNS,
resources,
interpolation: {
escapeValue: false, // React already escapes
},
})
export default i18n

View File

@ -0,0 +1,21 @@
/**
* Rithmomachia translations aggregated by locale
* Co-located with the game code
*/
// Import existing locale files
import enGuide from './i18n/locales/en.json'
import deGuide from './i18n/locales/de.json'
import jaGuide from './i18n/locales/ja.json'
import hiGuide from './i18n/locales/hi.json'
import esGuide from './i18n/locales/es.json'
import laGuide from './i18n/locales/la.json'
export const rithmomachiaMessages = {
en: { rithmomachia: enGuide },
de: { rithmomachia: deGuide },
ja: { rithmomachia: jaGuide },
hi: { rithmomachia: hiGuide },
es: { rithmomachia: esGuide },
la: { rithmomachia: laGuide },
} as const

View File

@ -0,0 +1,32 @@
import { getRequestConfig } from 'next-intl/server'
import { headers, cookies } from 'next/headers'
import { defaultLocale, LOCALE_COOKIE_NAME, locales, type Locale } from './routing'
import { getMessages } from './messages'
export async function getRequestLocale(): Promise<Locale> {
// Get locale from header (set by middleware) or cookie
const headersList = await headers()
const cookieStore = await cookies()
let locale = headersList.get('x-locale') as Locale | null
if (!locale) {
locale = cookieStore.get(LOCALE_COOKIE_NAME)?.value as Locale | undefined
}
// Validate and fallback to default
if (!locale || !locales.includes(locale)) {
locale = defaultLocale
}
return locale
}
export default getRequestConfig(async () => {
const locale = await getRequestLocale()
return {
locale,
messages: await getMessages(locale),
}
})

View File

@ -0,0 +1,9 @@
// Supported locales
export const locales = ['en', 'de', 'ja', 'hi', 'es', 'la'] as const
export type Locale = (typeof locales)[number]
// Default locale
export const defaultLocale: Locale = 'en'
// Locale cookie name
export const LOCALE_COOKIE_NAME = 'NEXT_LOCALE'

View File

@ -1,14 +1,43 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createGuestToken, GUEST_COOKIE_NAME } from './lib/guest-token'
import { defaultLocale, LOCALE_COOKIE_NAME, locales, type Locale } from './i18n/routing'
/**
* Middleware to:
* 1. Ensure every visitor gets a guest token
* 2. Add pathname to headers for Server Components
* 1. Detect and set locale based on Accept-Language header or cookie
* 2. Ensure every visitor gets a guest token
* 3. Add pathname and locale to headers for Server Components
*/
export async function middleware(request: NextRequest) {
const response = NextResponse.next()
// Detect locale from cookie or Accept-Language header
let locale = request.cookies.get(LOCALE_COOKIE_NAME)?.value as Locale | undefined
if (!locale || !locales.includes(locale)) {
// Parse Accept-Language header
const acceptLanguage = request.headers.get('accept-language')
if (acceptLanguage) {
const preferred = acceptLanguage
.split(',')
.map((lang) => lang.split(';')[0].trim().slice(0, 2))
.find((lang) => locales.includes(lang as Locale))
locale = (preferred as Locale) || defaultLocale
} else {
locale = defaultLocale
}
// Set locale cookie
response.cookies.set(LOCALE_COOKIE_NAME, locale, {
path: '/',
maxAge: 60 * 60 * 24 * 365, // 1 year
sameSite: 'lax',
})
}
// Add locale to headers for Server Components
response.headers.set('x-locale', locale)
// Add pathname to headers so Server Components can access it
response.headers.set('x-pathname', request.nextUrl.pathname)
@ -63,9 +92,10 @@ export const config = {
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* - api routes (next-intl doesn't need to handle these)
*
* Note: API routes ARE included so guest cookies are set for API requests
* Note: This matcher handles both i18n routing and guest tokens
*/
'/((?!_next/static|_next/image|favicon.ico).*)',
'/((?!api|_next|_vercel|.*\\..*).*)',
],
}

View File

@ -149,9 +149,6 @@ importers:
emojibase-data:
specifier: ^16.0.3
version: 16.0.3(emojibase@16.0.0)
i18next:
specifier: ^25.6.0
version: 25.6.0(typescript@5.9.3)
jose:
specifier: ^6.1.0
version: 6.1.0
@ -176,6 +173,9 @@ importers:
next-auth:
specifier: 5.0.0-beta.29
version: 5.0.0-beta.29(next@14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)
next-intl:
specifier: ^4.4.0
version: 4.4.0(next@14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
python-bridge:
specifier: ^1.1.0
version: 1.1.0
@ -188,9 +188,6 @@ importers:
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
react-i18next:
specifier: ^16.2.3
version: 16.2.3(i18next@25.6.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3)
react-resizable-layout:
specifier: ^0.7.3
version: 0.7.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -290,7 +287,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^1.0.0
version: 1.6.1(@types/node@20.19.19)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
version: 1.6.1(@types/node@20.19.19)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0)
packages/abacus-react:
dependencies:
@ -1819,6 +1816,24 @@ packages:
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@formatjs/ecma402-abstract@2.3.6':
resolution: {integrity: sha512-HJnTFeRM2kVFVr5gr5kH1XP6K0JcJtE7Lzvtr3FS/so5f1kpsqqqxy5JF+FRaO6H2qmcMfAUIox7AJteieRtVw==}
'@formatjs/fast-memoize@2.2.7':
resolution: {integrity: sha512-Yabmi9nSvyOMrlSeGGWDiH7rf3a7sIwplbvo/dlz9WCIjzIQAfy1RMf4S0X3yG724n5Ghu2GmEl5NJIV6O9sZQ==}
'@formatjs/icu-messageformat-parser@2.11.4':
resolution: {integrity: sha512-7kR78cRrPNB4fjGFZg3Rmj5aah8rQj9KPzuLsmcSn4ipLXQvC04keycTI1F7kJYDwIXtT2+7IDEto842CfZBtw==}
'@formatjs/icu-skeleton-parser@1.8.16':
resolution: {integrity: sha512-H13E9Xl+PxBd8D5/6TVUluSpxGNvFSlN/b3coUp0e0JpuWXXnQDiavIpY3NnvSp4xhEMoXyyBvVfdFX8jglOHQ==}
'@formatjs/intl-localematcher@0.5.10':
resolution: {integrity: sha512-af3qATX+m4Rnd9+wHcjJ4w2ijq+rAVP3CCinJQvFv1kgSu1W6jypUmvleJxcewdxmutM8dmIRZFxO/IQBZmP2Q==}
'@formatjs/intl-localematcher@0.6.2':
resolution: {integrity: sha512-XOMO2Hupl0wdd172Y06h6kLpBz6Dv+J4okPLl4LPtzbr8f66WbIoy4ev98EBuZ6ZK4h5ydTN6XneT4QVpD7cdA==}
'@humanwhocodes/config-array@0.13.0':
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
engines: {node: '>=10.10.0'}
@ -3154,6 +3169,9 @@ packages:
'@rushstack/eslint-patch@1.13.0':
resolution: {integrity: sha512-2ih5qGw5SZJ+2fLZxP6Lr6Na2NTIgPRL/7Kmyuw0uIyBQnuhQ8fi8fzUTd38eIQmqp+GYLC00cI6WgtqHxBwmw==}
'@schummar/icu-type-parser@1.21.5':
resolution: {integrity: sha512-bXHSaW5jRTmke9Vd0h5P7BtWZG9Znqb8gSDxZnxaGSJnGwPLDPfS+3g0BKzeWqzgZPsIVZkM7m2tbo18cm5HBw==}
'@semantic-release/changelog@6.0.3':
resolution: {integrity: sha512-dZuR5qByyfe3Y03TpmCvAxCyTnp7r5XwtHRf/8vD9EAn4ZWbavUX8adMtXYzE86EVh0gyLA7lm5yW4IV30XUag==}
engines: {node: '>=14.17'}
@ -6046,9 +6064,6 @@ packages:
engines: {node: '>=12'}
hasBin: true
html-parse-stringify@3.0.1:
resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==}
html-tags@3.3.1:
resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==}
engines: {node: '>=8'}
@ -6095,14 +6110,6 @@ packages:
resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==}
engines: {node: '>=16.17.0'}
i18next@25.6.0:
resolution: {integrity: sha512-tTn8fLrwBYtnclpL5aPXK/tAYBLWVvoHM1zdfXoRNLcI+RvtMsoZRV98ePlaW3khHYKuNh/Q65W/+NVFUeIwVw==}
peerDependencies:
typescript: ^5
peerDependenciesMeta:
typescript:
optional: true
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -6174,6 +6181,9 @@ packages:
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
engines: {node: '>= 0.4'}
intl-messageformat@10.7.18:
resolution: {integrity: sha512-m3Ofv/X/tV8Y3tHXLohcuVuhWKo7BBq62cqY15etqmLxg2DZ34AGGgQDeR+SCta2+zICb1NX83af0GJmbQ1++g==}
into-stream@7.0.0:
resolution: {integrity: sha512-2dYz766i9HprMBasCMvHMuazJ7u4WzhJwo5kb3iPSiW/iRYV6uPari3zHoqZlnuaR7V1bEiNMxikhp37rdBXbw==}
engines: {node: '>=12'}
@ -7009,6 +7019,10 @@ packages:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
neo-async@2.6.2:
resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==}
@ -7031,6 +7045,16 @@ packages:
nodemailer:
optional: true
next-intl@4.4.0:
resolution: {integrity: sha512-QHqnP9V9Pe7Tn0PdVQ7u1Z8k9yCkW5SJKeRy2g5gxzhSt/C01y3B9qNxuj3Fsmup/yreIHe6osxU6sFa+9WIkQ==}
peerDependencies:
next: ^12.0.0 || ^13.0.0 || ^14.0.0 || ^15.0.0 || ^16.0.0
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
typescript: ^5.0.0
peerDependenciesMeta:
typescript:
optional: true
next@14.2.33:
resolution: {integrity: sha512-GiKHLsD00t4ACm1p00VgrI0rUFAC9cRDGReKyERlM57aeEZkOQGcZTpIbsGn0b562FTPJWmYfKwplfO9EaT6ng==}
engines: {node: '>=18.17.0'}
@ -7843,22 +7867,6 @@ packages:
react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0
react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0
react-i18next@16.2.3:
resolution: {integrity: sha512-O0t2zvmIz7nHWKNfIL+O/NTIbpTaOPY0vZov779hegbep3IZ+xcqkeVPKWBSXwzdkiv77q8zmq9toKIUys1x3A==}
peerDependencies:
i18next: '>= 25.5.2'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
typescript: ^5
peerDependenciesMeta:
react-dom:
optional: true
react-native:
optional: true
typescript:
optional: true
react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
@ -9041,6 +9049,11 @@ packages:
'@types/react':
optional: true
use-intl@4.4.0:
resolution: {integrity: sha512-smFekJWtokDRBLC5/ZumlBREzdXOkw06+56Ifj2uRe9266Mk+yWQm2PcJO+EwlOE5sHIXHixOTzN6V8E0RGUbw==}
peerDependencies:
react: ^17.0.0 || ^18.0.0 || >=19.0.0-rc <19.0.0 || ^19.0.0
use-resize-observer@9.1.0:
resolution: {integrity: sha512-R25VqO9Wb3asSD4eqtcxk8sJalvIOYBqS8MNZlpDSQ4l4xMQxC/J7Id9HoTqPq8FwULIn0PVW+OAqF2dyYbjow==}
peerDependencies:
@ -9178,10 +9191,6 @@ packages:
vm-browserify@1.1.2:
resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==}
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
engines: {node: '>=0.10.0'}
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
@ -10678,6 +10687,36 @@ snapshots:
'@floating-ui/utils@0.2.10': {}
'@formatjs/ecma402-abstract@2.3.6':
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@formatjs/intl-localematcher': 0.6.2
decimal.js: 10.6.0
tslib: 2.8.1
'@formatjs/fast-memoize@2.2.7':
dependencies:
tslib: 2.8.1
'@formatjs/icu-messageformat-parser@2.11.4':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/icu-skeleton-parser': 1.8.16
tslib: 2.8.1
'@formatjs/icu-skeleton-parser@1.8.16':
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
tslib: 2.8.1
'@formatjs/intl-localematcher@0.5.10':
dependencies:
tslib: 2.8.1
'@formatjs/intl-localematcher@0.6.2':
dependencies:
tslib: 2.8.1
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@humanwhocodes/object-schema': 2.0.3
@ -12149,6 +12188,8 @@ snapshots:
'@rushstack/eslint-patch@1.13.0': {}
'@schummar/icu-type-parser@1.21.5': {}
'@semantic-release/changelog@6.0.3(semantic-release@22.0.12(typescript@5.9.3))':
dependencies:
'@semantic-release/error': 3.0.0
@ -16034,10 +16075,6 @@ snapshots:
relateurl: 0.2.7
terser: 5.44.0
html-parse-stringify@3.0.1:
dependencies:
void-elements: 3.1.0
html-tags@3.3.1: {}
html-webpack-plugin@5.6.4(webpack@5.102.0(esbuild@0.25.10)):
@ -16092,12 +16129,6 @@ snapshots:
human-signals@5.0.0: {}
i18next@25.6.0(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.4
optionalDependencies:
typescript: 5.9.3
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -16155,6 +16186,13 @@ snapshots:
hasown: 2.0.2
side-channel: 1.1.0
intl-messageformat@10.7.18:
dependencies:
'@formatjs/ecma402-abstract': 2.3.6
'@formatjs/fast-memoize': 2.2.7
'@formatjs/icu-messageformat-parser': 2.11.4
tslib: 2.8.1
into-stream@7.0.0:
dependencies:
from2: 2.3.0
@ -16973,6 +17011,8 @@ snapshots:
negotiator@0.6.4: {}
negotiator@1.0.0: {}
neo-async@2.6.2: {}
nerf-dart@1.0.0: {}
@ -16983,6 +17023,16 @@ snapshots:
next: 14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
next-intl@4.4.0(next@14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
dependencies:
'@formatjs/intl-localematcher': 0.5.10
negotiator: 1.0.0
next: 14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
use-intl: 4.4.0(react@18.3.1)
optionalDependencies:
typescript: 5.9.3
next@14.2.33(@babel/core@7.28.4)(@playwright/test@1.56.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@next/env': 14.2.33
@ -17777,17 +17827,6 @@ snapshots:
react-dom: 18.3.1(react@18.3.1)
react-is: 18.1.0
react-i18next@16.2.3(i18next@25.6.0(typescript@5.9.3))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3):
dependencies:
'@babel/runtime': 7.28.4
html-parse-stringify: 3.0.1
i18next: 25.6.0(typescript@5.9.3)
react: 18.3.1
use-sync-external-store: 1.6.0(react@18.3.1)
optionalDependencies:
react-dom: 18.3.1(react@18.3.1)
typescript: 5.9.3
react-is@16.13.1: {}
react-is@17.0.2: {}
@ -19116,6 +19155,13 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.26
use-intl@4.4.0(react@18.3.1):
dependencies:
'@formatjs/fast-memoize': 2.2.7
'@schummar/icu-type-parser': 1.21.5
intl-messageformat: 10.7.18
react: 18.3.1
use-resize-observer@9.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
'@juggle/resize-observer': 3.4.0
@ -19232,9 +19278,43 @@ snapshots:
- supports-color
- terser
vm-browserify@1.1.2: {}
vitest@1.6.1(@types/node@20.19.19)(happy-dom@18.0.1)(jsdom@27.0.0(postcss@8.5.6))(terser@5.44.0):
dependencies:
'@vitest/expect': 1.6.1
'@vitest/runner': 1.6.1
'@vitest/snapshot': 1.6.1
'@vitest/spy': 1.6.1
'@vitest/utils': 1.6.1
acorn-walk: 8.3.4
chai: 4.5.0
debug: 4.4.3
execa: 8.0.1
local-pkg: 0.5.1
magic-string: 0.30.19
pathe: 1.1.2
picocolors: 1.1.1
std-env: 3.9.0
strip-literal: 2.1.1
tinybench: 2.9.0
tinypool: 0.8.4
vite: 5.4.20(@types/node@20.19.19)(terser@5.44.0)
vite-node: 1.6.1(@types/node@20.19.19)(terser@5.44.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.19
happy-dom: 18.0.1
jsdom: 27.0.0(postcss@8.5.6)
transitivePeerDependencies:
- less
- lightningcss
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
void-elements@3.1.0: {}
vm-browserify@1.1.2: {}
w3c-xmlserializer@5.0.0:
dependencies: