Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions apps/web/src/components/LanguagePicker.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createServerFn } from "@tanstack/react-start";
import { useRouter } from "@tanstack/react-router";
import { setCookie } from "@tanstack/react-start/server";
import { useConvexAuth, useMutation } from "convex/react";
import { api } from "@news-app/backend/convex/_generated/api";
import { useLocale, useT } from "@/lib/i18n/LocaleContext";
import type { Locale } from "@/lib/i18n/strings";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";

function validateLocale(input: unknown): Locale {
if (input === "ro" || input === "en") {
return input;
}

throw new Error("Invalid locale");
}

const setLocaleCookie = createServerFn({ method: "POST" })
.inputValidator(validateLocale)
.handler(({ data }) => {
setCookie("bv_locale", data, {
path: "/",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 365,
});

return { locale: data };
});

const LANGUAGE_OPTIONS: Array<{
locale: Locale;
flag: string;
shortLabel: string;
translationKey: "settings.language.ro" | "settings.language.en";
}> = [
{
locale: "ro",
flag: "🇷🇴",
shortLabel: "RO",
translationKey: "settings.language.ro",
},
{
locale: "en",
flag: "🇺🇸",
shortLabel: "EN",
translationKey: "settings.language.en",
},
];

export function LanguagePicker({
compact = false,
className,
}: {
compact?: boolean;
className?: string;
}) {
const router = useRouter();
const t = useT();
const currentLocale = useLocale();
const { isAuthenticated } = useConvexAuth();
const updatePreferredLanguage = useMutation(api.user.updatePreferredLanguage);

const handleChange = async (locale: Locale) => {
if (locale === currentLocale) {
return;
}

await setLocaleCookie({ data: locale });

if (isAuthenticated) {
try {
await updatePreferredLanguage({ language: locale });
} catch {
// Cookie persistence is still enough for anonymous/fallback usage.
}
}

await router.invalidate();
};

return (
<div
className={cn("flex gap-2", className)}
role="group"
aria-label={t("settings.language")}
>
{LANGUAGE_OPTIONS.map(({ locale, flag, shortLabel, translationKey }) => (
<Button
key={locale}
type="button"
variant={currentLocale === locale ? "default" : "outline"}
size="sm"
className={cn(
"min-w-0 gap-2",
compact ? "px-2.5" : "px-3",
)}
onClick={() => void handleChange(locale)}
aria-pressed={currentLocale === locale}
aria-label={t(translationKey)}
>
<span aria-hidden="true" className="text-base leading-none">
{flag}
</span>
<span className={cn(compact ? "text-xs" : "text-sm")}>{shortLabel}</span>
</Button>
))}
</div>
);
}
49 changes: 49 additions & 0 deletions apps/web/src/components/SignInPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { ReactNode } from "react";
import { Link } from "@tanstack/react-router";
import type { AuthRedirectPath } from "@/lib/auth-redirect";
import { useT } from "@/lib/i18n/LocaleContext";
import { Button } from "@/components/ui/button";

type SignInPromptProps = {
title: string;
description: string;
redirectTo: AuthRedirectPath;
illustration?: ReactNode;
};

export function SignInPrompt({
title,
description,
redirectTo,
illustration,
}: SignInPromptProps) {
const t = useT();

return (
<div className="bg-gradient-to-b from-background via-background to-muted/35">
<div className="container mx-auto flex min-h-[calc(100vh-4rem)] max-w-4xl items-center px-4 py-10 sm:py-14">
<div className="mx-auto w-full max-w-xl rounded-[1.75rem] border border-border/70 bg-card/80 p-8 text-center shadow-sm sm:p-10">
{illustration ? (
<div className="mb-6 flex justify-center">{illustration}</div>
) : null}
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground sm:text-base">
{description}
</p>
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:justify-center">
<Button asChild size="lg" className="sm:min-w-56">
<Link to="/dashboard" search={{ mode: "signup", redirect: redirectTo }}>
{t("auth.signUp")}
</Link>
</Button>
<Button asChild size="lg" variant="outline" className="sm:min-w-44">
<Link to="/dashboard" search={{ mode: "signin", redirect: redirectTo }}>
{t("auth.signIn")}
</Link>
</Button>
</div>
</div>
</div>
</div>
);
}
17 changes: 11 additions & 6 deletions apps/web/src/components/auth-prompt-banner.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Link } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import type { AuthRedirectPath } from "@/lib/auth-redirect";
import { useT } from "@/lib/i18n/LocaleContext";

