diff --git a/src/website_profiling/crawl/crawler.py b/src/website_profiling/crawl/crawler.py index c74d727..1c68926 100644 --- a/src/website_profiling/crawl/crawler.py +++ b/src/website_profiling/crawl/crawler.py @@ -302,6 +302,8 @@ def worker(self, url: str) -> dict: ) status = result.status + is_success = isinstance(status, int) and 200 <= status < 300 + is_redirect = isinstance(status, int) and 300 <= status < 400 ct = result.content_type text = result.text response_time_ms = result.response_time_ms @@ -371,7 +373,15 @@ def worker(self, url: str) -> dict: if self.store_outlinks: outlink_list.append(link) self.link_edges_accum.append({"from_url": url, **edge}) - self.frontier.try_enqueue_link(link, url) + # Only crawl links discovered on successful (2xx) pages; links + # parsed from custom 4xx/5xx error pages should not be followed. + if is_success: + self.frontier.try_enqueue_link(link, url) + + # A redirect (3xx) has no crawlable body; enqueue its target so the + # destination is fetched and recorded as its own row (per-hop chain). + if is_redirect and final_url and final_url.rstrip("/") != url.rstrip("/"): + self.frontier.try_enqueue_link(final_url, url) ext["response_time_ms"] = response_time_ms if response_time_ms is not None else "" ext["content_length"] = content_length or 0 diff --git a/src/website_profiling/crawl/fetchers/browser.py b/src/website_profiling/crawl/fetchers/browser.py index 4d6a7d6..bf2e258 100644 --- a/src/website_profiling/crawl/fetchers/browser.py +++ b/src/website_profiling/crawl/fetchers/browser.py @@ -299,6 +299,21 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: max_per_page=self.console_max_per_page, ) collector.attach(page) + + # Record main-frame navigation responses in order so that (a) a response + # that was received is not lost when goto raises, and (b) we can report + # the URL's OWN status (e.g. a 301) instead of the followed destination. + nav_responses: list[Any] = [] + + def _on_response(resp: Any) -> None: + try: + req = resp.request + if req.is_navigation_request() and resp.frame == page.main_frame: + nav_responses.append(resp) + except Exception: + pass + + page.on("response", _on_response) try: try: response = await page.goto( @@ -312,15 +327,21 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: if self.extra_wait_ms and response is not None: await asyncio.sleep(self.extra_wait_ms / 1000.0) finally: + try: + page.remove_listener("response", _on_response) + except Exception: + pass if collector is not None: collector.detach(page) response_time_ms = int((time.perf_counter() - t0) * 1000) final_url = page.url or url - redirect_chain_length = 1 if final_url.rstrip("/") != url.rstrip("/") else 0 browser_diagnostics = collector.build() if collector is not None else None - if response is None: + # Prefer the first observed main-frame response: this is the URL's own + # response (a 3xx redirect or an error status), not the final hop. + own_response = nav_responses[0] if nav_responses else response + if own_response is None: return FetchResult( status=None, content_type=None, @@ -329,20 +350,27 @@ async def _fetch_page(self, page: Any, url: str) -> FetchResult: content_length=0, final_url=final_url, headers_dict={}, - redirect_chain_length=redirect_chain_length, + redirect_chain_length=1 if final_url.rstrip("/") != url.rstrip("/") else 0, fetch_method="rendered", browser_diagnostics=browser_diagnostics, ) - status = response.status - headers = response.headers or {} + status = own_response.status + redirect_chain_length = sum( + 1 for r in nav_responses if 300 <= int(getattr(r, "status", 0) or 0) < 400 + ) + headers = own_response.headers or {} lower_headers = {str(k).lower(): v for k, v in headers.items()} ct = lower_headers.get("content-type", "") headers_dict = { k: (headers.get(k) or lower_headers.get(k.lower(), "")) for k in HEADER_KEYS } - is_html = status == 200 and ("text/html" in ct or "application/xhtml+xml" in ct) + is_redirect = 300 <= status < 400 + # Capture body for 2xx and error (4xx/5xx) HTML pages; skip redirects. + is_html = (not is_redirect) and ( + "text/html" in ct or "application/xhtml+xml" in ct + ) text: Optional[str] = None content_length = 0 if is_html: diff --git a/src/website_profiling/crawl/fetchers/static.py b/src/website_profiling/crawl/fetchers/static.py index 37a867a..44d9f57 100644 --- a/src/website_profiling/crawl/fetchers/static.py +++ b/src/website_profiling/crawl/fetchers/static.py @@ -5,6 +5,7 @@ import threading import time from typing import Callable, Optional +from urllib.parse import urljoin import requests @@ -71,16 +72,30 @@ def fetch(self, url: str) -> FetchResult: session = self.session try: t0 = time.perf_counter() - resp = session.get(url, timeout=self.timeout, allow_redirects=True) + # Do NOT auto-follow redirects: we want to record the URL's own + # response (e.g. 301/308) rather than collapsing the chain into the + # final 200. The crawler enqueues the Location target so each hop is + # crawled and recorded as its own row. + resp = session.get(url, timeout=self.timeout, allow_redirects=False) response_time_ms = int((time.perf_counter() - t0) * 1000) ct = resp.headers.get("Content-Type", "") - is_html = resp.status_code == 200 and ( + location = resp.headers.get("Location") or resp.headers.get("location") or "" + # A redirect is a 3xx with a Location header (matches requests' own + # definition; excludes 304 Not Modified). + is_redirect = resp.status_code in (301, 302, 303, 307, 308) and bool(location) + # Capture the body for 2xx and error (4xx/5xx) HTML pages so custom + # error pages can be analysed; redirects have no meaningful body. + is_html = (not is_redirect) and ( "text/html" in ct or "application/xhtml+xml" in ct ) text = resp.text if is_html else None content_length = len(resp.content) if resp.content is not None else 0 - final_url = resp.url or url - redirect_chain_length = len(resp.history) + if is_redirect: + final_url = urljoin(url, location) + redirect_chain_length = 1 + else: + final_url = resp.url or url + redirect_chain_length = len(resp.history) headers_dict = {k: (resp.headers.get(k) or "") for k in HEADER_KEYS} return FetchResult( status=resp.status_code, diff --git a/src/website_profiling/reporting/seo_summary.py b/src/website_profiling/reporting/seo_summary.py index bd49de9..e930063 100644 --- a/src/website_profiling/reporting/seo_summary.py +++ b/src/website_profiling/reporting/seo_summary.py @@ -10,10 +10,29 @@ META_DESC_LEN_MAX = 160 THIN_CONTENT_CHARS = 300 + +def _status_text(value: object) -> str: + """Normalize a status value to a clean string (e.g. 400.0 -> "400"). + + Numeric statuses can arrive as ints, strings, or floats (when pandas coerces + a column containing NaN); non-numeric markers like "error"/"blocked_by_robots" + pass through unchanged. Keeps status-code matching robust across all of them. + """ + if value is None: + return "" + try: + f = float(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return str(value).strip() + if f != f: # NaN + return "" + return str(int(f)) + + def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: """Compute crawl summary, SEO health metrics, issues list, and recommendations from crawl DataFrame.""" total = len(df) - status_str = df["status"].astype(str) if "status" in df.columns else pd.Series(["unknown"] * len(df)) + status_str = df["status"].map(_status_text) if "status" in df.columns else pd.Series(["unknown"] * len(df)) count_2xx = int((status_str.str.match(r"2\d{2}").fillna(False)).sum()) count_3xx = int((status_str.str.match(r"3\d{2}").fillna(False)).sum()) count_4xx = int((status_str.str.match(r"4\d{2}").fillna(False)).sum()) @@ -76,7 +95,7 @@ def _compute_summary_seo_issues(df: pd.DataFrame) -> dict: if pd.isna(u) or not u: continue u = str(u).strip() - st = str(row.get("status", "")).strip() + st = _status_text(row.get("status", "")) if st.startswith("4") or st.startswith("5") or st in ("error", "blocked_by_robots"): issues["broken"].append({"url": u, "status": st}) elif st.startswith("3"): diff --git a/tests/reporting/test_reporting_builder_modules.py b/tests/reporting/test_reporting_builder_modules.py index 9720689..b348e78 100644 --- a/tests/reporting/test_reporting_builder_modules.py +++ b/tests/reporting/test_reporting_builder_modules.py @@ -122,6 +122,40 @@ def test_compute_summary_seo_issues() -> None: assert out["recommendations"] +def test_status_text_normalization() -> None: + assert seo_summary._status_text(400) == "400" + assert seo_summary._status_text(400.0) == "400" + assert seo_summary._status_text("301") == "301" + assert seo_summary._status_text("error") == "error" + assert seo_summary._status_text(None) == "" + assert seo_summary._status_text(float("nan")) == "" + + +def test_compute_summary_classifies_numeric_and_float_statuses() -> None: + df = pd.DataFrame( + [ + {"url": "https://example.com/ok", "status": 200.0}, + {"url": "https://example.com/redir", "status": 301, "final_url": "https://example.com/dest"}, + {"url": "https://example.com/bad", "status": 400.0}, + {"url": "https://example.com/boom", "status": 500}, + ] + ) + out = seo_summary._compute_summary_seo_issues(df) + summary = out["summary"] + assert summary["count_2xx"] == 1 + assert summary["count_3xx"] == 1 + assert summary["count_4xx"] == 1 + assert summary["count_5xx"] == 1 + + broken = {b["url"] for b in out["issues"]["broken"]} + assert {"https://example.com/bad", "https://example.com/boom"} <= broken + + redirects = {r["url"]: r for r in out["issues"]["redirects"]} + assert "https://example.com/redir" in redirects + assert redirects["https://example.com/redir"]["status"] == "301" + assert redirects["https://example.com/redir"]["final_url"] == "https://example.com/dest" + + def test_content_analytics_helpers() -> None: df = _crawl_df() content = content_analytics._build_content_analytics(df) diff --git a/tests/test_browser_fetcher_unit.py b/tests/test_browser_fetcher_unit.py index a1a7538..29032a8 100644 --- a/tests/test_browser_fetcher_unit.py +++ b/tests/test_browser_fetcher_unit.py @@ -846,3 +846,180 @@ def test_browser_fetcher_close_when_loop_unavailable(fake_playwright) -> None: fetcher._loop = None fetcher._closed = False fetcher.close() + + +class _NavRequest: + def __init__(self, *, is_navigation: bool) -> None: + self._is_navigation = is_navigation + + def is_navigation_request(self) -> bool: + return self._is_navigation + + +class _NavResponse: + def __init__(self, page: "_NavResponsePage", *, status: int, is_navigation: bool) -> None: + self.status = status + self.headers = {"content-type": "text/html", "Location": "https://example.com/new"} + self.request = _NavRequest(is_navigation=is_navigation) + self.frame = page.main_frame + + +class _NavResponsePage(_FakePage): + def __init__(self) -> None: + super().__init__() + self.main_frame = object() + self.url = "https://example.com/new" + + async def goto(self, _url: str, **_kwargs: Any) -> _FakeResponse: + for handler in self._handlers.get("response", []): + handler(_NavResponse(self, status=301, is_navigation=True)) + return _FakeResponse() + + +class _NavResponseContext(_FakeContext): + async def new_page(self) -> _NavResponsePage: + return _NavResponsePage() + + +class _NavResponseBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _NavResponseContext: + return _NavResponseContext() + + +class _NavResponseChromium: + async def launch(self, **_kwargs: Any) -> _NavResponseBrowser: + return _NavResponseBrowser() + + +class _NavResponsePlaywright: + chromium = _NavResponseChromium() + + async def stop(self) -> None: + return None + + +class _NavResponsePlaywrightContext: + async def start(self) -> _NavResponsePlaywright: + return _NavResponsePlaywright() + + +def test_browser_fetcher_prefers_recorded_navigation_response(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _NavResponsePlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/old") + assert result.status == 301 + assert result.redirect_chain_length == 1 + assert result.text is None + finally: + fetcher.close() + + +class _BrokenNavResponse: + @property + def request(self) -> None: + raise RuntimeError("response handler failed") + + +class _BrokenNavResponsePage(_FakePage): + async def goto(self, _url: str, **_kwargs: Any) -> _FakeResponse: + for handler in self._handlers.get("response", []): + handler(_BrokenNavResponse()) + return _FakeResponse() + + +class _BrokenNavResponseContext(_FakeContext): + async def new_page(self) -> _BrokenNavResponsePage: + return _BrokenNavResponsePage() + + +class _BrokenNavResponseBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _BrokenNavResponseContext: + return _BrokenNavResponseContext() + + +class _BrokenNavResponseChromium: + async def launch(self, **_kwargs: Any) -> _BrokenNavResponseBrowser: + return _BrokenNavResponseBrowser() + + +class _BrokenNavResponsePlaywright: + chromium = _BrokenNavResponseChromium() + + async def stop(self) -> None: + return None + + +class _BrokenNavResponsePlaywrightContext: + async def start(self) -> _BrokenNavResponsePlaywright: + return _BrokenNavResponsePlaywright() + + +def test_browser_fetcher_response_handler_swallows_errors(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _BrokenNavResponsePlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/") + assert result.status == 200 + finally: + fetcher.close() + + +class _RemoveListenerFailPage(_FakePage): + def remove_listener(self, event: str, handler: Any) -> None: + raise RuntimeError("listener missing") + + +class _RemoveListenerFailContext(_FakeContext): + async def new_page(self) -> _RemoveListenerFailPage: + return _RemoveListenerFailPage() + + +class _RemoveListenerFailBrowser(_FakeBrowser): + async def new_context(self, **_kwargs: Any) -> _RemoveListenerFailContext: + return _RemoveListenerFailContext() + + +class _RemoveListenerFailChromium: + async def launch(self, **_kwargs: Any) -> _RemoveListenerFailBrowser: + return _RemoveListenerFailBrowser() + + +class _RemoveListenerFailPlaywright: + chromium = _RemoveListenerFailChromium() + + async def stop(self) -> None: + return None + + +class _RemoveListenerFailPlaywrightContext: + async def start(self) -> _RemoveListenerFailPlaywright: + return _RemoveListenerFailPlaywright() + + +def test_browser_fetcher_remove_listener_failure_swallowed(monkeypatch: pytest.MonkeyPatch) -> None: + from website_profiling.crawl.fetchers.browser import BrowserFetcher + + fake_api = MagicMock() + fake_api.async_playwright = lambda: _RemoveListenerFailPlaywrightContext() + monkeypatch.setitem(__import__("sys").modules, "playwright", MagicMock(async_api=fake_api)) + monkeypatch.setitem(__import__("sys").modules, "playwright.async_api", fake_api) + + fetcher = BrowserFetcher(timeout=5, js_concurrency=1, extra_wait_ms=0, block_resources=False) + try: + result = fetcher.fetch("https://example.com/") + assert result.status == 200 + finally: + fetcher.close() diff --git a/tests/test_crawl_fetchers.py b/tests/test_crawl_fetchers.py index e599809..3545629 100644 --- a/tests/test_crawl_fetchers.py +++ b/tests/test_crawl_fetchers.py @@ -40,6 +40,84 @@ def test_static_fetcher_parses_html(): assert result.browser_diagnostics is None +class _FakeResp: + def __init__(self, status_code, headers=None, text="", url=""): + self.status_code = status_code + self.headers = headers or {} + self.text = text + self.content = text.encode("utf-8") + self.url = url + self.history = [] + + @property + def is_redirect(self): + return self.status_code in (301, 302, 303, 307, 308) and "Location" in self.headers + + @property + def is_permanent_redirect(self): + return self.status_code in (301, 308) and "Location" in self.headers + + +class _FakeSession: + def __init__(self, resp): + self._resp = resp + self.headers = {} + self.calls = [] + + def get(self, url, timeout=None, allow_redirects=True): + self.calls.append({"url": url, "allow_redirects": allow_redirects}) + return self._resp + + def close(self): + pass + + +def test_static_fetcher_records_permanent_redirect_without_following(): + resp = _FakeResp( + 301, + headers={"Location": "https://example.com/new", "Content-Type": "text/html"}, + text="ignored redirect body", + url="https://example.com/old", + ) + session = _FakeSession(resp) + fetcher = StaticFetcher(timeout=5, session=session) + result = fetcher.fetch("https://example.com/old") + + # The redirect must NOT be followed: status is the real 301, not the dest's 200. + assert session.calls[0]["allow_redirects"] is False + assert result.status == 301 + assert result.final_url == "https://example.com/new" + assert result.text is None + assert result.redirect_chain_length == 1 + + +def test_static_fetcher_resolves_relative_redirect_location(): + resp = _FakeResp( + 308, + headers={"Location": "/moved"}, + url="https://example.com/old", + ) + fetcher = StaticFetcher(timeout=5, session=_FakeSession(resp)) + result = fetcher.fetch("https://example.com/old") + assert result.status == 308 + assert result.final_url == "https://example.com/moved" + + +def test_static_fetcher_records_client_error_and_captures_body(): + resp = _FakeResp( + 400, + headers={"Content-Type": "text/html"}, + text="
Loading…
-+ {app.productName} +
+{app.productSubtitle}
+ +{app.loading}
+{app.loadingSubtitle}
+{label}
++ {label} +
) : ( )}
-
+
+
$
{command}
diff --git a/web/src/components/landing/LandingDeckContext.tsx b/web/src/components/landing/LandingDeckContext.tsx
new file mode 100644
index 0000000..4a7d8b7
--- /dev/null
+++ b/web/src/components/landing/LandingDeckContext.tsx
@@ -0,0 +1,32 @@
+'use client';
+
+import { createContext, useContext, type ReactNode } from 'react';
+import { LANDING_DECK_SECTION_ORDER } from '@/components/landing/landingLayout';
+import { useLandingDeck, type UseLandingDeckResult } from '@/hooks/useLandingDeck';
+
+const LandingDeckContext = createContext(null);
+
+export interface LandingDeckProviderProps {
+ children: ReactNode;
+}
+
+export function LandingDeckProvider({ children }: LandingDeckProviderProps) {
+ const deck = useLandingDeck({
+ sectionIds: LANDING_DECK_SECTION_ORDER,
+ });
+
+ return {children} ;
+}
+
+export function useLandingDeckContext(): UseLandingDeckResult | null {
+ return useContext(LandingDeckContext);
+}
+
+/** Required inside landing deck UI (progress, controls, scroll cues). */
+export function useLandingDeckRequired(): UseLandingDeckResult {
+ const ctx = useContext(LandingDeckContext);
+ if (!ctx) {
+ throw new Error('useLandingDeckRequired must be used within LandingDeckProvider');
+ }
+ return ctx;
+}
diff --git a/web/src/components/landing/LandingDeckControls.tsx b/web/src/components/landing/LandingDeckControls.tsx
new file mode 100644
index 0000000..023e0a3
--- /dev/null
+++ b/web/src/components/landing/LandingDeckControls.tsx
@@ -0,0 +1,155 @@
+'use client';
+
+import {
+ ChevronLeft,
+ ChevronRight,
+ MonitorPlay,
+ Pause,
+ Play,
+ X,
+} from 'lucide-react';
+import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext';
+import {
+ LANDING_DECK_AUTO_ADVANCE_INTERVALS_MS,
+ type LandingDeckAutoAdvanceMs,
+} from '@/components/landing/landingLayout';
+import { strings } from '@/lib/strings';
+
+export default function LandingDeckControls() {
+ const vl = strings.views.landing;
+ const {
+ activeIndex,
+ total,
+ activeId,
+ goNext,
+ goPrev,
+ presenterMode,
+ setPresenterMode,
+ autoAdvance,
+ setAutoAdvance,
+ autoAdvanceMs,
+ setAutoAdvanceMs,
+ } = useLandingDeckRequired();
+
+ const deckNotes = vl.deckNotes as Record | undefined;
+ const speakerNote = activeId && deckNotes ? deckNotes[activeId] ?? '' : '';
+
+ const atStart = activeIndex <= 0;
+ const atEnd = activeIndex >= total - 1;
+
+ return (
+ <>
+ {!presenterMode ? (
+
+
+
+
+ ) : null}
+
+ {presenterMode ? (
+
+
+
+
+
+
+
+
+
+
+
+
+ {vl.deckShortcutsHint}
+ {speakerNote ? (
+ {speakerNote}
+ ) : null}
+
+ ) : null}
+ >
+ );
+}
+
+export function LandingDeckPresentButton() {
+ const vl = strings.views.landing;
+ const { setPresenterMode } = useLandingDeckRequired();
+
+ return (
+
+ );
+}
diff --git a/web/src/components/landing/LandingDeckProgress.tsx b/web/src/components/landing/LandingDeckProgress.tsx
new file mode 100644
index 0000000..d0f1df7
--- /dev/null
+++ b/web/src/components/landing/LandingDeckProgress.tsx
@@ -0,0 +1,35 @@
+'use client';
+
+import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext';
+import { strings } from '@/lib/strings';
+
+export default function LandingDeckProgress() {
+ const vl = strings.views.landing;
+ const { activeIndex, total } = useLandingDeckRequired();
+ const progress = total > 0 ? (activeIndex + 1) / total : 0;
+
+ return (
+
+
+
+
+
+
+ {activeIndex + 1} / {total}
+
+
+
+ );
+}
diff --git a/web/src/components/landing/LandingDeckTrack.tsx b/web/src/components/landing/LandingDeckTrack.tsx
new file mode 100644
index 0000000..234949d
--- /dev/null
+++ b/web/src/components/landing/LandingDeckTrack.tsx
@@ -0,0 +1,23 @@
+'use client';
+
+import type { ReactNode } from 'react';
+import { useLandingDeckRequired } from '@/components/landing/LandingDeckContext';
+
+export interface LandingDeckTrackProps {
+ children: ReactNode;
+}
+
+/** Horizontal slide strip — only one child panel visible in the viewport at a time. */
+export default function LandingDeckTrack({ children }: LandingDeckTrackProps) {
+ const { activeIndex, slideTransition } = useLandingDeckRequired();
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/web/src/components/landing/LandingFeatureSpotlight.tsx b/web/src/components/landing/LandingFeatureSpotlight.tsx
index 8a6bae3..949e561 100644
--- a/web/src/components/landing/LandingFeatureSpotlight.tsx
+++ b/web/src/components/landing/LandingFeatureSpotlight.tsx
@@ -3,6 +3,15 @@
import Link from 'next/link';
import { CheckCircle2, ChevronRight } from 'lucide-react';
import LandingProductMock, { type LandingProductMockVariant } from '@/components/landing/LandingProductMock';
+import {
+ landingGutterClass,
+ landingSectionSplitClass,
+ landingSplitCopyClass,
+ landingSplitMockClass,
+ landingSplitVisualClass,
+} from '@/components/landing/landingLayout';
+import { useInView } from '@/lib/useInView';
+import type { CSSProperties } from 'react';
interface LandingFeatureSpotlightProps {
eyebrow: string;
@@ -12,6 +21,9 @@ interface LandingFeatureSpotlightProps {
mockVariant: LandingProductMockVariant;
ctaHref: string;
ctaLabel: string;
+ secondaryCtaHref?: string;
+ secondaryCtaLabel?: string;
+ secondaryCtaExternal?: boolean;
reversed?: boolean;
}
@@ -23,45 +35,82 @@ export default function LandingFeatureSpotlight({
mockVariant,
ctaHref,
ctaLabel,
+ secondaryCtaHref,
+ secondaryCtaLabel,
+ secondaryCtaExternal = false,
reversed = false,
}: LandingFeatureSpotlightProps) {
- const textCol = (
-
- {eyebrow}
- {title}
- {description}
-
- {bullets.map((bullet) => (
- -
-
- {bullet}
+ const { ref: bulletsRef, inView: bulletsInView } = useInView();
+
+ const copy = (
+ <>
+ {eyebrow}
+ {title}
+ {description}
+
+ {bullets.slice(0, 3).map((bullet, index) => (
+ -
+
+ {bullet}
))}
{ctaLabel}
-
- );
-
- const mockCol = (
-
-
-
+ {secondaryCtaHref && secondaryCtaLabel ? (
+ secondaryCtaExternal ? (
+
+ {secondaryCtaLabel}
+
+
+ ) : (
+
+ {secondaryCtaLabel}
+
+
+ )
+ ) : null}
+ >
);
return (
- *:first-child]:order-2' : ''
- }`}
- >
- {textCol}
- {mockCol}
+
+
+ {copy}
+
+
+
+
+
+
);
}
diff --git a/web/src/components/landing/LandingFinalCta.tsx b/web/src/components/landing/LandingFinalCta.tsx
index 0a25c11..ff3fdb4 100644
--- a/web/src/components/landing/LandingFinalCta.tsx
+++ b/web/src/components/landing/LandingFinalCta.tsx
@@ -1,35 +1,78 @@
'use client';
import Link from 'next/link';
-import Button from '@/components/Button';
+import { CheckCircle2, ChevronRight } from 'lucide-react';
+import LandingProductMock from '@/components/landing/LandingProductMock';
+import LandingSectionHeader from '@/components/landing/LandingSectionHeader';
+import {
+ landingContentClass,
+ landingGutterClass,
+ landingSectionSplitClass,
+ landingSplitCopyClass,
+ landingSplitMockClass,
+ landingSplitVisualClass,
+} from '@/components/landing/landingLayout';
import { strings } from '@/lib/strings';
const vl = strings.views.landing;
export default function LandingFinalCta() {
return (
-
-
- {vl.finalCtaTitle}
-
- {vl.finalCtaSubtitle}
-
-
-
-