Skip to content

Commit 0b5a7e6

Browse files
committed
Pull homepage suggestions from edge config
1 parent 5017ba3 commit 0b5a7e6

18 files changed

+1060
-73
lines changed

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,14 @@ NEXT_PUBLIC_MAPBOX_TOKEN=
3434

3535
# Optional: override user agent sent with upstream requests
3636
EXTERNAL_USER_AGENT=
37+
38+
# Vercel Flags SDK secret for encryption, overrides, and Flags Explorer
39+
# Generate with: openssl rand -base64 32 | tr '+/' '-_' | tr -d '='
40+
FLAGS_SECRET=
41+
42+
# Vercel Edge Config URL (set by Edge Config integration)
43+
EDGE_CONFIG=
44+
45+
# Optional: Statsig Console API for Flags Explorer integration
46+
STATSIG_CONSOLE_API_KEY=
47+
STATSIG_PROJECT_ID=
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { getProviderData as getStatsigProviderData } from "@flags-sdk/statsig";
2+
import { mergeProviderData } from "flags";
3+
import { createFlagsDiscoveryEndpoint, getProviderData } from "flags/next";
4+
import * as flags from "@/flags";
5+
6+
/**
7+
* Flags Explorer API endpoint
8+
*
9+
* This endpoint is used by the Vercel Toolbar's Flags Explorer to:
10+
* 1. Discover flags defined in code
11+
* 2. Fetch metadata from Statsig (if configured)
12+
* 3. Allow overriding flag values during development
13+
*
14+
* Authorization is handled automatically by createFlagsDiscoveryEndpoint.
15+
*/
16+
export const GET = createFlagsDiscoveryEndpoint(async () => {
17+
const providers = [
18+
// Expose flags declared in code
19+
getProviderData(flags),
20+
];
21+
22+
// If Statsig Console API credentials are available, enhance with metadata
23+
if (process.env.STATSIG_CONSOLE_API_KEY && process.env.STATSIG_PROJECT_ID) {
24+
providers.push(
25+
await getStatsigProviderData({
26+
projectId: process.env.STATSIG_PROJECT_ID,
27+
consoleApiKey: process.env.STATSIG_CONSOLE_API_KEY,
28+
}),
29+
);
30+
}
31+
32+
return mergeProviderData(providers);
33+
});

