Feat: JsonLd product metadata (#2)

* Generates JSON ld structured metadata from a product & appends to head

* Updated IProduct & IVariation interface

* Added IProductResponse & IProductsResponse interfaces

* Fixed sitemap urls having to many protocols
This commit is contained in:
2022-11-28 22:19:32 +01:00
committed by GitHub
parent f3751da335
commit 296cfb80a0
17 changed files with 103 additions and 45 deletions

View File

@@ -15,3 +15,13 @@ export interface IOrderSummaryResponse {
success: boolean; success: boolean;
order: IOrderSummary; order: IOrderSummary;
} }
export interface IProductResponse {
success: boolean
product: IProduct
}
export interface IProductsResponse {
success: boolean
products: Array<IProduct>
}

View File

@@ -1,16 +1,21 @@
import type IProductVariation from './IProductVariation'; export interface IProduct {
export default interface IProduct {
product_no: number; product_no: number;
name: string; name: string;
subtext?: string; subtext?: string;
description?: string; description?: string;
image: string; image: string;
currency: string;
variations?: IProductVariation[];
primary_color?: string; primary_color?: string;
variation_count?: string;
sum_stock?: number;
updated?: Date; updated?: Date;
created?: Date; created?: Date;
variations?: IVariation[];
}
export interface IVariation {
sku_id: number;
price: number;
size: string;
stock: number;
default_price: boolean;
updated?: Date;
created?: Date;
} }

38
src/lib/jsonld/product.ts Normal file
View File

@@ -0,0 +1,38 @@
import type { IProduct } from '../interfaces/IProduct'
function structureProduct(product: IProduct) {
const output = product?.variations?.map(variation => {
return {
'@context': 'https://schema.org/',
'@type': 'Product',
name: `${product.name} - ${variation.size}`,
image: [product.image],
description: product.description,
sku: `${product.product_no}-${variation.sku_id}`,
productID: product.product_no,
mpn: product.product_no,
brand: {
'@type': 'Brand',
name: 'Planetposen'
},
offers: {
'@type': 'Offer',
url: `https://planet.schleppe.cloud/shop/${product.product_no}`,
priceCurrency: 'NOK',
price: variation.price,
itemCondition: 'https://schema.org/NewCondition',
availability: 'https://schema.org/InStock'
}
}
})
return JSON.stringify(output);
}
export default function generateProductJsonLd(product: IProduct): HTMLElement {
const jsonldScript = document.createElement('script');
jsonldScript.setAttribute('type', 'application/ld+json');
jsonldScript.textContent = structureProduct(product);
return jsonldScript;
}

View File

@@ -7,7 +7,7 @@
import VippsHurtigkasse from '$lib/components/VippsHurtigkasse.svelte'; import VippsHurtigkasse from '$lib/components/VippsHurtigkasse.svelte';
import { cart } from '$lib/cartStore'; import { cart } from '$lib/cartStore';
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
function postOrder(event: any) { function postOrder(event: any) {
const formData = new FormData(event.target); const formData = new FormData(event.target);

View File

@@ -3,7 +3,7 @@
import { mockProducts } from '$lib/utils/mock'; import { mockProducts } from '$lib/utils/mock';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
function subTotal(products: Array<IProduct>) { function subTotal(products: Array<IProduct>) {
let total = 0; let total = 0;

View File

@@ -1,5 +1,6 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import type { IProductsResponse } from '$lib/interfaces/ApiResponse';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch }) => { export const load: PageServerLoad = async ({ fetch }) => {
@@ -9,7 +10,7 @@ export const load: PageServerLoad = async ({ fetch }) => {
} }
const res = await fetch(url); const res = await fetch(url);
const products = await res.json(); const products: IProductsResponse = await res.json();
return products; return products;
}; };

View File

@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import ProductTile from './ProductTile.svelte'; import ProductTile from './ProductTile.svelte';
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
export let data: PageData; export let data: PageData;
const products = data.products as Array<IProduct>; const products = data.products as Array<IProduct>;

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
export let product: IProduct; export let product: IProduct;
export let large = false; export let large = false;
@@ -12,14 +12,16 @@
product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e' product?.primary_color === '#231B1D' ? '#f3efeb' : '#37301e'
}`}" }`}"
> >
{#if !large}<h3>{product?.name}</h3>{/if} {#if !large}
<h3>{product?.name}</h3>
{/if}
<div class="{`image-frame ${large ? 'large' : null}`}"> <div class="{`image-frame ${large ? 'large' : null}`}">
<img src="{product?.image}" alt="{product?.name}" /> <img src="{product?.image}" alt="{product?.name}" />
</div> </div>
{#if !large} {#if !large}
<p class="subtext">{product?.subtext}</p> <p class="subtext">{product?.subtext}</p>
{/if} {/if}
</div> </div>
</a> </a>

View File

@@ -1,5 +1,6 @@
import { dev } from '$app/environment'; import { dev } from '$app/environment';
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import type { IProductResponse } from '$lib/interfaces/ApiResponse';
import type { PageServerLoad } from './$types'; import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ fetch, params }) => { export const load: PageServerLoad = async ({ fetch, params }) => {
@@ -11,6 +12,6 @@ export const load: PageServerLoad = async ({ fetch, params }) => {
} }
const res = await fetch(url); const res = await fetch(url);
const product = await res.json(); const product: IProductResponse = await res.json();
return product; return product;
}; };

