Skip to content

Commit 37ab6a5

Browse files
committed
feat: Add ProductSeo component for improved SEO
Introduces a new ProductSeo component to handle SEO metadata and structured data for product pages. Refactors product/[id].vue to use ProductSeo, removing inline SEO logic for better maintainability and consistency.
1 parent 28c2cfe commit 37ab6a5

File tree

2 files changed

+90
-50
lines changed

2 files changed

+90
-50
lines changed

app/components/ProductSeo.vue

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<script setup lang="ts">
2+
type ProductInfo = {
3+
name: string;
4+
sku?: string;
5+
shortDescription?: string;
6+
description?: string;
7+
image?: { sourceUrl?: string };
8+
};
9+
10+
const props = defineProps({
11+
info: { type: Object as PropType<ProductInfo>, required: true },
12+
});
13+
14+
const stripHtml = (html?: string) =>
15+
(html ?? '')
16+
.replace(/<[^>]+>/g, ' ')
17+
.replace(/\s+/g, ' ')
18+
.trim();
19+
20+
const { site } = useAppConfig() as any;
21+
const siteName: string = site?.name || 'NuxtCommerce';
22+
23+
const route = useRoute();
24+
const url = useRequestURL();
25+
const { locales, locale } = useI18n();
26+
const localePath = useLocalePath();
27+
28+
const canonical = `${url.origin}${url.pathname}`;
29+
30+
const img = useImage();
31+
const baseImage: string = props.info.image?.sourceUrl || '/images/placeholder.jpg';
32+
const ogSrc = img.getSizes(baseImage, { width: 1200, height: 630 }).src;
33+
const twSrc = img.getSizes(baseImage, { width: 1600, height: 900 }).src;
34+
const absolutize = (u?: string) => (!u ? '' : u.startsWith('http') ? u : `${url.origin}${u}`);
35+
const ogImage = absolutize(ogSrc);
36+
const twitterImage = absolutize(twSrc);
37+
38+
const rawDescription = props.info.shortDescription && stripHtml(props.info.shortDescription) ? stripHtml(props.info.shortDescription) : stripHtml(props.info.description);
39+
const description = rawDescription?.slice(0, 160) || '';
40+
41+
const alternates = computed(() => {
42+
const items: Array<{ href: string; hreflang: string }> = [];
43+
const codes = (locales.value as any[]).map(l => ({ code: l.code, iso: l.iso }));
44+
for (const l of codes) {
45+
const localizedPath = localePath(route.fullPath, l.code);
46+
items.push({ href: `${url.origin}${localizedPath}`, hreflang: l.iso || l.code });
47+
}
48+
items.push({ href: `${url.origin}${localePath(route.fullPath, locale.value)}`, hreflang: 'x-default' });
49+
return items;
50+
});
51+
52+
const productSchema = computed(() => {
53+
const images = [ogImage].filter(Boolean);
54+
return {
55+
'@context': 'https://schema.org',
56+
'@type': 'Product',
57+
name: props.info.name,
58+
description,
59+
image: images,
60+
sku: props.info.sku || undefined,
61+
brand: { '@type': 'Brand', name: siteName },
62+
};
63+
});
64+
65+
useSeoMeta({
66+
title: props.info.name,
67+
description,
68+
ogTitle: props.info.name,
69+
ogDescription: description,
70+
ogType: 'article',
71+
ogImage,
72+
ogUrl: canonical,
73+
ogSiteName: siteName,
74+
twitterTitle: props.info.name,
75+
twitterDescription: description,
76+
twitterCard: 'summary_large_image',
77+
twitterImage,
78+
});
79+
80+
useHead({
81+
htmlAttrs: { lang: locale.value },
82+
link: [{ rel: 'canonical', href: canonical }, ...alternates.value.map(a => ({ rel: 'alternate', href: a.href, hreflang: a.hreflang }))],
83+
script: [{ type: 'application/ld+json', innerHTML: JSON.stringify(productSchema.value) }],
84+
});
85+
</script>
86+
87+
<template>
88+
<slot />
89+
</template>

app/pages/product/[id].vue

Lines changed: 1 addition & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -58,60 +58,11 @@ const calculateDiscountPercentage = computed(() => {
5858
return Math.round(((salePriceValue - regularPriceValue) / regularPriceValue) * 100);
5959
});
6060
61-
const { name } = useAppConfig();
62-
const url = useRequestURL();
63-
const canonical = url.origin + url.pathname;
64-
const image = computed(() => product.value?.image?.sourceUrl);
65-
const plainDescription = computed(() => {
66-
const raw = product.value?.description?.replace(/<[^>]+>/g, '');
67-
return raw ? raw.slice(0, 160) : '';
68-
});
69-
70-
useHead(() => {
71-
const title = product.value?.name || name;
72-
const description = plainDescription.value;
73-
const img = image.value;
74-
const keywords = [product.value?.name, product.value?.allPaStyle?.nodes?.[0]?.name, name].filter(Boolean).join(', ');
75-
76-
return {
77-
title,
78-
ogTitle: title,
79-
description,
80-
ogDescription: description,
81-
ogImage: img,
82-
ogUrl: canonical,
83-
canonical,
84-
ogType: 'product',
85-
twitterTitle: title,
86-
twitterDescription: description,
87-
twitterImage: img,
88-
keywords,
89-
};
90-
});
91-
92-
const productSchema = computed(() => ({
93-
'@context': 'https://schema.org',
94-
'@type': 'Product',
95-
name: product.value?.name,
96-
description: plainDescription.value,
97-
image: image.value ? [image.value] : [],
98-
sku: product.value?.sku,
99-
brand: { '@type': 'Brand', name: name },
100-
}));
101-
102-
useHead(() => ({
103-
script: [
104-
{
105-
type: 'application/ld+json',
106-
children: JSON.stringify(productSchema.value),
107-
},
108-
],
109-
}));
110-
11161
const { handleAddToCart, addToCartButtonStatus } = useCart();
11262
</script>
11363
11464
<template>
65+
<ProductSeo v-if="product?.name" :info="product" />
11566
<ProductSkeleton v-if="!product.name" />
11667
<div v-else class="justify-center flex flex-col lg:flex-row lg:mx-5">
11768
<ButtonBack />

0 commit comments

Comments
 (0)