diff --git a/.forgejo/workflows/docker-publish.yml b/.forgejo/workflows/docker-publish.yml
index 991aa00..10a3b89 100644
--- a/.forgejo/workflows/docker-publish.yml
+++ b/.forgejo/workflows/docker-publish.yml
@@ -6,10 +6,6 @@ on:
tags: ["v*"]
workflow_dispatch:
-env:
- REGISTRY: ${{ gitea.server_url }}
- IMAGE_NAME: ${{ gitea.repository }}
-
jobs:
build-and-push:
runs-on: docker
@@ -27,54 +23,64 @@ jobs:
- name: Set up Docker Buildx
uses: https://github.com/docker/setup-buildx-action@v3
+ - name: Resolve registry vars
+ id: registry
+ run: |
+ host="${{ gitea.server_url }}"
+ host="${host#https://}"
+ host="${host#http://}"
+ repo=$(echo "${{ gitea.repository }}" | tr '[:upper:]' '[:lower:]')
+ {
+ echo "host=${host}"
+ echo "image=${host}/${repo}"
+ echo "screenshot_image=${host}/${repo}-screenshot"
+ } >> "$GITHUB_OUTPUT"
+
- name: Log in to Forgejo Container Registry
uses: https://github.com/docker/login-action@v3
with:
- registry: ${{ env.REGISTRY }}
+ registry: ${{ steps.registry.outputs.host }}
username: ${{ gitea.actor }}
password: ${{ secrets.GITEA_TOKEN }}
- - name: Determine image tags
- id: tags
- run: |
- REPO=$(echo "${{ env.IMAGE_NAME }}" | tr '[:upper:]' '[:lower:]')
- SHA_SHORT=$(echo "${{ gitea.sha }}" | cut -c1-7)
- TAGS="${{ env.REGISTRY }}/${REPO}:sha-${SHA_SHORT}"
+ - name: Extract metadata (main)
+ id: meta
+ uses: https://github.com/docker/metadata-action@v5
+ with:
+ images: ${{ steps.registry.outputs.image }}
+ tags: |
+ type=sha,format=short
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
- # Add latest tag on default branch
- if [ "${{ gitea.ref }}" = "refs/heads/main" ]; then
- TAGS="${TAGS},${{ env.REGISTRY }}/${REPO}:latest"
- fi
-
- # Add version tags for semver tags
- REF="${{ gitea.ref }}"
- if [[ "$REF" =~ ^refs/tags/v([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
- VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.${BASH_REMATCH[3]}"
- MINOR="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${REPO}:${VERSION}"
- TAGS="${TAGS},${{ env.REGISTRY }}/${REPO}:${MINOR}"
- fi
-
- echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
- echo "repo=${REPO}" >> "$GITHUB_OUTPUT"
- echo "sha_short=${SHA_SHORT}" >> "$GITHUB_OUTPUT"
-
- - name: Build and push
+ - name: Build and push (main)
uses: https://github.com/docker/build-push-action@v6
with:
context: .
push: true
- tags: ${{ steps.tags.outputs.tags }}
- cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}:buildcache
- cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}:buildcache,mode=max
+ tags: ${{ steps.meta.outputs.tags }}
+ labels: ${{ steps.meta.outputs.labels }}
+ cache-from: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache
+ cache-to: type=registry,ref=${{ steps.registry.outputs.image }}:buildcache,mode=max
- - name: Build and push screenshot service
+ - name: Extract metadata (screenshot)
+ id: meta-screenshot
+ uses: https://github.com/docker/metadata-action@v5
+ with:
+ images: ${{ steps.registry.outputs.screenshot_image }}
+ tags: |
+ type=sha,format=short
+ type=raw,value=latest,enable={{is_default_branch}}
+ type=semver,pattern={{version}}
+ type=semver,pattern={{major}}.{{minor}}
+
+ - name: Build and push (screenshot)
uses: https://github.com/docker/build-push-action@v6
with:
context: ./screenshot
push: true
- tags: |
- ${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:latest
- ${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:sha-${{ steps.tags.outputs.sha_short }}
- cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache
- cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ steps.tags.outputs.repo }}-screenshot:buildcache,mode=max
+ tags: ${{ steps.meta-screenshot.outputs.tags }}
+ labels: ${{ steps.meta-screenshot.outputs.labels }}
+ cache-from: type=registry,ref=${{ steps.registry.outputs.screenshot_image }}:buildcache
+ cache-to: type=registry,ref=${{ steps.registry.outputs.screenshot_image }}:buildcache,mode=max
diff --git a/price_model.ipynb b/analyses/price_model.ipynb
similarity index 100%
rename from price_model.ipynb
rename to analyses/price_model.ipynb
diff --git a/frontend/scripts/check-translations.mjs b/frontend/scripts/check-translations.mjs
index 49638b9..64683bf 100644
--- a/frontend/scripts/check-translations.mjs
+++ b/frontend/scripts/check-translations.mjs
@@ -12,6 +12,8 @@
// 5. The lazy locale loader map covers every non-English supported language.
// 6. Selected visible UI strings that previously slipped through are not
// hardcoded outside the i18n files.
+// 7. Server-derived feature/group names from server-rs/src/features.rs are
+// present in en.ts > server so they can be translated.
//
// The script parses the TypeScript source with the compiler API and walks the
// AST — no runtime import, no transpilation, no temp files. Run it with:
@@ -26,6 +28,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const I18N_DIR = join(__dirname, '..', 'src', 'i18n');
const LOCALES_DIR = join(I18N_DIR, 'locales');
const SRC_DIR = join(__dirname, '..', 'src');
+const FEATURES_RS = join(__dirname, '..', '..', 'server-rs', 'src', 'features.rs');
const PLACEHOLDER_RE = /\{\{\s*[a-zA-Z_][\w]*\s*\}\}/g;
const HTML_TAG_RE = /<\/?[a-zA-Z][\w]*\b[^>]*>/g;
@@ -204,6 +207,26 @@ function readLocale(code) {
return obj;
}
+function readServerFeatureNames() {
+ const src = readFileSync(FEATURES_RS, 'utf8');
+ const names = [];
+ const re = /\bname:\s*"((?:\\.|[^"\\])*)"/g;
+ for (const match of src.matchAll(re)) {
+ names.push(JSON.parse(`"${match[1]}"`));
+ }
+ return [...new Set(names)];
+}
+
+function readServerFeatureConfigNames() {
+ const src = readFileSync(FEATURES_RS, 'utf8');
+ const names = [];
+ const re = /Feature::(?:Enum|Numeric)\([^]*?name:\s*"((?:\\.|[^"\\])*)"/g;
+ for (const match of src.matchAll(re)) {
+ names.push(JSON.parse(`"${match[1]}"`));
+ }
+ return [...new Set(names)];
+}
+
function readNamedRecord(file, varName) {
const sf = parseFile(join(I18N_DIR, file));
const init = findVarInitializer(sf, varName);
@@ -335,7 +358,7 @@ function checkLocales(supportedCodes) {
}
}
-function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
+function checkRecordCoverage(file, varName, supportedCodes, serverKeys, requiredKeys) {
const record = readNamedRecord(file, varName);
const expected = supportedCodes.filter((c) => c !== 'en');
const present = Object.keys(record);
@@ -357,6 +380,12 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
if (record[code]) for (const k of Object.keys(record[code])) union.add(k);
}
+ for (const key of requiredKeys) {
+ if (!union.has(key)) {
+ fail(`${file}: missing translations for API feature "${key}"`);
+ }
+ }
+
for (const code of expected) {
const langKeys = new Set(Object.keys(record[code] ?? {}));
for (const key of union) {
@@ -380,6 +409,14 @@ function checkRecordCoverage(file, varName, supportedCodes, serverKeys) {
}
}
+function checkServerSourceCoverage(serverKeys) {
+ for (const name of readServerFeatureNames()) {
+ if (!serverKeys.has(name)) {
+ fail(`en.ts > server is missing API feature/group name "${name}" from features.rs`);
+ }
+ }
+}
+
function collectSourceFiles(dir, out = []) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
@@ -444,8 +481,16 @@ function main() {
checkLocaleLoaders(supportedCodes);
const en = readLocale('en');
const serverKeys = new Set(Object.keys(en.server ?? {}));
- checkRecordCoverage('descriptions.ts', 'descriptions', supportedCodes, serverKeys);
- checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys);
+ const sourceFeatureKeys = readServerFeatureConfigNames();
+ checkServerSourceCoverage(serverKeys);
+ checkRecordCoverage(
+ 'descriptions.ts',
+ 'descriptions',
+ supportedCodes,
+ serverKeys,
+ sourceFeatureKeys
+ );
+ checkRecordCoverage('details.ts', 'details', supportedCodes, serverKeys, sourceFeatureKeys);
checkForbiddenVisibleStrings();
for (const w of warnings) console.warn(`warn: ${w}`);
diff --git a/frontend/src/components/home/HomeFinalCta.tsx b/frontend/src/components/home/HomeFinalCta.tsx
new file mode 100644
index 0000000..0d3158d
--- /dev/null
+++ b/frontend/src/components/home/HomeFinalCta.tsx
@@ -0,0 +1,36 @@
+import { useTranslation } from 'react-i18next';
+import { trackEvent } from '../../lib/analytics';
+
+const HOME_SECTION_HEADING_CLASS =
+ 'text-2xl md:text-3xl font-bold text-navy-950 dark:text-warm-100';
+const HOME_BODY_CLASS = 'text-base leading-relaxed text-warm-600 dark:text-warm-400';
+const HOME_PRIMARY_BUTTON_CLASS =
+ 'bg-coral-500 text-white rounded-lg font-semibold hover:bg-coral-600 transition-colors text-base shadow-lg shadow-coral-500/25 text-center';
+
+export default function HomeFinalCta({
+ onOpenDashboard,
+ trackingLocation = 'bottom',
+ className = '',
+}: {
+ onOpenDashboard: () => void;
+ trackingLocation?: string;
+ className?: string;
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+
{t('home.ctaTitle')}
+
{t('home.ctaDescription')}
+
+
+ );
+}
diff --git a/frontend/src/components/home/HomePage.tsx b/frontend/src/components/home/HomePage.tsx
index 0585f77..35a0ffe 100644
--- a/frontend/src/components/home/HomePage.tsx
+++ b/frontend/src/components/home/HomePage.tsx
@@ -1,8 +1,8 @@
-import { useState, useEffect, useRef } from 'react';
+import { lazy, Suspense, useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useFadeInRef } from '../../hooks/useFadeIn';
import HexCanvas from './HexCanvas';
-import ProductShowcase from './ProductShowcase';
+import HomeFinalCta from './HomeFinalCta';
import BottomIllustration from './BottomIllustration';
import { TickerValue } from '../ui/TickerValue';
import { ChevronIcon, LogoIcon, PlayIcon } from '../ui/icons';
@@ -23,6 +23,16 @@ const PRODUCT_DEMO_VIDEO_BY_LANGUAGE: Record = {
hi: 'recording-hi',
};
const PRODUCT_DEMO_SECTION_ID = 'product-demo-video';
+const ProductShowcase = lazy(() => import('./ProductShowcase'));
+
+function ProductShowcaseFallback({ className = '' }: { className?: string }) {
+ return (
+
+ );
+}
function getProductDemoSlug(language: string | undefined): string {
const code = language?.toLowerCase().split('-')[0] ?? 'en';
@@ -347,7 +357,9 @@ export default function HomePage({
-
+ }>
+
+