View File

@@ -1,12 +1,13 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'
import type { PageData } from './$types'; import type { PageData } from './$types';
import ProductTile from '../ProductTile.svelte'; import ProductTile from '../ProductTile.svelte';
import ProductVariationSelect from '$lib/components/ProductVariationSelect.svelte'; import ProductVariationSelect from '$lib/components/ProductVariationSelect.svelte';
import QuantitySelect from '$lib/components/QuantitySelect.svelte'; import QuantitySelect from '$lib/components/QuantitySelect.svelte';
import SizesSection from './SizesSection.svelte'; import SizesSection from './SizesSection.svelte';
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct, IVariation } from '$lib/interfaces/IProduct';
import type IProductVariation from '$lib/interfaces/IProductVariation';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import generateProductJsonLd from '$lib/jsonld/product';
export let data: PageData; export let data: PageData;
const product = data.product as IProduct; const product = data.product as IProduct;
@@ -28,12 +29,19 @@
cooldownInputs = false; cooldownInputs = false;
} }
function defaultVariation() {
return product.variations?.find(variation => variation.default_price)
}
let jsonLd: HTMLElement;
let cooldownInputs = false; let cooldownInputs = false;
let quantity = 1; let quantity = 1;
let selectedVariation: IProductVariation; let selectedVariation: IVariation | undefined = defaultVariation()
$: addProductButtonText = cooldownInputs $: addProductButtonText = cooldownInputs
? `${quantity} produkt${quantity > 1 ? 'er' : ''} lagt til` ? `${quantity} produkt${quantity > 1 ? 'er' : ''} lagt til`
: `Legg til ${quantity} i handlekurven`; : `Legg til ${quantity} i handlekurven`;
onMount(() => document.head.appendChild(generateProductJsonLd(product)))
</script> </script>
<div class="product-container"> <div class="product-container">

View File

