Merge commit '61adc80615' into dev

This commit is contained in:
DESKTOP-RTLN3BA\$punk 2026-06-08 12:51:38 -07:00
commit 6d1d00ebbc
7 changed files with 136 additions and 46 deletions

View file

@ -0,0 +1,18 @@
import type { ReactNode } from "react";
import { AdSenseScript } from "@/components/ads/adsense-script";
/**
* Wraps the /free hub and all /free/[model_slug] subpages. Mounting
* <AdSenseScript /> here loads adsbygoogle.js across the entire /free route
* tree, which is what powers both the manual <AdUnit /> slots and AdSense
* Auto ads. Because the script lives here (not in the root layout), Auto ads
* is naturally scoped to /free and its subpages only.
*/
export default function FreeSectionLayout({ children }: { children: ReactNode }) {
return (
<>
<AdSenseScript />
{children}
</>
);
}

View file

@ -3,7 +3,6 @@ import type { Metadata } from "next";
import Link from "next/link";
import { AdUnit } from "@/components/ads/ad-unit";
import { ADSENSE_SLOTS } from "@/components/ads/adsense-config";
import { AdSenseScript } from "@/components/ads/adsense-script";
import { BreadcrumbNav } from "@/components/seo/breadcrumb-nav";
import { FAQJsonLd, JsonLd } from "@/components/seo/json-ld";
import { Badge } from "@/components/ui/badge";
@ -160,7 +159,6 @@ export default async function FreeHubPage() {
return (
<div className="min-h-screen pt-20">
<AdSenseScript />
<JsonLd
data={{
"@context": "https://schema.org",

View file

@ -5,12 +5,16 @@ import type { Context } from "@/types/zero";
import { queries } from "@/zero/queries";
import { schema } from "@/zero/schema";
function getBackendBaseUrl() {
const base = process.env.FASTAPI_BACKEND_INTERNAL_URL || "http://localhost:8000";
return base.endsWith("/") ? base.slice(0, -1) : base;
}
const backendURL = getBackendBaseUrl();
// This route is invoked server-to-server by zero-cache (via ZERO_QUERY_URL),
// so it must reach the backend over the internal Docker network
// (e.g. http://backend:8000). The browser-facing NEXT_PUBLIC_FASTAPI_BACKEND_URL
// (e.g. http://localhost:8929) does NOT resolve from inside the frontend
// container and would make every authenticated Zero query fail with a 503.
const backendURL = (
process.env.FASTAPI_BACKEND_INTERNAL_URL ||
BACKEND_URL ||
"http://localhost:8000"
).replace(/\/$/, "");
async function authenticateRequest(
request: Request

View file

@ -254,13 +254,15 @@ const ThreadWelcome: FC = () => {
return (
<div className="aui-thread-welcome-root mx-auto flex w-full max-w-(--thread-max-width) grow flex-col items-center px-4 relative">
<div className="aui-thread-welcome-message absolute bottom-[calc(50%+5rem)] left-0 right-0 flex flex-col items-center text-center">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none">
{greeting}
</h1>
</div>
<div className="w-full flex items-start justify-center absolute top-[calc(50%-3.5rem)] left-0 right-0">
<Composer />
<div className="my-auto flex w-full flex-col items-center gap-6 py-6 sm:contents sm:my-0 sm:gap-0 sm:py-0">
<div className="aui-thread-welcome-message flex flex-col items-center text-center sm:absolute sm:bottom-[calc(50%+5rem)] sm:left-0 sm:right-0">
<h1 className="aui-thread-welcome-message-inner text-3xl md:text-[2.625rem] select-none">
{greeting}
</h1>
</div>
<div className="w-full flex items-start justify-center sm:absolute sm:top-[calc(50%-3.5rem)] sm:left-0 sm:right-0">
<Composer />
</div>
</div>
</div>
);

View file

@ -37,6 +37,7 @@ import { BACKEND_URL } from "@/lib/env-config";
import { trackAnonymousChatMessageSent } from "@/lib/posthog/events";
import { FreeModelSelector } from "./free-model-selector";
import { FreeThread } from "./free-thread";
import { RemoveAdsBanner } from "./remove-ads-banner";
// Render all tool calls via ToolFallback; backend keeps persisted
// payloads bounded by summarising / truncating outputs.
@ -135,7 +136,7 @@ export function FreeChatPage() {
pendingRetryRef.current = null;
}, [resetKey, modelSlug, tokenUsageStore]);
const cancelRun = useCallback(() => {
const cancelRun = useCallback(async () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
@ -487,6 +488,8 @@ export function FreeChatPage() {
<FreeModelSelector />
</div>
<RemoveAdsBanner />
{captchaRequired && TURNSTILE_SITE_KEY && (
<div className="flex justify-center border-b bg-muted/30 px-4 py-4">
<Alert className="w-auto max-w-md">

View file

@ -0,0 +1,77 @@
"use client";
import { Sparkles, X } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const ADSENSE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_ADSENSE_CLIENT_ID;
// Versioned key so the copy can change later without resurfacing for users who
// already dismissed an older variant (bump the version to re-show).
const DISMISS_KEY = "surfsense:remove-ads-banner-dismissed:v1";
/**
* Dismissible promo shown on the free /free/[model_slug] chat pages, nudging
* anonymous users to sign up to remove ads. Dismissal is persisted in
* localStorage so it stays hidden across reloads and navigations. The free
* chat keeps working whether or not the banner is dismissed.
*
* Renders nothing when AdSense is not configured (dev/preview), since there are
* no ads to remove in that case.
*/
export function RemoveAdsBanner({ className }: { className?: string }) {
// Default hidden so dismissed users never see a flash before the stored
// value is read on the client (avoids a hydration/flicker mismatch).
const [dismissed, setDismissed] = useState(true);
useEffect(() => {
try {
setDismissed(localStorage.getItem(DISMISS_KEY) === "1");
} catch {
// localStorage can throw in private browsing / when disabled.
setDismissed(false);
}
}, []);
const handleDismiss = () => {
setDismissed(true);
try {
localStorage.setItem(DISMISS_KEY, "1");
} catch {
// Ignore: dismissal just won't persist across reloads.
}
};
if (!ADSENSE_CLIENT_ID || dismissed) return null;
return (
<div className={cn("shrink-0 border-b bg-muted/30 px-4 py-3", className)}>
<Alert className="relative mx-auto w-full max-w-2xl pr-10">
<Sparkles />
<AlertTitle>Go ad-free with a free account</AlertTitle>
<AlertDescription>
<p>
Create a free SurfSense account to remove ads, unlock $5 of premium credit, and save
your chat history. You can keep chatting for free either way.
</p>
<Button asChild size="sm" className="mt-1">
<Link href="/login">Create Free Account</Link>
</Button>
</AlertDescription>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleDismiss}
aria-label="Dismiss"
className="absolute top-2 right-2 size-6"
>
<X />
</Button>
</Alert>
</div>
);
}

View file

@ -76,36 +76,24 @@ export function ChatExamplePrompts({ onSelect }: ChatExamplePromptsProps) {
})}
</div>
</div>
)}
{activeCategory ? (
<div className="overflow-hidden rounded-lg border border-input bg-muted shadow-sm shadow-black/5 dark:shadow-black/10 sm:rounded-xl">
<div className="flex items-center justify-between gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
<div className="flex min-w-0 items-center gap-2 text-xs font-medium text-foreground sm:text-sm">
<span className="truncate">{activeCategory.label}</span>
</div>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setActiveCategoryId(null)}
aria-label="Close example prompts"
className="size-7 shrink-0 rounded-full text-muted-foreground hover:bg-foreground/10 hover:text-foreground sm:size-8"
>
<X aria-hidden="true" className="size-3.5 sm:size-4" />
</Button>
</div>
<ScrollArea className="max-h-52 sm:max-h-64">
<ul className="divide-y px-2 pb-2 sm:px-3 sm:pb-3">
{activeCategory.prompts.map((prompt) => (
<li key={prompt} className="py-0.5 sm:py-1">
<ExamplePromptButton prompt={prompt} onSelect={onSelect} />
</li>
))}
</ul>
</ScrollArea>
</div>
) : null}
{CHAT_EXAMPLE_CATEGORIES.map((category) => (
<TabsContent
key={category.id}
value={category.id}
className="mt-3 focus-visible:outline-none"
>
<ScrollArea className="h-[clamp(7.5rem,26vh,12rem)]">
<ul className="flex flex-col gap-2 pr-3">
{category.prompts.map((prompt) => (
<li key={prompt}>
<ExamplePromptButton prompt={prompt} onSelect={onSelect} />
</li>
))}
</ul>
</ScrollArea>
</TabsContent>
))}
</Tabs>
</div>
);
}