Skip to content

Commit d3b0ebb

Browse files
committed
fix visibility metadata
1 parent 698a56c commit d3b0ebb

23 files changed

Lines changed: 480 additions & 167 deletions

File tree

e2e/chat.spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ test("chat loads the seeded thread and composer for a signed-in donor", async ({
3333
});
3434

3535
await expect(page).toHaveURL(new RegExp(`/chats/${SEEDED_THREAD_ID}$`));
36+
await expect(page).toHaveTitle("Avery · Chats · Peels");
3637
await expect(page.getByTestId("chat-window")).toBeVisible();
3738
await expect(page.getByTestId("chat-message-list")).toContainText(
3839
"Hey Avery, do you take coffee grounds from a small home espresso machine?"
@@ -50,6 +51,7 @@ test("chat loads the seeded thread and composer for a signed-in donor", async ({
5051
await expect(page).toHaveURL(
5152
new RegExp(`/chats/${SECOND_SEEDED_THREAD_ID}$`)
5253
);
54+
await expect(page).toHaveTitle("Morgan · Chats · Peels");
5355
await expect(page.getByTestId("thread-list")).toHaveAttribute(
5456
"data-persist-check",
5557
"true"
@@ -63,6 +65,7 @@ test("chat loads the seeded thread and composer for a signed-in donor", async ({
6365

6466
await page.getByTestId(`thread-preview-${SEEDED_THREAD_ID}`).click();
6567
await expect(page).toHaveURL(new RegExp(`/chats/${SEEDED_THREAD_ID}$`));
68+
await expect(page).toHaveTitle("Avery · Chats · Peels");
6669
await expect(page.getByTestId("chat-message-list")).toContainText(
6770
"Yes, absolutely. Small sealed containers are perfect."
6871
);

e2e/seo.spec.ts

Lines changed: 136 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,12 @@
11
import { expect, test } from "@playwright/test";
2+
import { DONOR_EMAIL, SEEDED_THREAD_ID, signIn } from "./helpers";
23

34
const LISTING_PATH = "/listings/demo-marrickville-compost";
45
const MAP_LISTING_PATH = "/map?listing=demo-marrickville-compost";
56
const RESIDENTIAL_LISTING_PATH = "/listings/demo-newtown-worm-farm";
7+
const SITE_URL = "https://www.peels.app";
8+
const DEFAULT_OG_IMAGE_PATTERN =
9+
/^https:\/\/www\.peels\.app\/opengraph-image\.jpg/;
610

711
async function getMetaDescription(page: import("@playwright/test").Page) {
812
return page.locator('head meta[name="description"]').getAttribute("content");
@@ -12,6 +16,35 @@ async function getListingJsonLdScripts(page: import("@playwright/test").Page) {
1216
return page.locator('script[type="application/ld+json"]').allTextContents();
1317
}
1418

19+
async function expectSharedSocialMetadata(
20+
page: import("@playwright/test").Page,
21+
canonicalPath: string
22+
) {
23+
const canonicalUrl =
24+
canonicalPath === "/" ? SITE_URL : `${SITE_URL}${canonicalPath}`;
25+
26+
await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute(
27+
"href",
28+
canonicalUrl
29+
);
30+
await expect(page.locator('head meta[property="og:url"]')).toHaveAttribute(
31+
"content",
32+
canonicalUrl
33+
);
34+
await expect(page.locator('head meta[property="og:image"]')).toHaveAttribute(
35+
"content",
36+
DEFAULT_OG_IMAGE_PATTERN
37+
);
38+
await expect(page.locator('head meta[name="twitter:card"]')).toHaveAttribute(
39+
"content",
40+
"summary_large_image"
41+
);
42+
await expect(page.locator('head meta[name="twitter:image"]')).toHaveAttribute(
43+
"content",
44+
DEFAULT_OG_IMAGE_PATTERN
45+
);
46+
}
47+
1548
type ListingJsonLd = {
1649
"@context"?: unknown;
1750
"@type"?: unknown;
@@ -57,25 +90,73 @@ test("homepage emits object-shaped site JSON-LD", async ({ page }) => {
5790
await page.goto("/", { waitUntil: "domcontentloaded" });
5891

5992
const jsonLdScripts = await getListingJsonLdScripts(page);
60-
const siteJsonLd = parseJsonLdScripts(jsonLdScripts).find((data) =>
61-
Array.isArray(data["@graph"])
93+
const siteJsonLd = parseJsonLdScripts(jsonLdScripts);
94+
const organizationJsonLd = siteJsonLd.find(
95+
(data) => data["@type"] === "Organization"
6296
);
97+
const websiteJsonLd = siteJsonLd.find((data) => data["@type"] === "WebSite");
6398

64-
expect(siteJsonLd).toEqual(
99+
expect(organizationJsonLd).toEqual(
65100
expect.objectContaining({
66101
"@context": "https://schema.org",
67-
"@graph": expect.arrayContaining([
68-
expect.objectContaining({
69-
"@type": "Organization",
70-
name: "Peels",
71-
}),
72-
expect.objectContaining({
73-
"@type": "WebSite",
74-
name: "Peels",
75-
}),
76-
]),
102+
"@type": "Organization",
103+
name: "Peels",
77104
})
78105
);
106+
expect(websiteJsonLd).toEqual(
107+
expect.objectContaining({
108+
"@context": "https://schema.org",
109+
"@type": "WebSite",
110+
name: "Peels",
111+
})
112+
);
113+
});
114+
115+
test("homepage exposes canonical social metadata and one primary H1", async ({
116+
page,
117+
}) => {
118+
await page.goto("/", { waitUntil: "domcontentloaded" });
119+
120+
await expect(page).toHaveTitle(
121+
"Peels: Find a home for your food scraps, wherever you are"
122+
);
123+
await expectSharedSocialMetadata(page, "/");
124+
await expect(page.getByRole("heading", { level: 1 })).toHaveText([
125+
"Find a home for your food scraps, wherever you are",
126+
]);
127+
128+
const emptyLinks = await page.locator("a").evaluateAll((links) =>
129+
links
130+
.filter((link) => {
131+
const text = link.textContent?.trim();
132+
const ariaLabel = link.getAttribute("aria-label")?.trim();
133+
const imageAlts = Array.from(link.querySelectorAll("img"))
134+
.map((image) => image.getAttribute("alt")?.trim())
135+
.filter(Boolean);
136+
137+
return !text && !ariaLabel && imageAlts.length === 0;
138+
})
139+
.map((link) => link.outerHTML)
140+
);
141+
142+
expect(emptyLinks).toEqual([]);
143+
});
144+
145+
test("public static pages expose canonical social metadata", async ({
146+
page,
147+
}) => {
148+
for (const { path, title } of [
149+
{ path: "/map", title: "Map · Peels" },
150+
{ path: "/newsletter", title: "Newsletter · Peels" },
151+
{ path: "/help", title: "Help · Peels" },
152+
{ path: "/partners", title: "Partners · Peels" },
153+
{ path: "/share", title: "Share · Peels" },
154+
]) {
155+
await page.goto(path, { waitUntil: "domcontentloaded" });
156+
157+
await expect(page).toHaveTitle(title);
158+
await expectSharedSocialMetadata(page, path);
159+
}
79160
});
80161

81162
test("homepage emits summary FAQPage JSON-LD", async ({ page }) => {
@@ -104,11 +185,8 @@ test("public listing pages expose crawlable listing metadata", async ({
104185
}) => {
105186
await page.goto(LISTING_PATH, { waitUntil: "domcontentloaded" });
106187

107-
await expect(page).toHaveTitle("Marrickville Neighbourhood Compost");
108-
await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute(
109-
"href",
110-
/\/listings\/demo-marrickville-compost$/
111-
);
188+
await expect(page).toHaveTitle("Marrickville Neighbourhood Compost · Peels");
189+
await expectSharedSocialMetadata(page, LISTING_PATH);
112190

113191
const description = await getMetaDescription(page);
114192
expect(description).toContain(
@@ -148,11 +226,8 @@ test("anonymous residential listing pages expose an indexable private-host tease
148226
}) => {
149227
await page.goto(RESIDENTIAL_LISTING_PATH, { waitUntil: "domcontentloaded" });
150228

151-
await expect(page).toHaveTitle("Private Host");
152-
await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute(
153-
"href",
154-
/\/listings\/demo-newtown-worm-farm$/
155-
);
229+
await expect(page).toHaveTitle("Private Host · Peels");
230+
await expectSharedSocialMetadata(page, RESIDENTIAL_LISTING_PATH);
156231
await expect(page.locator('head meta[name="robots"]')).toHaveCount(0);
157232

158233
const description = await getMetaDescription(page);
@@ -213,7 +288,9 @@ test("public listing pages localise Spanish SEO metadata", async ({
213288
await page.goto(LISTING_PATH, { waitUntil: "domcontentloaded" });
214289

215290
await expect(page.locator("html")).toHaveAttribute("lang", "es");
216-
await expect(page).toHaveTitle("Marrickville Neighbourhood Compost");
291+
await expect(page).toHaveTitle(
292+
"Marrickville Neighbourhood Compost · Peels"
293+
);
217294
await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute(
218295
"href",
219296
/\/listings\/demo-marrickville-compost$/
@@ -269,7 +346,7 @@ test("anonymous residential listing pages localise the private-host teaser", asy
269346
});
270347

271348
await expect(page.locator("html")).toHaveAttribute("lang", "es");
272-
await expect(page).toHaveTitle("Anfitrión privado");
349+
await expect(page).toHaveTitle("Anfitrión privado · Peels");
273350
await expect(
274351
page.getByRole("heading", { name: "Anfitrión privado" })
275352
).toBeVisible();
@@ -313,10 +390,7 @@ test("map listing URLs canonicalise to the static listing sibling", async ({
313390
}) => {
314391
await page.goto(MAP_LISTING_PATH, { waitUntil: "domcontentloaded" });
315392

316-
await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute(
317-
"href",
318-
/\/listings\/demo-marrickville-compost$/
319-
);
393+
await expectSharedSocialMetadata(page, LISTING_PATH);
320394
});
321395

322396
test("map listing URLs localise metadata without changing canonical", async ({
@@ -331,10 +405,7 @@ test("map listing URLs localise metadata without changing canonical", async ({
331405
try {
332406
await page.goto(MAP_LISTING_PATH, { waitUntil: "domcontentloaded" });
333407

334-
await expect(page.locator('head link[rel="canonical"]')).toHaveAttribute(
335-
"href",
336-
/\/listings\/demo-marrickville-compost$/
337-
);
408+
await expectSharedSocialMetadata(page, LISTING_PATH);
338409

339410
const description = await getMetaDescription(page);
340411
expect(description).toContain(
@@ -384,6 +455,7 @@ test("auth utility pages are noindex and omitted from the sitemap", async ({
384455
"content",
385456
/noindex,\s*follow/
386457
);
458+
await expectSharedSocialMetadata(page, "/sign-in");
387459

388460
const sitemap = await request.get("/sitemap.xml");
389461
expect(sitemap.ok()).toBeTruthy();
@@ -394,6 +466,37 @@ test("auth utility pages are noindex and omitted from the sitemap", async ({
394466
expect(sitemapXml).toContain("/listings/demo-marrickville-compost");
395467
});
396468

469+
test("signed-in private pages keep noindex metadata and route-specific titles", async ({
470+
page,
471+
}) => {
472+
await signIn(page, { email: DONOR_EMAIL, redirectTo: "/profile" });
473+
474+
await expect(page).toHaveTitle("Profile · Peels");
475+
await expect(page.locator('head meta[name="robots"]')).toHaveAttribute(
476+
"content",
477+
/noindex,\s*follow/
478+
);
479+
await expectSharedSocialMetadata(page, "/profile");
480+
481+
await page.goto("/chats", { waitUntil: "domcontentloaded" });
482+
await expect(page).toHaveTitle("Chats · Peels");
483+
await expect(page.locator('head meta[name="robots"]')).toHaveAttribute(
484+
"content",
485+
/noindex,\s*follow/
486+
);
487+
await expectSharedSocialMetadata(page, "/chats");
488+
489+
await page.goto(`/chats/${SEEDED_THREAD_ID}`, {
490+
waitUntil: "domcontentloaded",
491+
});
492+
await expect(page).toHaveTitle("Avery · Chats · Peels");
493+
await expect(page.locator('head meta[name="robots"]')).toHaveAttribute(
494+
"content",
495+
/noindex,\s*follow/
496+
);
497+
await expectSharedSocialMetadata(page, `/chats/${SEEDED_THREAD_ID}`);
498+
});
499+
397500
test("help page emits FAQPage JSON-LD for visible help questions", async ({
398501
page,
399502
}) => {

src/app/(core)/(interact)/(centered)/profile/page.tsx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,21 +20,28 @@ import { getTranslations } from "next-intl/server";
2020
import { siteConfig } from "@/config/site";
2121
import { defaultLocale, normaliseLocale } from "@/i18n/config";
2222
import { redirect } from "next/navigation";
23+
import { createPeelsMetadata, noindexFollowMetadata } from "@/utils/seo";
2324
import type { ListingType } from "@/types/listing";
2425
import {
2526
sharedSectionHeadingStyles,
2627
sharedSurfaceSectionStyles,
2728
} from "@/styles/commonStyles";
2829

2930
export async function generateMetadata() {
30-
const t = await getTranslations("Profile.sections");
31+
const t = await getTranslations("App");
32+
const title = t("profile");
3133

32-
return {
33-
title: t("account"),
34+
return createPeelsMetadata({
35+
...noindexFollowMetadata,
36+
canonicalPath: "/profile",
37+
title,
3438
openGraph: {
35-
title: `${t("account")} · ${siteConfig.name}`,
39+
title: `${title} · ${siteConfig.name}`,
3640
},
37-
};
41+
twitter: {
42+
title: `${title} · ${siteConfig.name}`,
43+
},
44+
});
3845
}
3946

4047
// Keep URL-based feedback in a client leaf so server rendering is driven by auth/data only.

src/app/(core)/(interact)/(stretched)/chats/[threadId]/page.tsx

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,74 @@ import { ChatConversationClient } from "@/components/ChatPageClient";
33
import { getSelectedChatThread, isUuid } from "@/features/chat/chatData";
44
import { redirect } from "next/navigation";
55
import type { Metadata } from "next";
6+
import { siteConfig } from "@/config/site";
7+
import { createPeelsMetadata, noindexFollowMetadata } from "@/utils/seo";
68

79
type ChatThreadPageProps = {
810
params: Promise<{
911
threadId: string;
1012
}>;
1113
};
1214

13-
export const metadata: Metadata = {
14-
title: "Chats",
15-
};
15+
function getChatThreadTitle(
16+
selectedThread: Awaited<ReturnType<typeof getSelectedChatThread>>,
17+
userId: string
18+
) {
19+
if (!selectedThread) return "Chats";
20+
21+
const otherPersonName =
22+
selectedThread.initiator_id === userId
23+
? selectedThread.owner_first_name
24+
: selectedThread.initiator_first_name;
25+
26+
return otherPersonName ? `${otherPersonName} · Chats` : "Chats";
27+
}
28+
29+
export async function generateMetadata({
30+
params,
31+
}: ChatThreadPageProps): Promise<Metadata> {
32+
const { threadId } = await params;
33+
34+
if (!isUuid(threadId)) {
35+
return createPeelsMetadata({
36+
...noindexFollowMetadata,
37+
canonicalPath: "/chats",
38+
title: "Chats",
39+
});
40+
}
41+
42+
const supabase = await createClient();
43+
const {
44+
data: { user },
45+
} = await supabase.auth.getUser();
46+
47+
if (!user) {
48+
return createPeelsMetadata({
49+
...noindexFollowMetadata,
50+
canonicalPath: `/chats/${threadId}`,
51+
title: "Chats",
52+
});
53+
}
54+
55+
const selectedThread = await getSelectedChatThread(
56+
supabase,
57+
user.id,
58+
threadId
59+
);
60+
const title = getChatThreadTitle(selectedThread, user.id);
61+
62+
return createPeelsMetadata({
63+
...noindexFollowMetadata,
64+
canonicalPath: `/chats/${threadId}`,
65+
title,
66+
openGraph: {
67+
title: `${title} · ${siteConfig.name}`,
68+
},
69+
twitter: {
70+
title: `${title} · ${siteConfig.name}`,
71+
},
72+
});
73+
}
1674

1775
export default async function ChatThreadPage({ params }: ChatThreadPageProps) {
1876
const { threadId } = await params;

0 commit comments

Comments
 (0)