app/layout.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { Analytics } from "@vercel/analytics/next";
2+
import { VercelToolbar } from "@vercel/toolbar/next";
23
import { GeistMono } from "geist/font/mono";
34
import { GeistSans } from "geist/font/sans";
45
import type { Metadata } from "next";
@@ -46,6 +47,7 @@ export default function RootLayout({
4647
<Toaster />
4748
</Providers>
4849
<Analytics />
50+
{process.env.NODE_ENV === "development" && <VercelToolbar />}
4951
</body>
5052
</html>
5153
);

app/not-found.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export default function NotFound() {
2424
</EmptyDescription>
2525
</EmptyHeader>
2626
<EmptyContent className="w-full">
27-
<DomainSearch showSuggestions={false} />
27+
<DomainSearch variant="lg" />
2828
</EmptyContent>
2929
</Empty>
3030
</div>

app/page.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
import { DomainSearch } from "@/components/domain/domain-search";
1+
import { DomainSuggestionsServer } from "@/components/domain/domain-suggestions-server";
22
import { HomeHero } from "@/components/home-hero";
3+
import { HomeSearchSection } from "@/components/home-search-section";
34

45
export default function Home() {
56
return (
67
<div className="container mx-auto my-auto flex items-center justify-center px-4 py-8">
78
<div className="w-full space-y-6">
89
<HomeHero />
9-
10-
<div className="mx-auto w-full max-w-3xl">
11-
<DomainSearch variant="lg" />
12-
</div>
10+
<HomeSearchSection>
11+
<DomainSuggestionsServer />
12+
</HomeSearchSection>
1313
</div>
1414
</div>
1515
);

components/domain/domain-search.test.tsx

Lines changed: 17 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,6 @@ vi.mock("next/navigation", () => ({
1616
useParams: () => ({}),
1717
}));
1818

19-
vi.mock("@/components/domain/domain-suggestions", () => ({
20-
DomainSuggestions: ({
21-
onSelectAction,
22-
}: {
23-
onSelectAction?: (domain: string) => void;
24-
}) => (
25-
<button type="button" onClick={() => onSelectAction?.("example.com")}>
26-
example.com
27-
</button>
28-
),
29-
}));
30-
3119
vi.mock("sonner", () => ({ toast: { error: vi.fn() } }));
3220

3321
describe("DomainSearch (form variant)", () => {
@@ -59,25 +47,29 @@ describe("DomainSearch (form variant)", () => {
5947
expect(toast.error).toHaveBeenCalled();
6048
});
6149

62-
it("fills input and navigates when a suggestion is clicked", async () => {
63-
render(<DomainSearch variant="lg" />);
64-
// Click the mocked suggestion button
65-
await userEvent.click(
66-
screen.getByRole("button", { name: /example\.com/i }),
50+
it("handles external navigation trigger", async () => {
51+
const onComplete = vi.fn();
52+
const { rerender } = render(
53+
<DomainSearch variant="lg" onNavigationComplete={onComplete} />,
6754
);
68-
// Input should reflect the selected domain immediately
55+
56+
// Simulate external navigation request (e.g., from suggestion click)
57+
rerender(
58+
<DomainSearch
59+
variant="lg"
60+
externalNavigation={{ domain: "example.com", source: "suggestion" }}
61+
onNavigationComplete={onComplete}
62+
/>,
63+
);
64+
65+
// Input should reflect the triggered domain
6966
const input = screen.getByLabelText(
7067
/Search any domain/i,
7168
) as HTMLInputElement;
7269
expect(input.value).toBe("example.com");
7370
// Navigation should have been triggered
7471
expect(nav.push).toHaveBeenCalledWith("/example.com");
75-
// Submit button shows a loading spinner and is disabled while navigating
76-
expect(screen.getByRole("button", { name: /loading/i })).toBeDisabled();
77-
// Input should be disabled while loading
78-
expect(
79-
(screen.getByLabelText(/Search any domain/i) as HTMLInputElement)
80-
.disabled,
81-
).toBe(true);
72+
// Completion callback should be called
73+
expect(onComplete).toHaveBeenCalled();
8274
});
8375
});

components/domain/domain-search.tsx

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import { ArrowRight, Search } from "lucide-react";
44
import { useEffect, useRef, useState } from "react";
5-
import { DomainSuggestions } from "@/components/domain/domain-suggestions";
65
import {
76
InputGroup,
87
InputGroupAddon,
@@ -17,16 +16,20 @@ import { cn } from "@/lib/utils";
1716

1817
export type DomainSearchVariant = "sm" | "lg";
1918

19+
type Source = "form" | "header" | "suggestion";
20+
2021
export type DomainSearchProps = {
2122
variant?: DomainSearchVariant;
2223
initialValue?: string;
23-
showSuggestions?: boolean;
24+
externalNavigation?: { domain: string; source: Source } | null;
25+
onNavigationComplete?: () => void;
2426
};
2527

2628
export function DomainSearch({
2729
variant = "lg",
2830
initialValue = "",
29-
showSuggestions = true,
31+
externalNavigation,
32+
onNavigationComplete,
3033
}: DomainSearchProps) {
3134
const { value, setValue, loading, inputRef, submit, navigateToDomain } =
3235
useDomainSearch({
@@ -42,6 +45,18 @@ export function DomainSearch({
4245
const [isFocused, setIsFocused] = useState(false);
4346
useEffect(() => setMounted(true), []);
4447

48+
// Handle external navigation requests (e.g., from suggestion clicks)
49+
useEffect(() => {
50+
if (externalNavigation) {
51+
// Mirror the selected domain in the input so the form appears submitted
52+
setValue(externalNavigation.domain);
53+
// Trigger navigation
54+
navigateToDomain(externalNavigation.domain, externalNavigation.source);
55+
// Notify parent that navigation was handled
56+
onNavigationComplete?.();
57+
}
58+
}, [externalNavigation, setValue, navigateToDomain, onNavigationComplete]);
59+
4560
// Select all on first focus from keyboard or first click; allow precise cursor on next click.
4661
const pointerDownRef = useRef(false);
4762
const justFocusedRef = useRef(false);
@@ -181,17 +196,6 @@ export function DomainSearch({
181196
</InputGroup>
182197
</div>
183198
</form>
184-
185-
{variant === "lg" && showSuggestions && (
186-
<DomainSuggestions
187-
onSelectAction={(d) => {
188-
// Mirror the selected domain in the input so the form
189-
// appears submitted while navigation is in-flight.
190-
setValue(d);
191-
navigateToDomain(d, "suggestion");
192-
}}
193-
/>
194-
)}
195199
</div>
196200
);
197201
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"use client";
2+
3+
import { useHomeSearch } from "@/components/home-search-context";
4+
import { DomainSuggestions } from "./domain-suggestions";
5+
6+
type DomainSuggestionsClientProps = {
7+
suggestions: string[];
8+
className?: string;
9+
faviconSize?: number;
10+
max?: number;
11+
};
12+
13+
/**
14+
* Client wrapper that connects DomainSuggestions to HomeSearchContext.
15+
*
16+
* This allows the server component DomainSuggestionsServer to pass suggestions
17+
* while the click handler comes from the context provided by HomeSearchSection.
18+
*/
19+
export function DomainSuggestionsClient(props: DomainSuggestionsClientProps) {
20+
const { onSuggestionClickAction } = useHomeSearch();
21+
22+
return (
23+
<DomainSuggestions {...props} onSelectAction={onSuggestionClickAction} />
24+
);
25+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { domainSuggestionsFlag } from "@/flags";
2+
import { DomainSuggestionsClient } from "./domain-suggestions-client";
3+
4+
type DomainSuggestionsServerProps = {
5+
className?: string;
6+
faviconSize?: number;
7+
max?: number;
8+
};
9+
10+
/**
11+
* Server wrapper for DomainSuggestions that evaluates the feature flag.
12+
*
13+
* This component evaluates the domainSuggestionsFlag on the server side
14+
* and passes the result to the client DomainSuggestions component.
15+
*
16+
* The onSelectAction handler is provided via HomeSearchContext from the parent.
17+
*/
18+
export async function DomainSuggestionsServer(
19+
props: DomainSuggestionsServerProps,
20+
) {
21+
const suggestions = await domainSuggestionsFlag();
22+
23+
return <DomainSuggestionsClient {...props} suggestions={suggestions} />;
24+
}

components/domain/domain-suggestions.test.tsx

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ import { createElement } from "react";
55
import { beforeEach, describe, expect, it, vi } from "vitest";
66
import { DomainSuggestions } from "@/components/domain/domain-suggestions";
77

8+
const TEST_SUGGESTIONS = [
9+
"github.com",
10+
"reddit.com",
11+
"wikipedia.org",
12+
"firefox.com",
13+
"jarv.is",
14+
];
15+
816
vi.mock("@bprogress/next/app", () => ({
917
useRouter: () => ({ push: vi.fn() }),
1018
}));
@@ -23,9 +31,9 @@ describe("DomainSuggestions", () => {
2331
localStorage.removeItem("search-history");
2432
});
2533

26-
it("renders default suggestions when there is no history", async () => {
27-
render(<DomainSuggestions />);
28-
// Wait for a known default like jarv.is to appear
34+
it("renders provided suggestions when there is no history", async () => {
35+
render(<DomainSuggestions suggestions={TEST_SUGGESTIONS} />);
36+
// Wait for a known suggestion like jarv.is to appear
2937
expect(
3038
await screen.findByRole("button", { name: /jarv\.is/i }),
3139
).toBeInTheDocument();
@@ -35,20 +43,20 @@ describe("DomainSuggestions", () => {
3543
).toBeGreaterThan(0);
3644
});
3745

38-
it("merges history and defaults without duplicates, capped by max", async () => {
46+
it("merges history and suggestions without duplicates, capped by max", async () => {
3947
localStorage.setItem(
4048
"search-history",
4149
JSON.stringify(["foo.com", "github.com", "bar.org"]),
4250
);
43-
render(<DomainSuggestions max={4} />);
51+
render(<DomainSuggestions suggestions={TEST_SUGGESTIONS} max={4} />);
4452
// History entries appear
4553
expect(
4654
await screen.findByRole("button", { name: /foo\.com/i }),
4755
).toBeInTheDocument();
4856
expect(
4957
screen.getByRole("button", { name: /bar\.org/i }),
5058
).toBeInTheDocument();
51-
// github.com appears only once (deduped with defaults)
59+
// github.com appears only once (deduped with suggestions)
5260
expect(screen.getAllByRole("button", { name: /github\.com/i }).length).toBe(
5361
1,
5462
);
@@ -57,7 +65,12 @@ describe("DomainSuggestions", () => {
5765
it("invokes onSelect when a suggestion is clicked", async () => {
5866
const onSelect = vi.fn();
5967
localStorage.setItem("search-history", JSON.stringify(["example.com"]));
60-
render(<DomainSuggestions onSelectAction={onSelect} />);
68+
render(
69+
<DomainSuggestions
70+
suggestions={TEST_SUGGESTIONS}
71+
onSelectAction={onSelect}
72+
/>,
73+
);
6174
await userEvent.click(screen.getByRole("button", { name: /example.com/i }));
6275
expect(onSelect).toHaveBeenCalledWith("example.com");
6376
});

0 commit comments

Comments
 (0)