type AuthPromptBannerProps = {
redirectTo: AuthRedirectPath;
Expand All @@ -11,28 +12,32 @@ type AuthPromptBannerProps = {

export default function AuthPromptBanner({
redirectTo,
title = "Create a free account to save and personalize your news",
description = "Reading stays open to everyone. Accounts unlock bookmarks, personalized ranking, and notifications.",
title,
description,
compact = false,
}: AuthPromptBannerProps) {
const t = useT();
const resolvedTitle = title ?? t("auth.promptTitle");
const resolvedDescription = description ?? t("auth.promptBody");

return (
<section className="rounded-[1.2rem] border border-border/70 bg-card/80 p-5 shadow-sm">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<p className="text-sm font-semibold tracking-tight">{title}</p>
<p className="text-sm font-semibold tracking-tight">{resolvedTitle}</p>
<p className="max-w-[60ch] text-sm text-muted-foreground">
{description}
{resolvedDescription}
</p>
</div>
<div className="flex flex-wrap gap-3">
<Button asChild size={compact ? "sm" : "default"}>
<Link to="/dashboard" search={{ mode: "signup", redirect: redirectTo }}>
Create account
{t("auth.createAccount")}
</Link>
</Button>
<Button asChild variant="ghost" size={compact ? "sm" : "default"}>
<Link to="/dashboard" search={{ mode: "signin", redirect: redirectTo }}>
Sign in
{t("auth.signIn")}
</Link>
</Button>
</div>
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/components/auth-social.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import { authClient } from "@/lib/auth-client";
import { useT } from "@/lib/i18n/LocaleContext";
import { toast } from "sonner";
import type { AuthRedirectPath } from "@/lib/auth-redirect";
import { Button } from "./ui/button";

export function AuthDivider() {
const t = useT();

return (
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">
Or continue with
{t("auth.orContinueWith")}
</span>
</div>
</div>
Expand All @@ -25,6 +28,8 @@ export function GoogleSignInButton({
callbackURL: AuthRedirectPath;
onBeforeSignIn?: () => void;
}) {
const t = useT();

return (
<Button
type="button"
Expand All @@ -39,7 +44,7 @@ export function GoogleSignInButton({
});
} catch (error) {
console.error(error);
toast.error("Google sign-in failed. Please try again.");
toast.error(t("auth.googleError"));
}
}}
>
Expand All @@ -66,7 +71,7 @@ export function GoogleSignInButton({
fill="#EA4335"
/>
</svg>
Continue with Google
{t("auth.continueGoogle")}
</Button>
);
}
40 changes: 24 additions & 16 deletions apps/web/src/components/bias-balance-meter.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,48 @@
import { cn } from "@/lib/utils";
import { useT } from "@/lib/i18n/LocaleContext";

type BiasBalanceMeterProps = {
value: number;
className?: string;
};

function getBiasBalanceLabel(value: number) {
function getBiasBalanceLabel(
value: number,
t: ReturnType<typeof useT>,
) {
const absolute = Math.abs(value);
if (absolute < 15) {
return "Balanced";
return t("bias.label.balanced");
}
if (value < 0) {
return absolute >= 60 ? "Mostly left" : "Leaning left";
return absolute >= 60 ? t("bias.label.leftStrong") : t("bias.label.left");
}
return absolute >= 60 ? "Mostly right" : "Leaning right";
return absolute >= 60 ? t("bias.label.rightStrong") : t("bias.label.right");
}

function getBiasBalanceCopy(value: number) {
function getBiasBalanceCopy(
value: number,
t: ReturnType<typeof useT>,
) {
const absolute = Math.abs(value);
if (absolute < 15) {
return "You’re reading a healthy mix of perspectives.";
return t("bias.copy.balanced");
}
if (value < 0) {
return absolute >= 60
? "Recent reading skews left-leaning."
: "Recent reading tilts left-leaning.";
? t("bias.copy.leftStrong")
: t("bias.copy.left");
}
return absolute >= 60
? "Recent reading skews right-leaning."
: "Recent reading tilts right-leaning.";
? t("bias.copy.rightStrong")
: t("bias.copy.right");
}

export default function BiasBalanceMeter({
value,
className,
}: BiasBalanceMeterProps) {
const t = useT();
const clampedValue = Math.max(-100, Math.min(100, Math.round(value)));
const indicatorPosition = ((clampedValue + 100) / 200) * 100;
const indicatorStyle =
Expand All @@ -52,8 +60,8 @@ export default function BiasBalanceMeter({
left: `${indicatorPosition}%`,
transform: "translate(-50%, -50%)",
};
const label = getBiasBalanceLabel(clampedValue);
const copy = getBiasBalanceCopy(clampedValue);
const label = getBiasBalanceLabel(clampedValue, t);
const copy = getBiasBalanceCopy(clampedValue, t);

return (
<div className={cn("space-y-3", className)}>
Expand All @@ -68,7 +76,7 @@ export default function BiasBalanceMeter({
{clampedValue}
</p>
<p className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Bias Balance
{t("bias.balance")}
</p>
</div>
</div>
Expand All @@ -85,9 +93,9 @@ export default function BiasBalanceMeter({
/>
</div>
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
<span>Left-heavy</span>
<span>Balanced</span>
<span>Right-heavy</span>
<span>{t("bias.leftHeavy")}</span>
<span>{t("bias.center")}</span>
<span>{t("bias.rightHeavy")}</span>
</div>
</div>
</div>
Expand Down
15 changes: 9 additions & 6 deletions apps/web/src/components/bias-indicator.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { useT } from "@/lib/i18n/LocaleContext";

type BiasIndicatorProps = {
bias: number; // -5 (Left) to +5 (Right)
size?: "sm" | "md" | "lg";
Expand All @@ -24,6 +26,7 @@ const BiasIndicator = ({
size = "md",
thresholds: thresholdsProp,
}: BiasIndicatorProps) => {
const t = useT();
const thresholds = validateThresholds(thresholdsProp ?? DEFAULT_THRESHOLDS);

// Normalize bias to a 0-100 scale for positioning
Expand All @@ -40,11 +43,11 @@ const BiasIndicator = ({

// Determine label
const getLabel = (biasValue: number) => {
if (biasValue < thresholds[0]) return "Left";
if (biasValue < thresholds[1]) return "Lean Left";
if (biasValue <= thresholds[2]) return "Center";
if (biasValue <= thresholds[3]) return "Lean Right";
return "Right";
if (biasValue < thresholds[0]) return t("bias.label.leftStrong");
if (biasValue < thresholds[1]) return t("bias.label.left");
if (biasValue <= thresholds[2]) return t("bias.center");
if (biasValue <= thresholds[3]) return t("bias.label.right");
return t("bias.label.rightStrong");
};

// Determine text color
Expand Down Expand Up @@ -82,7 +85,7 @@ const BiasIndicator = ({
aria-valuenow={bias}
aria-valuemin={-5}
aria-valuemax={5}
aria-label={`Bias: ${getLabel(bias)}`}
aria-label={`${t("bias.balance")}: ${getLabel(bias)}`}
>
{/* Gradient track for visual interest */}
<div className="absolute inset-0 rounded-full overflow-hidden opacity-40">
Expand Down
Loading