- Complete your first practice to earn a reward.
+ {t("rewards.empty_message")}
) : (
@@ -78,7 +83,7 @@ export default function RewardsPage() {
- {formatRewardType(reward.type)}
+ {formatRewardType(reward.type, t)}
{reward.message}
@@ -156,16 +161,3 @@ function RewardIcon({ type }: { type: string }) {
);
}
}
-
-function formatRewardType(type: string): string {
- const labels: Record = {
- daily_completion: "Daily Practice",
- streak_3: "3-Day Streak",
- streak_7: "7-Day Streak",
- streak_14: "14-Day Streak",
- streak_30: "30-Day Streak",
- stage_up: "Stage Advance",
- first_session: "First Practice",
- };
- return labels[type] ?? type;
-}
diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx
index a59e3a1..7cd549e 100644
--- a/src/app/settings/page.tsx
+++ b/src/app/settings/page.tsx
@@ -1,7 +1,9 @@
"use client";
import { useEffect, useState } from "react";
-import { useUser, SignOutButton } from "@clerk/nextjs";
+import { useAuth } from "@/hooks/use-auth";
+import { useRouter } from "next/navigation";
+import { useI18n } from "@/lib/i18n/context";
interface StateData {
attentionLevel: number;
@@ -15,11 +17,14 @@ interface StateData {
}
export default function SettingsPage() {
- const { user, isLoaded: userLoaded } = useUser();
+ const { user, isLoaded: userLoaded } = useAuth();
+ const router = useRouter();
+ const { t } = useI18n();
const [state, setState] = useState(null);
const [loading, setLoading] = useState(true);
const [completing, setCompleting] = useState(false);
const [message, setMessage] = useState(null);
+ const [signingOut, setSigningOut] = useState(false);
useEffect(() => {
if (!userLoaded) return;
@@ -38,18 +43,24 @@ export default function SettingsPage() {
if (!res.ok) throw new Error("Failed");
const json = await res.json();
setState(json.state);
- setMessage("You're all set. Head home to start your practice.");
+ setMessage("settings.onboarding.done");
} catch {
- setMessage("Something went wrong. Try again.");
+ setMessage("settings.onboarding.error");
} finally {
setCompleting(false);
}
};
+ const handleSignOut = async () => {
+ setSigningOut(true);
+ await fetch("/api/auth/logout", { method: "POST" });
+ router.push("/sign-in");
+ };
+
if (!userLoaded || loading) {
return (
-
Loading...
+
{t("shared.loading")}
);
}
@@ -62,7 +73,7 @@ export default function SettingsPage() {
- Settings
+ {t("settings.title")}
@@ -71,13 +82,10 @@ export default function SettingsPage() {
- Welcome to Inner OS
+ {t("settings.onboarding.title")}
- Inner OS generates one personalized mental training task each day,
- guided by your practice history and inner development stage. Your
- reflections shape what comes next. Everything is private — just you
- and your practice.
+ {t("settings.onboarding.description")}
- {completing ? "Setting up..." : "Get started"}
+ {completing ? t("settings.onboarding.setting_up") : t("settings.onboarding.cta")}
{message && (
-
{message}
+
{t(message)}
)}
)}
@@ -96,18 +104,18 @@ export default function SettingsPage() {
{/* Profile */}
- Profile
+ {t("settings.profile.title")}
- Signed in as{" "}
+ {t("settings.profile.signed_in_as")}{" "}
- {user.primaryEmailAddress?.emailAddress || user.firstName || "User"}
+ {user?.email || "User"}
{state?.onboardingCompleted && (
- Stage {state.practiceStage} · {state.totalSessionsCompleted} sessions
+ {t("settings.profile.stage_and_sessions", { stage: state.practiceStage, count: state.totalSessionsCompleted })}
)}
@@ -116,29 +124,23 @@ export default function SettingsPage() {
{/* About */}
- About Inner OS
+ {t("settings.about.title")}
-
- Inner OS is a private inner development practice. You receive one
- personalized mental training task each day, tailored to your current
- state and development stage.
-
-
- Your reflections are private. Your data is yours. There are no
- leaderboards, no social features, no competition — just you and the
- work of becoming more aware, more stable, and more yourself.
-
+
{t("settings.about.p1")}
+
{t("settings.about.p2")}
{/* Sign out */}
-
-
- Sign out
-
-
+
+ {signingOut ? t("settings.signing_out") : t("settings.sign_out")}
+
);
diff --git a/src/app/sign-in/page.tsx b/src/app/sign-in/page.tsx
new file mode 100644
index 0000000..d787e4b
--- /dev/null
+++ b/src/app/sign-in/page.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { useI18n } from "@/lib/i18n/context";
+
+export default function SignInPage() {
+ const router = useRouter();
+ const { t } = useI18n();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [error, setError] = useState
(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setSubmitting(true);
+
+ try {
+ const res = await fetch("/api/auth/login", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ });
+
+ if (!res.ok) {
+ const json = await res.json().catch(() => null);
+ setError(json?.error || t("sign_in.fallback_error"));
+ return;
+ }
+
+ router.push("/");
+ } catch {
+ setError(t("sign_in.network_error"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ {t("sign_in.title")}
+
+
{t("sign_in.subtitle")}
+
+
+
+
+
+ {t("sign_in.no_account")}{" "}
+
+ {t("sign_in.sign_up_link")}
+
+
+
+ );
+}
diff --git a/src/app/sign-up/page.tsx b/src/app/sign-up/page.tsx
new file mode 100644
index 0000000..f6c813f
--- /dev/null
+++ b/src/app/sign-up/page.tsx
@@ -0,0 +1,127 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import Link from "next/link";
+import { useI18n } from "@/lib/i18n/context";
+
+export default function SignUpPage() {
+ const router = useRouter();
+ const { t } = useI18n();
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const [confirm, setConfirm] = useState("");
+ const [error, setError] = useState(null);
+ const [submitting, setSubmitting] = useState(false);
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+
+ if (password !== confirm) {
+ setError(t("sign_up.password_mismatch"));
+ return;
+ }
+
+ if (password.length < 6) {
+ setError(t("sign_up.password_short"));
+ return;
+ }
+
+ setSubmitting(true);
+
+ try {
+ const res = await fetch("/api/auth/register", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ email, password }),
+ });
+
+ if (!res.ok) {
+ const json = await res.json().catch(() => null);
+ setError(json?.error || t("sign_up.fallback_error"));
+ return;
+ }
+
+ router.push("/settings");
+ } catch {
+ setError(t("sign_up.network_error"));
+ } finally {
+ setSubmitting(false);
+ }
+ };
+
+ return (
+
+
+
+ {t("sign_up.title")}
+
+
{t("sign_up.subtitle")}
+
+
+
+
+
+ {t("sign_up.has_account")}{" "}
+
+ {t("sign_up.sign_in_link")}
+
+
+
+ );
+}
diff --git a/src/app/summary/page.tsx b/src/app/summary/page.tsx
index f5ebb84..0c0dbdf 100644
--- a/src/app/summary/page.tsx
+++ b/src/app/summary/page.tsx
@@ -1,7 +1,8 @@
"use client";
import { useEffect, useState } from "react";
-import { useUser } from "@clerk/nextjs";
+import { useAuth } from "@/hooks/use-auth";
+import { useI18n } from "@/lib/i18n/context";
interface SummaryData {
summary: string;
@@ -10,7 +11,8 @@ interface SummaryData {
}
export default function SummaryPage() {
- const { isLoaded } = useUser();
+ const { isLoaded } = useAuth();
+ const { t } = useI18n();
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);
@@ -26,7 +28,7 @@ export default function SummaryPage() {
if (loading) {
return (
-
Loading...
+
{t("shared.loading")}
);
}
@@ -35,15 +37,17 @@ export default function SummaryPage() {
- Weekly summary
+ {t("summary.title")}
{summary
- ? `Week of ${new Date(summary.weekStart).toLocaleDateString("en-US", {
- month: "long",
- day: "numeric",
- })}`
- : "Generated each week from your practice history"}
+ ? t("summary.week_of", {
+ date: new Date(summary.weekStart).toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ }),
+ })
+ : t("summary.empty_subtitle")}
@@ -73,7 +77,7 @@ export default function SummaryPage() {
- Your first weekly summary will be generated after you complete at least one practice session. Summaries run automatically each week.
+ {t("summary.empty_message")}
)}
diff --git a/src/components/AuthProvider.tsx b/src/components/AuthProvider.tsx
new file mode 100644
index 0000000..d686bc2
--- /dev/null
+++ b/src/components/AuthProvider.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { createContext, useContext } from "react";
+import { useAuth } from "@/hooks/use-auth";
+
+interface AuthContextValue {
+ user: { email: string } | null;
+ isLoaded: boolean;
+}
+
+const AuthContext = createContext({
+ user: null,
+ isLoaded: false,
+});
+
+export function useAuthContext() {
+ return useContext(AuthContext);
+}
+
+export default function AuthProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ const auth = useAuth();
+
+ return {children} ;
+}
diff --git a/src/components/LanguageSwitcher.tsx b/src/components/LanguageSwitcher.tsx
new file mode 100644
index 0000000..c1527e7
--- /dev/null
+++ b/src/components/LanguageSwitcher.tsx
@@ -0,0 +1,19 @@
+"use client";
+
+import { useI18n } from "@/lib/i18n/context";
+
+export default function LanguageSwitcher() {
+ const { lang, setLang } = useI18n();
+ const next = lang === "zh" ? "en" : "zh";
+ const label = lang === "zh" ? "EN" : "中文";
+
+ return (
+ setLang(next)}
+ className="absolute top-3 right-5 text-xs font-medium text-muted hover:text-ink dark:hover:text-[#F3EFE8] transition-colors tracking-wider uppercase"
+ aria-label={`Switch to ${next === "zh" ? "Chinese" : "English"}`}
+ >
+ {label}
+
+ );
+}
diff --git a/src/components/StageBadge.tsx b/src/components/StageBadge.tsx
index 91e600a..ad77585 100644
--- a/src/components/StageBadge.tsx
+++ b/src/components/StageBadge.tsx
@@ -1,20 +1,16 @@
-const stageLabels: Record = {
- 0: { label: "Beginner", color: "text-muted bg-stone" },
- 1: { label: "Foundation", color: "text-amber bg-amber/10" },
- 2: { label: "Growing", color: "text-sage bg-sage/10" },
- 3: { label: "Established", color: "text-info bg-info/10" },
- 4: { label: "Advanced", color: "text-ink bg-amber/15" },
-};
+import { useI18n } from "@/lib/i18n/context";
+import { formatStage, getStageColor } from "@/lib/i18n/utils";
export default function StageBadge({ stage }: { stage: number }) {
- const info = stageLabels[stage] ?? stageLabels[0];
+ const { t } = useI18n();
+ const color = getStageColor(stage);
return (
- {info.label}
+ {formatStage(stage, t)}
);
}
diff --git a/src/components/TabBar.tsx b/src/components/TabBar.tsx
index 63c3fcb..636177f 100644
--- a/src/components/TabBar.tsx
+++ b/src/components/TabBar.tsx
@@ -2,17 +2,19 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
-
-const tabs = [
- { href: "/", label: "Today", icon: SunIcon },
- { href: "/progress", label: "Progress", icon: ChartIcon },
- { href: "/rewards", label: "Rewards", icon: StarIcon },
- { href: "/summary", label: "Summary", icon: BookIcon },
- { href: "/settings", label: "Settings", icon: GearIcon },
-];
+import { useI18n } from "@/lib/i18n/context";
export default function TabBar() {
const pathname = usePathname();
+ const { t } = useI18n();
+
+ const tabs = [
+ { href: "/", label: t("nav.today"), icon: SunIcon },
+ { href: "/progress", label: t("nav.progress"), icon: ChartIcon },
+ { href: "/rewards", label: t("nav.rewards"), icon: StarIcon },
+ { href: "/summary", label: t("nav.summary"), icon: BookIcon },
+ { href: "/settings", label: t("nav.settings"), icon: GearIcon },
+ ];
return (
(null);
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ useEffect(() => {
+ fetch("/api/auth/me")
+ .then((r) => {
+ if (!r.ok) throw new Error("Unauthorized");
+ return r.json();
+ })
+ .then((json) => setUser({ email: json.email }))
+ .catch(() => setUser(null))
+ .finally(() => setIsLoaded(true));
+ }, []);
+
+ return { user, isLoaded };
+}
diff --git a/src/lib/auth.ts b/src/lib/auth.ts
new file mode 100644
index 0000000..122e530
--- /dev/null
+++ b/src/lib/auth.ts
@@ -0,0 +1,80 @@
+import { SignJWT, jwtVerify } from "jose";
+import { compare, hash } from "bcryptjs";
+import { cookies } from "next/headers";
+import { cache } from "react";
+
+const COOKIE_NAME = "inneros-auth";
+const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET || "dev-secret-change-me");
+const EXPIRY = "7d";
+
+// ---------------------------------------------------------------------------
+// Password helpers
+// ---------------------------------------------------------------------------
+
+export async function hashPassword(password: string): Promise {
+ return hash(password, 12);
+}
+
+export async function verifyPassword(
+ password: string,
+ hashed: string
+): Promise {
+ return compare(password, hashed);
+}
+
+// ---------------------------------------------------------------------------
+// JWT helpers
+// ---------------------------------------------------------------------------
+
+export async function signToken(userId: string): Promise {
+ return new SignJWT({ sub: userId })
+ .setProtectedHeader({ alg: "HS256" })
+ .setIssuedAt()
+ .setExpirationTime(EXPIRY)
+ .sign(JWT_SECRET);
+}
+
+export async function verifyToken(token: string): Promise {
+ try {
+ const { payload } = await jwtVerify(token, JWT_SECRET);
+ return (payload.sub as string) || null;
+ } catch {
+ return null;
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Session helpers
+// ---------------------------------------------------------------------------
+
+export async function setAuthCookie(userId: string): Promise {
+ const token = await signToken(userId);
+ const jar = await cookies();
+ jar.set(COOKIE_NAME, token, {
+ httpOnly: true,
+ secure: process.env.NODE_ENV === "production",
+ sameSite: "lax",
+ path: "/",
+ maxAge: 60 * 60 * 24 * 7, // 7 days
+ });
+}
+
+export async function clearAuthCookie(): Promise {
+ const jar = await cookies();
+ jar.delete(COOKIE_NAME);
+}
+
+// ---------------------------------------------------------------------------
+// getSession — server-side auth check (cached per request)
+// ---------------------------------------------------------------------------
+
+export const getSession = cache(async (): Promise<{ userId: string } | null> => {
+ const jar = await cookies();
+ const token = jar.get(COOKIE_NAME)?.value;
+ if (!token) return null;
+
+ const userId = await verifyToken(token);
+ if (!userId) return null;
+
+ return { userId };
+});
diff --git a/src/lib/engines/reflection-analyzer.ts b/src/lib/engines/reflection-analyzer.ts
index 1cbdefc..1228514 100644
--- a/src/lib/engines/reflection-analyzer.ts
+++ b/src/lib/engines/reflection-analyzer.ts
@@ -1,5 +1,11 @@
import OpenAI from "openai";
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
+import type { Lang } from "@/lib/i18n/types";
+import { createServerT } from "@/lib/i18n/utils";
+import zh from "../../../dictionaries/zh";
+import en from "../../../dictionaries/en";
+
+const DICTS = { zh, en } as const;
// ---------------------------------------------------------------------------
// Types
@@ -83,15 +89,18 @@ Be generous but honest. These scores guide their development path.`;
export async function analyzeReflection(
input: ReflectionInput,
- openai: OpenAI
+ openai: OpenAI,
+ locale: Lang = "zh"
): Promise {
+ const t = createServerT(DICTS[locale]);
+
// Safety check on user input
const keywordResult = keywordCheck(input.notes);
if (!keywordResult.safe) {
return {
focusScore: 3,
emotionScore: 3,
- insight: "Thank you for sharing.",
+ insight: t("engine.reflection.keyword_blocked"),
safety: keywordResult,
};
}
@@ -143,7 +152,7 @@ export async function analyzeReflection(
const insight =
typeof parsed.insight === "string" && parsed.insight.length > 0
? parsed.insight
- : "You completed your practice today.";
+ : t("engine.reflection.no_reflection");
// LLM safety check on generated insight
const llmSafety = await llmSafetyCheck(insight, openai as any);
@@ -151,7 +160,7 @@ export async function analyzeReflection(
return {
focusScore,
emotionScore,
- insight: llmSafety.safe ? insight : "You completed your practice today.",
+ insight: llmSafety.safe ? insight : t("engine.reflection.safety_blocked"),
safety: llmSafety,
};
} catch {
@@ -159,7 +168,7 @@ export async function analyzeReflection(
return {
focusScore: 3,
emotionScore: 3,
- insight: "You showed up today. That's what matters.",
+ insight: t("engine.reflection.llm_failed"),
safety: { safe: true },
};
}
@@ -186,9 +195,10 @@ export async function processReflection(
emotionStability: number;
stressLevel: number;
},
- openai: OpenAI
+ openai: OpenAI,
+ locale: Lang = "zh"
): Promise {
- const analysis = await analyzeReflection(input, openai);
+ const analysis = await analyzeReflection(input, openai, locale);
return {
focusScore: analysis.focusScore,
diff --git a/src/lib/engines/summary-generator.ts b/src/lib/engines/summary-generator.ts
index d10c6e9..57d6a43 100644
--- a/src/lib/engines/summary-generator.ts
+++ b/src/lib/engines/summary-generator.ts
@@ -1,6 +1,12 @@
import OpenAI from "openai";
import { prisma } from "@/lib/prisma";
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
+import type { Lang } from "@/lib/i18n/types";
+import { createServerT } from "@/lib/i18n/utils";
+import zh from "../../../dictionaries/zh";
+import en from "../../../dictionaries/en";
+
+const DICTS = { zh, en } as const;
// ---------------------------------------------------------------------------
// Types
@@ -56,8 +62,11 @@ Voice rules:
export async function generateWeeklySummary(
userId: string,
- openai: OpenAI
+ openai: OpenAI,
+ locale: Lang = "zh"
): Promise {
+ const t = createServerT(DICTS[locale]);
+
// Gather week data
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
@@ -85,8 +94,7 @@ export async function generateWeeklySummary(
if (!userState || sessions.length === 0) {
return {
- summary:
- "No completed practice sessions this week. Every week is a fresh start — your practice is here when you're ready.",
+ summary: t("engine.summary.no_sessions"),
safety: { safe: true },
};
}
@@ -124,7 +132,7 @@ export async function generateWeeklySummary(
// Safety check
const keywordResult = keywordCheck(summary);
if (!keywordResult.safe) {
- return { summary: "Your practice summary for this week.", safety: keywordResult };
+ return { summary: t("engine.summary.safety_fallback"), safety: keywordResult };
}
const llmSafety = await llmSafetyCheck(summary, openai as any);
@@ -133,25 +141,34 @@ export async function generateWeeklySummary(
const weekStart = new Date(weekAgo);
weekStart.setHours(0, 0, 0, 0);
+ const safetyFallback = t("engine.summary.safety_fallback");
+
await prisma.weeklySummary.upsert({
where: { userId },
create: {
userId,
- summary: llmSafety.safe ? summary : "Your practice summary for this week.",
+ summary: llmSafety.safe ? summary : safetyFallback,
weekStart,
},
update: {
- summary: llmSafety.safe ? summary : "Your practice summary for this week.",
+ summary: llmSafety.safe ? summary : safetyFallback,
weekStart,
},
});
return {
- summary: llmSafety.safe ? summary : "Your practice summary for this week.",
+ summary: llmSafety.safe ? summary : safetyFallback,
safety: llmSafety,
};
} catch {
- const fallback = `You completed ${sessions.length} practice session${sessions.length === 1 ? "" : "s"} this week. Showing up consistently is the foundation everything else is built on. Your current streak is ${userState.streakDays} day${userState.streakDays === 1 ? "" : "s"}. See you next week.`;
+ const count = sessions.length;
+ const streak = userState.streakDays;
+ const fallback = t("engine.summary.fallback_template", {
+ count,
+ plural: count === 1 ? "" : "s",
+ streak,
+ streak_plural: streak === 1 ? "" : "s",
+ });
return { summary: fallback, safety: { safe: true } };
}
}
@@ -161,7 +178,8 @@ export async function generateWeeklySummary(
// ---------------------------------------------------------------------------
export async function generateAllWeeklySummaries(
- openai: OpenAI
+ openai: OpenAI,
+ locale: Lang = "zh"
): Promise<{ userId: string; success: boolean }[]> {
// Find users who had sessions in the last 7 days
const weekAgo = new Date();
@@ -180,7 +198,7 @@ export async function generateAllWeeklySummaries(
for (const { userId } of activeUsers) {
try {
- await generateWeeklySummary(userId, openai);
+ await generateWeeklySummary(userId, openai, locale);
results.push({ userId, success: true });
} catch {
results.push({ userId, success: false });
diff --git a/src/lib/engines/task-selector.ts b/src/lib/engines/task-selector.ts
index a59d6dc..24848a5 100644
--- a/src/lib/engines/task-selector.ts
+++ b/src/lib/engines/task-selector.ts
@@ -1,4 +1,6 @@
import OpenAI from "openai";
+import { readFile } from "fs/promises";
+import path from "path";
import { prisma } from "@/lib/prisma";
import { keywordCheck, llmSafetyCheck, type SafetyResult } from "@/lib/safety";
import type { TaskType } from "@/generated/prisma/client";
@@ -43,8 +45,8 @@ let _templates: TaskTemplate[] | null = null;
async function loadTemplates(): Promise {
if (_templates) return _templates;
- // Templates are bundled at build time from data/task-templates.json
- _templates = (await import("@/../data/task-templates.json")).default as TaskTemplate[];
+ const templatesPath = path.resolve(process.cwd(), "data", "task-templates.json");
+ _templates = JSON.parse(await readFile(templatesPath, "utf-8")) as TaskTemplate[];
return _templates!;
}
@@ -232,6 +234,8 @@ export async function generateDailyTask(
safety: SafetyResult;
fromTemplate: boolean;
}> {
+ // User record is created at registration — no upsert needed
+
// Gather user state
const [userState, recentSessions, recentTasks] = await Promise.all([
prisma.userState.findUnique({ where: { userId } }),
diff --git a/src/lib/i18n/context.tsx b/src/lib/i18n/context.tsx
new file mode 100644
index 0000000..b36d240
--- /dev/null
+++ b/src/lib/i18n/context.tsx
@@ -0,0 +1,57 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useState,
+ useCallback,
+ useEffect,
+ type ReactNode,
+} from "react";
+import type { Lang, Dict, I18nContextValue } from "./types";
+import { getStoredLang, persistLang, getDictValue, interpolate } from "./utils";
+import zh from "../../../dictionaries/zh";
+import en from "../../../dictionaries/en";
+
+const DICTS: Record = { zh, en };
+
+const I18nContext = createContext(null);
+
+export function I18nProvider({ children }: { children: ReactNode }) {
+ const [lang, setLangState] = useState("zh");
+
+ // Hydrate from localStorage on mount (avoids SSR mismatch)
+ useEffect(() => {
+ setLangState(getStoredLang());
+ }, []);
+
+ // Sync HTML lang attribute on language change (for CSS [lang] selectors)
+ useEffect(() => {
+ document.documentElement.lang = lang;
+ }, [lang]);
+
+ const setLang = useCallback((next: Lang) => {
+ persistLang(next);
+ setLangState(next);
+ }, []);
+
+ const t = useCallback(
+ (key: string, vars?: Record): string => {
+ const dict = DICTS[lang];
+ return interpolate(getDictValue(dict, key), vars);
+ },
+ [lang]
+ );
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useI18n(): I18nContextValue {
+ const ctx = useContext(I18nContext);
+ if (!ctx) throw new Error("useI18n must be used within I18nProvider");
+ return ctx;
+}
diff --git a/src/lib/i18n/types.ts b/src/lib/i18n/types.ts
new file mode 100644
index 0000000..2868769
--- /dev/null
+++ b/src/lib/i18n/types.ts
@@ -0,0 +1,15 @@
+export type Lang = "zh" | "en";
+
+export interface Dict {
+ [key: string]: string | Dict;
+}
+
+export type TFunc = (key: string, vars?: Record) => string;
+
+export type Formatter = Record string>;
+
+export interface I18nContextValue {
+ lang: Lang;
+ t: TFunc;
+ setLang: (lang: Lang) => void;
+}
diff --git a/src/lib/i18n/utils.ts b/src/lib/i18n/utils.ts
new file mode 100644
index 0000000..f0e57da
--- /dev/null
+++ b/src/lib/i18n/utils.ts
@@ -0,0 +1,120 @@
+import type { Dict, TFunc, Lang } from "./types";
+
+const LANG_STORAGE_KEY = "inneros-lang";
+
+/** Read language preference from localStorage (client only). Falls back to "zh". */
+export function getStoredLang(): Lang {
+ if (typeof window === "undefined") return "zh";
+ const stored = localStorage.getItem(LANG_STORAGE_KEY);
+ if (stored === "zh" || stored === "en") return stored;
+ return "zh";
+}
+
+/** Persist language to both localStorage and cookie. */
+export function persistLang(lang: Lang): void {
+ if (typeof window === "undefined") return;
+ localStorage.setItem(LANG_STORAGE_KEY, lang);
+ // Set non-HTTP-only cookie so layout can read it for
+ document.cookie = `inneros-lang=${lang};path=/;max-age=${365 * 24 * 60 * 60};SameSite=Lax`;
+}
+
+/**
+ * Resolve a dotted key path to a value in the dictionary.
+ * Returns the key itself if not found.
+ */
+export function getDictValue(dict: Dict, key: string): string {
+ const parts = key.split(".");
+ let current: string | Dict = dict;
+ for (const part of parts) {
+ if (typeof current !== "object" || current === null) return key;
+ current = current[part];
+ }
+ return typeof current === "string" ? current : key;
+}
+
+/**
+ * Replace {variable} placeholders in a template string.
+ * Example: interpolate("Hello, {name}", { name: "World" }) → "Hello, World"
+ */
+export function interpolate(
+ template: string,
+ vars?: Record
+): string {
+ if (!vars) return template;
+ return template.replace(/\{(\w+)\}/g, (_, key) => String(vars[key] ?? `{${key}}`));
+}
+
+/**
+ * Create a server-side t() function from a dictionary.
+ * Useful in API routes and engine files where React context is unavailable.
+ */
+export function createServerT(dict: Dict): TFunc {
+ return (key, vars) => interpolate(getDictValue(dict, key), vars);
+}
+
+// ---------------------------------------------------------------------------
+// Shared formatters — consolidated from duplicated page-level functions
+// ---------------------------------------------------------------------------
+
+const TASK_TYPE_KEYS: Record = {
+ attention_training: "shared.task_type.attention",
+ emotion_awareness: "shared.task_type.emotion",
+ stress_regulation: "shared.task_type.stress",
+ cognitive_clarity: "shared.task_type.clarity",
+ integration: "shared.task_type.integration",
+};
+
+export function formatTaskType(type: string, t: TFunc): string {
+ const key = TASK_TYPE_KEYS[type];
+ return key ? t(key) : type;
+}
+
+const DIFFICULTY_KEYS: Record = {
+ 1: "shared.difficulty.beginner",
+ 2: "shared.difficulty.intermediate",
+ 3: "shared.difficulty.advanced",
+};
+
+export function formatDifficulty(difficulty: number, t: TFunc): string {
+ const key = DIFFICULTY_KEYS[difficulty];
+ return key ? t(key) : String(difficulty);
+}
+
+const STAGE_KEYS: Record = {
+ 0: "shared.stage.beginner",
+ 1: "shared.stage.foundation",
+ 2: "shared.stage.growing",
+ 3: "shared.stage.established",
+ 4: "shared.stage.advanced",
+};
+
+export function formatStage(stage: number, t: TFunc): string {
+ return t(STAGE_KEYS[stage] ?? "shared.stage.beginner");
+}
+
+const STAGE_COLORS: Record = {
+ 0: "text-muted bg-stone",
+ 1: "text-amber bg-amber/10",
+ 2: "text-sage bg-sage/10",
+ 3: "text-info bg-info/10",
+ 4: "text-ink bg-amber/15",
+};
+
+export function getStageColor(stage: number): string {
+ return STAGE_COLORS[stage] ?? STAGE_COLORS[0];
+}
+
+const REWARD_TYPE_KEYS: Record = {
+ daily_completion: "shared.reward_type.daily_completion",
+ streak_3: "shared.reward_type.streak_3",
+ streak_7: "shared.reward_type.streak_7",
+ streak_14: "shared.reward_type.streak_14",
+ streak_30: "shared.reward_type.streak_30",
+ stage_up: "shared.reward_type.stage_up",
+ first_session: "shared.reward_type.first_session",
+};
+
+export function formatRewardType(type: string, t: TFunc): string {
+ const key = REWARD_TYPE_KEYS[type];
+ return key ? t(key) : type;
+}
diff --git a/src/lib/prisma.ts b/src/lib/prisma.ts
index b7bde08..6974072 100644
--- a/src/lib/prisma.ts
+++ b/src/lib/prisma.ts
@@ -1,16 +1,18 @@
import { PrismaClient } from "@/generated/prisma/client";
-import { PrismaPg } from "@prisma/adapter-pg";
+import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
+import path from "path";
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
-function createPrismaClient(): PrismaClient {
- const adapter = new PrismaPg({
- connectionString: process.env.DATABASE_URL!,
+const createPrismaClient = () => {
+ // Prisma CLI resolves file:./dev.db relative to prisma.config.ts (project root)
+ const dbPath = path.resolve(process.cwd(), "dev.db");
+ return new PrismaClient({
+ adapter: new PrismaBetterSqlite3({ url: dbPath }),
});
- return new PrismaClient({ adapter });
-}
+};
export const prisma = globalForPrisma.prisma ?? createPrismaClient();
diff --git a/src/proxy.ts b/src/proxy.ts
index b425146..ae93934 100644
--- a/src/proxy.ts
+++ b/src/proxy.ts
@@ -1,20 +1,43 @@
-import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server";
+import { NextResponse } from "next/server";
+import { verifyToken } from "@/lib/auth";
+import type { NextRequest } from "next/server";
-const isPublicRoute = createRouteMatcher([
+const PUBLIC_PATHS = [
+ "/sign-in",
+ "/sign-up",
+ "/api/auth/register",
+ "/api/auth/login",
"/api/cron/weekly-summaries",
- "/sign-in(.*)",
- "/sign-up(.*)",
-]);
+];
-export default clerkMiddleware(async (auth, req) => {
- if (!isPublicRoute(req)) {
- await auth.protect();
+function isPublic(pathname: string): boolean {
+ return PUBLIC_PATHS.some((p) => pathname.startsWith(p));
+}
+
+export default async function middleware(req: NextRequest) {
+ const { pathname } = req.nextUrl;
+
+ if (isPublic(pathname)) {
+ return NextResponse.next();
}
-});
+
+ const token = req.cookies.get("inneros-auth")?.value;
+ if (!token) {
+ return NextResponse.redirect(new URL("/sign-in", req.url));
+ }
+
+ const userId = await verifyToken(token);
+ if (!userId) {
+ const res = NextResponse.redirect(new URL("/sign-in", req.url));
+ res.cookies.delete("inneros-auth");
+ return res;
+ }
+
+ return NextResponse.next();
+}
export const config = {
matcher: [
- // Skip Next.js internals and static files
"/((?!_next/static|_next/image|favicon.ico).*)",
"/",
"/(api|trpc)(.*)",