mirror of
				https://github.com/KevinMidboe/planetposen-frontend.git
				synced 2025-10-29 13:10:12 +00:00 
			
		
		
		
	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:
		| @@ -15,3 +15,13 @@ export interface IOrderSummaryResponse { | ||||
|   success: boolean; | ||||
|   order: IOrderSummary; | ||||
| } | ||||
|  | ||||
| export interface IProductResponse { | ||||
|   success: boolean | ||||
|   product: IProduct | ||||
| } | ||||
|  | ||||
| export interface IProductsResponse { | ||||
|   success: boolean | ||||
|   products: Array<IProduct> | ||||
| } | ||||
| @@ -1,16 +1,21 @@ | ||||
| import type IProductVariation from './IProductVariation'; | ||||
|  | ||||
| export default interface IProduct { | ||||
| export interface IProduct { | ||||
|   product_no: number; | ||||
|   name: string; | ||||
|   subtext?: string; | ||||
|   description?: string; | ||||
|   image: string; | ||||
|   currency: string; | ||||
|   variations?: IProductVariation[]; | ||||
|   primary_color?: string; | ||||
|   variation_count?: string; | ||||
|   sum_stock?: number; | ||||
|   updated?: 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
									
								
							
							
						
						
									
										38
									
								
								src/lib/jsonld/product.ts
									
									
									
									
									
										Normal 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; | ||||
| } | ||||
| @@ -7,7 +7,7 @@ | ||||
|   import VippsHurtigkasse from '$lib/components/VippsHurtigkasse.svelte'; | ||||
|   import { cart } from '$lib/cartStore'; | ||||
|  | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   function postOrder(event: any) { | ||||
|     const formData = new FormData(event.target); | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|  | ||||
|   import { mockProducts } from '$lib/utils/mock'; | ||||
|   import type { PageServerData } from './$types'; | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   function subTotal(products: Array<IProduct>) { | ||||
|     let total = 0; | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { dev } from '$app/environment'; | ||||
| import { env } from '$env/dynamic/private'; | ||||
| import type { IProductsResponse } from '$lib/interfaces/ApiResponse'; | ||||
| import type { PageServerLoad } from './$types'; | ||||
|  | ||||
| export const load: PageServerLoad = async ({ fetch }) => { | ||||
| @@ -9,7 +10,7 @@ export const load: PageServerLoad = async ({ fetch }) => { | ||||
|   } | ||||
|  | ||||
|   const res = await fetch(url); | ||||
|   const products = await res.json(); | ||||
|   const products: IProductsResponse = await res.json(); | ||||
|  | ||||
|   return products; | ||||
| }; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| <script lang="ts"> | ||||
|   import type { PageData } from './$types'; | ||||
|   import ProductTile from './ProductTile.svelte'; | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   export let data: PageData; | ||||
|   const products = data.products as Array<IProduct>; | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <script lang="ts"> | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   export let product: IProduct; | ||||
|   export let large = false; | ||||
| @@ -12,14 +12,16 @@ | ||||
|       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}`}"> | ||||
|       <img src="{product?.image}" alt="{product?.name}" /> | ||||
|     </div> | ||||
|  | ||||
|     {#if !large} | ||||
|       <p class="subtext">{product?.subtext}</p> | ||||
|     <p class="subtext">{product?.subtext}</p> | ||||
|     {/if} | ||||
|   </div> | ||||
| </a> | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| import { dev } from '$app/environment'; | ||||
| import { env } from '$env/dynamic/private'; | ||||
| import type { IProductResponse } from '$lib/interfaces/ApiResponse'; | ||||
| import type { PageServerLoad } from './$types'; | ||||
|  | ||||
| export const load: PageServerLoad = async ({ fetch, params }) => { | ||||
| @@ -11,6 +12,6 @@ export const load: PageServerLoad = async ({ fetch, params }) => { | ||||
|   } | ||||
|  | ||||
|   const res = await fetch(url); | ||||
|   const product = await res.json(); | ||||
|   const product: IProductResponse = await res.json(); | ||||
|   return product; | ||||
| }; | ||||
|   | ||||
| @@ -1,12 +1,13 @@ | ||||
| <script lang="ts"> | ||||
|   import { onMount } from 'svelte' | ||||
|   import type { PageData } from './$types'; | ||||
|   import ProductTile from '../ProductTile.svelte'; | ||||
|   import ProductVariationSelect from '$lib/components/ProductVariationSelect.svelte'; | ||||
|   import QuantitySelect from '$lib/components/QuantitySelect.svelte'; | ||||
|   import SizesSection from './SizesSection.svelte'; | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type IProductVariation from '$lib/interfaces/IProductVariation'; | ||||
|   import type { IProduct, IVariation } from '$lib/interfaces/IProduct'; | ||||
|   import Button from '$lib/components/Button.svelte'; | ||||
|   import generateProductJsonLd from '$lib/jsonld/product'; | ||||
|  | ||||
|   export let data: PageData; | ||||
|   const product = data.product as IProduct; | ||||
| @@ -28,12 +29,19 @@ | ||||
|     cooldownInputs = false; | ||||
|   } | ||||
|  | ||||
|   function defaultVariation() { | ||||
|     return product.variations?.find(variation => variation.default_price) | ||||
|   } | ||||
|  | ||||
|   let jsonLd: HTMLElement; | ||||
|   let cooldownInputs = false; | ||||
|   let quantity = 1; | ||||
|   let selectedVariation: IProductVariation; | ||||
|   let selectedVariation: IVariation | undefined = defaultVariation() | ||||
|   $: addProductButtonText = cooldownInputs | ||||
|     ? `${quantity} produkt${quantity > 1 ? 'er' : ''} lagt til` | ||||
|     : `Legg til ${quantity} i handlekurven`; | ||||
|  | ||||
|   onMount(() => document.head.appendChild(generateProductJsonLd(product))) | ||||
| </script> | ||||
|  | ||||
| <div class="product-container"> | ||||
|   | ||||
| @@ -41,18 +41,16 @@ export async function GET() { | ||||
|   return new Response(body, { headers }); | ||||
| } | ||||
|  | ||||
| function buildSitemapUrl(address: string, modified: string, frequency: string) { | ||||
| function buildSitemapUrl(path: string, modified: string, frequency: string) { | ||||
|   return `<url> | ||||
|  <loc>https://${address}</loc> | ||||
|  <loc>https://${domain}${path}</loc> | ||||
|   <lastmod>${modified}</lastmod> | ||||
|   <changefreq>${frequency}</changefreq> | ||||
| </url>`; | ||||
| } | ||||
|  | ||||
| function sitemapPages(): string { | ||||
|   return pages | ||||
|     .map((page) => buildSitemapUrl(`https://${domain}/${page.name}`, page.modified, 'yearly')) | ||||
|     .join('\n'); | ||||
|   return pages.map((page) => buildSitemapUrl(`/${page.name}`, page.modified, 'yearly')).join('\n'); | ||||
| } | ||||
|  | ||||
| async function sitemapShopPages(): Promise<string> { | ||||
| @@ -66,11 +64,7 @@ async function sitemapShopPages(): Promise<string> { | ||||
|  | ||||
|   return products?.products | ||||
|     ?.map((product) => | ||||
|       buildSitemapUrl( | ||||
|         `https://${domain}/shop/${product.product_no}`, | ||||
|         String(product.updated), | ||||
|         'daily' | ||||
|       ) | ||||
|       buildSitemapUrl(`/shop/${product.product_no}`, String(product.updated), 'daily') | ||||
|     ) | ||||
|     .join('\n'); | ||||
| } | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| <script lang="ts"> | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import ProductList from './WarehouseProductList.svelte'; | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import Button from '$lib/components/Button.svelte'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|   import type { PageData } from './$types'; | ||||
|  | ||||
|   export let data: PageData; | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <script lang="ts"> | ||||
|   import { goto } from '$app/navigation'; | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   export let products: Array<IProduct>; | ||||
|  | ||||
| @@ -29,10 +29,10 @@ | ||||
|  | ||||
|         <td class="name-and-price"> | ||||
|           <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 class="stock-column">{product.sum_stock}</td> | ||||
|         <td class="stock-column">{product?.sum_stock}</td> | ||||
|  | ||||
|         <td class="date-column" | ||||
|           >{new Intl.DateTimeFormat('nb-NO', { dateStyle: 'short', timeStyle: 'short' }).format( | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|   import DetailsSection from './DetailsSection.svelte'; | ||||
|   import Button from '$lib/components/Button.svelte'; | ||||
|   import type { PageServerData } from './$types'; | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   export let data: PageServerData; | ||||
|   const product = data.product as IProduct; | ||||
| @@ -20,7 +20,7 @@ | ||||
|   <img src="{product.image}" alt="Product" /> | ||||
|   <div class="name-and-price"> | ||||
|     <p>{product.name}</p> | ||||
|     <p>NOK {product.price}</p> | ||||
|     <p>NOK {product?.price}</p> | ||||
|   </div> | ||||
|  | ||||
|   <div class="edit-button"> | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| <script lang="ts"> | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type { IProduct } from '$lib/interfaces/IProduct'; | ||||
|  | ||||
|   export let product: IProduct; | ||||
|   export let edit: boolean; | ||||
|   | ||||
| @@ -1,6 +1,5 @@ | ||||
| <script lang="ts"> | ||||
|   import type IProduct from '$lib/interfaces/IProduct'; | ||||
|   import type IProductVariation from '$lib/interfaces/IProductVariation'; | ||||
|   import type { IProduct, IVariation } from '$lib/interfaces/IProduct'; | ||||
|   import Button from '$lib/components/Button.svelte'; | ||||
|   import Badge from '$lib/components/Badge.svelte'; | ||||
|   import BadgeType from '$lib/interfaces/BadgeType'; | ||||
| @@ -10,7 +9,7 @@ | ||||
|   let editingVariationIndex = -1; | ||||
|  | ||||
|   interface ISkuResponse { | ||||
|     skus: IProductVariation[]; | ||||
|     skus: IVariation[]; | ||||
|     success: boolean; | ||||
|   } | ||||
|  | ||||
| @@ -19,7 +18,7 @@ | ||||
|     product.variations = response?.skus; | ||||
|   }; | ||||
|  | ||||
|   function setDefault(variation: IProductVariation) { | ||||
|   function setDefault(variation: IVariation) { | ||||
|     if (!product.variations) return; | ||||
|  | ||||
|     let url = `/api/product/${product.product_no}/sku/${variation.sku_id}/default_price`; | ||||
| @@ -60,7 +59,7 @@ | ||||
|       .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}`; | ||||
|     if (window?.location?.href.includes('localhost')) { | ||||
|       url = 'http://localhost:30010'.concat(url); | ||||
| @@ -81,7 +80,7 @@ | ||||
|       .then(() => resetEditingIndex()); | ||||
|   } | ||||
|  | ||||
|   function deleteVariation(variation: IProductVariation) { | ||||
|   function deleteVariation(variation: IVariation) { | ||||
|     console.log('delete it using api', variation); | ||||
|     let url = `/api/product/${product.product_no}/sku/${variation?.sku_id}`; | ||||
|     if (window?.location?.href.includes('localhost')) { | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| <script lang="ts"> | ||||
|   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'; | ||||
|  | ||||
|   export let data: PageServerData; | ||||
| @@ -12,9 +12,9 @@ | ||||
|   <div> | ||||
|     <h2>Product attributes</h2> | ||||
|     <Input label="Name" value="{product.name}" required="{false}" /> | ||||
|     <Input label="Description" value="{product.description}" required="{false}" /> | ||||
|     <Input label="Subtext" value="{product.subtext}" required="{false}" /> | ||||
|     <Input label="Color" value="{product.primary_color}" required="{false}" /> | ||||
|     <Input label="Description" value="{product.description || ''}" required="{false}" /> | ||||
|     <Input label="Subtext" value="{product.subtext || ''}" required="{false}" /> | ||||
|     <Input label="Color" value="{product.primary_color || ''}" required="{false}" /> | ||||
|   </div> | ||||
|  | ||||
|   <div> | ||||
|   | ||||
		Reference in New Issue
	
	Block a user