@@ -41,18 +41,16 @@ export async function GET() {
return new Response(body, { headers }); return new Response(body, { headers });
} }
function buildSitemapUrl(address: string, modified: string, frequency: string) { function buildSitemapUrl(path: string, modified: string, frequency: string) {
return `<url> return `<url>
<loc>https://${address}</loc> <loc>https://${domain}${path}</loc>
<lastmod>${modified}</lastmod> <lastmod>${modified}</lastmod>
<changefreq>${frequency}</changefreq> <changefreq>${frequency}</changefreq>
</url>`; </url>`;
} }
function sitemapPages(): string { function sitemapPages(): string {
return pages return pages.map((page) => buildSitemapUrl(`/${page.name}`, page.modified, 'yearly')).join('\n');
.map((page) => buildSitemapUrl(`https://${domain}/${page.name}`, page.modified, 'yearly'))
.join('\n');
} }
async function sitemapShopPages(): Promise<string> { async function sitemapShopPages(): Promise<string> {
@@ -66,11 +64,7 @@ async function sitemapShopPages(): Promise<string> {
return products?.products return products?.products
?.map((product) => ?.map((product) =>
buildSitemapUrl( buildSitemapUrl(`/shop/${product.product_no}`, String(product.updated), 'daily')
`https://${domain}/shop/${product.product_no}`,
String(product.updated),
'daily'
)
) )
.join('\n'); .join('\n');
} }

View File

@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import ProductList from './WarehouseProductList.svelte'; import ProductList from './WarehouseProductList.svelte';
import type IProduct from '$lib/interfaces/IProduct';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import type { IProduct } from '$lib/interfaces/IProduct';
import type { PageData } from './$types'; import type { PageData } from './$types';
export let data: PageData; export let data: PageData;

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
export let products: Array<IProduct>; export let products: Array<IProduct>;
@@ -29,10 +29,10 @@
<td class="name-and-price"> <td class="name-and-price">
<p><a href="/warehouse/{product.product_no}">{product.name}</a></p> <p><a href="/warehouse/{product.product_no}">{product.name}</a></p>
<p>{product.variation_count} variation(s)</p> <p>{product?.variations?.length} variation(s)</p>
</td> </td>
<td class="stock-column">{product.sum_stock}</td> <td class="stock-column">{product?.sum_stock}</td>
<td class="date-column" <td class="date-column"
>{new Intl.DateTimeFormat('nb-NO', { dateStyle: 'short', timeStyle: 'short' }).format( >{new Intl.DateTimeFormat('nb-NO', { dateStyle: 'short', timeStyle: 'short' }).format(

View File

@@ -3,7 +3,7 @@
import DetailsSection from './DetailsSection.svelte'; import DetailsSection from './DetailsSection.svelte';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
export let data: PageServerData; export let data: PageServerData;
const product = data.product as IProduct; const product = data.product as IProduct;
@@ -20,7 +20,7 @@
<img src="{product.image}" alt="Product" /> <img src="{product.image}" alt="Product" />
<div class="name-and-price"> <div class="name-and-price">
<p>{product.name}</p> <p>{product.name}</p>
<p>NOK {product.price}</p> <p>NOK {product?.price}</p>
</div> </div>
<div class="edit-button"> <div class="edit-button">

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
export let product: IProduct; export let product: IProduct;
export let edit: boolean; export let edit: boolean;

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct, IVariation } from '$lib/interfaces/IProduct';
import type IProductVariation from '$lib/interfaces/IProductVariation';
import Button from '$lib/components/Button.svelte'; import Button from '$lib/components/Button.svelte';
import Badge from '$lib/components/Badge.svelte'; import Badge from '$lib/components/Badge.svelte';
import BadgeType from '$lib/interfaces/BadgeType'; import BadgeType from '$lib/interfaces/BadgeType';
@@ -10,7 +9,7 @@
let editingVariationIndex = -1; let editingVariationIndex = -1;
interface ISkuResponse { interface ISkuResponse {
skus: IProductVariation[]; skus: IVariation[];
success: boolean; success: boolean;
} }
@@ -19,7 +18,7 @@
product.variations = response?.skus; product.variations = response?.skus;
}; };
function setDefault(variation: IProductVariation) { function setDefault(variation: IVariation) {
if (!product.variations) return; if (!product.variations) return;
let url = `/api/product/${product.product_no}/sku/${variation.sku_id}/default_price`; let url = `/api/product/${product.product_no}/sku/${variation.sku_id}/default_price`;
@@ -60,7 +59,7 @@
.then(() => (editingVariationIndex = product.variations.length - 1)); .then(() => (editingVariationIndex = product.variations.length - 1));
} }
function saveSkuVariation(variation: IProductVariation) { function saveSkuVariation(variation: IVariation) {
let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`; let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`;
if (window?.location?.href.includes('localhost')) { if (window?.location?.href.includes('localhost')) {
url = 'http://localhost:30010'.concat(url); url = 'http://localhost:30010'.concat(url);
@@ -81,7 +80,7 @@
.then(() => resetEditingIndex()); .then(() => resetEditingIndex());
} }
function deleteVariation(variation: IProductVariation) { function deleteVariation(variation: IVariation) {
console.log('delete it using api', variation); console.log('delete it using api', variation);
let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`; let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`;
if (window?.location?.href.includes('localhost')) { if (window?.location?.href.includes('localhost')) {

View File

@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import Input from '$lib/components/Input.svelte'; import Input from '$lib/components/Input.svelte';
import type IProduct from '$lib/interfaces/IProduct'; import type { IProduct } from '$lib/interfaces/IProduct';
import type { PageServerData } from './$types'; import type { PageServerData } from './$types';
export let data: PageServerData; export let data: PageServerData;
@@ -12,9 +12,9 @@
<div> <div>
<h2>Product attributes</h2> <h2>Product attributes</h2>
<Input label="Name" value="{product.name}" required="{false}" /> <Input label="Name" value="{product.name}" required="{false}" />
<Input label="Description" value="{product.description}" required="{false}" /> <Input label="Description" value="{product.description || ''}" required="{false}" />
<Input label="Subtext" value="{product.subtext}" required="{false}" /> <Input label="Subtext" value="{product.subtext || ''}" required="{false}" />
<Input label="Color" value="{product.primary_color}" required="{false}" /> <Input label="Color" value="{product.primary_color || ''}" required="{false}" />
</div> </div>
<div> <div>