Skip to content

Commit 55f4844

Browse files
committed
improve public page crawl signals
2 parents f1bb435 + 07dbd68 commit 55f4844

8 files changed

Lines changed: 177 additions & 11 deletions

File tree

e2e/home.spec.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { expect, test } from "@playwright/test";
2+
import { HOST_EMAIL, signIn } from "./helpers";
23

34
test("homepage hydrates without chat date mismatches", async ({
45
browser,
@@ -108,3 +109,57 @@ test("homepage drop-off only shows curated featured hosts", async ({
108109
page.getByTestId("homepage-featured-host-demo-newtown-worm-farm")
109110
).toHaveCount(0);
110111
});
112+
113+
test("homepage account button stays hidden while signed-in profile state loads", async ({
114+
page,
115+
}) => {
116+
await signIn(page, { email: HOST_EMAIL, redirectTo: "/profile" });
117+
118+
let resolveProfileRequest = () => {};
119+
const profileRequestStarted = new Promise<void>((resolve) => {
120+
resolveProfileRequest = resolve;
121+
});
122+
let continueProfileRequest = () => {};
123+
const profileRequestCanContinue = new Promise<void>((resolve) => {
124+
continueProfileRequest = resolve;
125+
});
126+
127+
await page.route(/\/rest\/v1\/profiles(?:\?|$)/, async (route) => {
128+
const request = route.request();
129+
130+
if (
131+
request.method() === "GET" &&
132+
request.url().includes("select=first_name")
133+
) {
134+
resolveProfileRequest();
135+
await profileRequestCanContinue;
136+
}
137+
138+
await route.continue();
139+
});
140+
141+
await page.goto("/", { waitUntil: "domcontentloaded" });
142+
await profileRequestStarted;
143+
144+
try {
145+
await expect(page.getByTestId("account-button-profile")).toHaveCount(0);
146+
await expect(page.getByTestId("account-button-sign-in")).toHaveCount(0);
147+
} finally {
148+
continueProfileRequest();
149+
}
150+
151+
const profileAccountButton = page.getByTestId("account-button-profile");
152+
153+
await expect(profileAccountButton).toHaveAttribute("href", "/profile");
154+
await expect(profileAccountButton).toHaveCSS("opacity", "1");
155+
});
156+
157+
test("homepage account button links guests to sign in", async ({ page }) => {
158+
await page.goto("/", { waitUntil: "domcontentloaded" });
159+
160+
await expect(page.getByTestId("account-button-sign-in")).toHaveAttribute(
161+
"href",
162+
"/sign-in"
163+
);
164+
await expect(page.getByTestId("account-button-profile")).toHaveCount(0);
165+
});
Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"use client";
22

3+
import { useEffect, useState } from "react";
34
import { useTranslations } from "next-intl";
5+
import { styled } from "next-yak";
46
import Button from "@/components/Button";
57
import { useAuthUser } from "@/hooks/useAuthUser";
68
import type { LinkButtonProps } from "@/components/Button";
@@ -13,16 +15,69 @@ type AccountButtonProps = Omit<
1315
export default function AccountButton({ ...props }: AccountButtonProps) {
1416
const t = useTranslations("Actions");
1517
const tCommon = useTranslations("Common");
16-
const { user, profileFirstName } = useAuthUser({ includeProfile: true });
18+
const { user, profileFirstName, isLoading } = useAuthUser({
19+
includeProfile: true,
20+
});
21+
const [isReadyToShow, setIsReadyToShow] = useState(false);
1722
const label = profileFirstName?.trim() || tCommon("account");
1823

24+
useEffect(() => {
25+
if (isLoading) {
26+
setIsReadyToShow(false);
27+
return;
28+
}
29+
30+
const animationFrame = window.requestAnimationFrame(() => {
31+
setIsReadyToShow(true);
32+
});
33+
34+
return () => {
35+
window.cancelAnimationFrame(animationFrame);
36+
};
37+
}, [isLoading]);
38+
39+
if (isLoading) {
40+
return null;
41+
}
42+
43+
const hiddenButtonProps = !isReadyToShow
44+
? ({
45+
"aria-hidden": true,
46+
tabIndex: -1,
47+
} satisfies Pick<LinkButtonProps, "aria-hidden" | "tabIndex">)
48+
: {};
49+
1950
return user ? (
20-
<Button href="/profile" variant="secondary" {...props}>
51+
<FadingAccountButton
52+
href="/profile"
53+
variant="secondary"
54+
{...props}
55+
{...hiddenButtonProps}
56+
$visible={isReadyToShow}
57+
data-testid="account-button-profile"
58+
>
2159
{label}
22-
</Button>
60+
</FadingAccountButton>
2361
) : (
24-
<Button href="/sign-in" variant="secondary" {...props}>
62+
<FadingAccountButton
63+
href="/sign-in"
64+
variant="secondary"
65+
{...props}
66+
{...hiddenButtonProps}
67+
$visible={isReadyToShow}
68+
data-testid="account-button-sign-in"
69+
>
2570
{t("signIn")}
26-
</Button>
71+
</FadingAccountButton>
2772
);
2873
}
74+
75+
const FadingAccountButton = styled(Button)<{ $visible: boolean }>`
76+
opacity: ${({ $visible }) => ($visible ? 1 : 0)};
77+
pointer-events: ${({ $visible }) => ($visible ? "auto" : "none")};
78+
transition: opacity 160ms ease-out;
79+
80+
@media (prefers-reduced-motion: reduce) {
81+
transition: none;
82+
}
83+
`;

src/components/JsonLd.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { serializeJsonLd } from "@/utils/jsonLd";
2+
13
type JsonLdProps = {
24
data: unknown;
35
};
@@ -7,7 +9,7 @@ export default function JsonLd({ data }: JsonLdProps) {
79
<script
810
type="application/ld+json"
911
dangerouslySetInnerHTML={{
10-
__html: JSON.stringify(data).replace(/</g, "\\u003c"),
12+
__html: serializeJsonLd(data),
1113
}}
1214
/>
1315
);

src/features/map/lib/mapUtils.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,9 +89,9 @@ export function wrapLongitude(lng: number): number {
8989
// direction). This lets us fetch a slightly padded area so that small pans
9090
// reuse already-loaded pins without hitting the network again.
9191
//
92-
// Returns 1 or 2 boxes. Two are returned when the padded viewport crosses
93-
// the antimeridian (e.g. Fiji, NZ → Alaska), so the caller can fetch both
94-
// halves and merge the results.
92+
// Returns one or more boxes. Two are returned when the padded viewport crosses
93+
// the antimeridian (e.g. Fiji, NZ → Alaska), and global viewports are split
94+
// into geography-safe slices. Callers should fetch each box and merge results.
9595
export function padBounds(bounds: LngLatBounds, factor = 0.3): BoundingBox[] {
9696
const sw = bounds.getSouthWest();
9797
const ne = bounds.getNorthEast();

src/utils/jsonLd.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import test from "node:test";
2+
import assert from "node:assert/strict";
3+
4+
import { serializeJsonLd } from "./jsonLd.ts";
5+
6+
test("JSON-LD serialization falls back safely for undefined data", () => {
7+
assert.equal(serializeJsonLd(undefined), "null");
8+
});
9+
10+
test("JSON-LD serialization falls back safely for unsupported data", () => {
11+
const circular: { self?: unknown } = {};
12+
circular.self = circular;
13+
14+
assert.equal(serializeJsonLd(1n), "null");
15+
assert.equal(serializeJsonLd(circular), "null");
16+
});
17+
18+
test("JSON-LD serialization escapes HTML tag openings", () => {
19+
assert.equal(
20+
serializeJsonLd({ name: "<script>alert('x')</script>" }),
21+
`{"name":"\\u003cscript>alert('x')\\u003c/script>"}`
22+
);
23+
});

src/utils/jsonLd.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export function serializeJsonLd(data: unknown) {
2+
let json = "null";
3+
4+
try {
5+
json = JSON.stringify(data ?? null) ?? "null";
6+
} catch {
7+
json = "null";
8+
}
9+
10+
return json.replace(/</g, "\\u003c");
11+
}

src/utils/listingUtils.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,3 +115,18 @@ test("anonymous residential listing JSON-LD omits structured location details",
115115
assert.equal(jsonLd.about.address, undefined);
116116
assert.equal(jsonLd.about.geo, undefined);
117117
});
118+
119+
test("anonymous listing JSON-LD treats missing listing types as sensitive", () => {
120+
const unknownTypeListing = {
121+
...communityListing,
122+
type: null,
123+
slug: "unknown-type-listing",
124+
} satisfies Listing;
125+
126+
const jsonLd = generateListingJsonLd(unknownTypeListing, null);
127+
128+
assert.ok(jsonLd);
129+
assert.equal(jsonLd.about.address, undefined);
130+
assert.equal(jsonLd.about.geo, undefined);
131+
assert.equal(jsonLd.about.image, undefined);
132+
});

src/utils/listingUtils.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -308,8 +308,13 @@ export function generateListingJsonLd(
308308
`/listings/${encodeURIComponent(listing.slug)}`,
309309
siteConfig.url
310310
).toString();
311-
const structuredDataImage = getListingStructuredDataImage(listing, user);
312-
const canIncludeStructuredLocation = listingType !== "residential" || !!user;
311+
const canIncludePublicStructuredDetails =
312+
listingType === "business" || listingType === "community";
313+
const canIncludeStructuredLocation =
314+
canIncludePublicStructuredDetails || !!user;
315+
const structuredDataImage = canIncludePublicStructuredDetails
316+
? getListingStructuredDataImage(listing, user)
317+
: null;
313318
const address = {
314319
"@type": "PostalAddress",
315320
...(listing.area_name ? { addressLocality: listing.area_name } : {}),

0 commit comments

Comments
 (0)