From 4026facdd271da6d3c1981d57d40e7db03af7222 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Wed, 27 Dec 2023 23:05:33 +0700 Subject: [PATCH] Feature/dynamic pages (#8) * Add get Pages query * Add landing page * Resolve fetched content * Render Dynamic Page * Setup Components Mapping --- .../ProductSlider/ProductSlider.tsx | 3 + .../RenderContent/RenderContent.tsx | 61 +-- .../components/RenderContent/UnknownBlock.tsx | 17 + apps/web/components/RenderContent/types.ts | 22 +- apps/web/components/RichText/RichText.tsx | 21 + apps/web/components/RichText/index.ts | 1 + apps/web/components/blocksConfig.ts | 21 + .../ui/CategoryCard/CategoryCard.tsx | 18 + apps/web/components/ui/Display/Display.tsx | 3 + apps/web/components/ui/Hero/Hero.tsx | 3 + apps/web/hooks/useContent/getPageQuery.ts | 77 ++++ apps/web/hooks/useContent/response.json | 409 ++++++++++++++++++ apps/web/hooks/useContent/types.ts | 173 ++++++-- apps/web/hooks/useContent/useContent.ts | 108 ++++- apps/web/layouts/DefaultLayout.tsx | 13 +- apps/web/package.json | 5 +- apps/web/pages/_app.tsx | 2 +- apps/web/pages/landing/[landing].tsx | 79 ++++ apps/web/sdk/shopify/withShopify.tsx | 55 +++ yarn.lock | 5 + 20 files changed, 996 insertions(+), 100 deletions(-) create mode 100644 apps/web/components/RenderContent/UnknownBlock.tsx create mode 100644 apps/web/components/RichText/RichText.tsx create mode 100644 apps/web/components/RichText/index.ts create mode 100644 apps/web/components/blocksConfig.ts create mode 100644 apps/web/hooks/useContent/getPageQuery.ts create mode 100644 apps/web/hooks/useContent/response.json create mode 100644 apps/web/pages/landing/[landing].tsx create mode 100644 apps/web/sdk/shopify/withShopify.tsx diff --git a/apps/web/components/ProductSlider/ProductSlider.tsx b/apps/web/components/ProductSlider/ProductSlider.tsx index 734fd8b..e90c836 100644 --- a/apps/web/components/ProductSlider/ProductSlider.tsx +++ b/apps/web/components/ProductSlider/ProductSlider.tsx @@ -3,6 +3,7 @@ import { SfScrollable } from '@storefront-ui/react'; import { ProductCard } from '~/components'; import type { ProductSliderProps } from '~/components'; import { useProducts } from '~/hooks'; +import withShopify from '~/sdk/shopify/withShopify'; export function ProductSlider({ className, collection, ...attributes }: ProductSliderProps) { const { products } = useProducts(collection); @@ -36,3 +37,5 @@ export function ProductSlider({ className, collection, ...attributes }: ProductS ); } + +export const ProductSliderBlock = withShopify({ wrapperFn: (v) => v, isDebug: true })(ProductSlider); diff --git a/apps/web/components/RenderContent/RenderContent.tsx b/apps/web/components/RenderContent/RenderContent.tsx index 9c3ad24..aaf9376 100644 --- a/apps/web/components/RenderContent/RenderContent.tsx +++ b/apps/web/components/RenderContent/RenderContent.tsx @@ -1,55 +1,16 @@ -import { Fragment } from 'react'; -import { Page, Hero, Display, Heading, CategoryCard, ProductSlider } from '~/components'; -import type { RenderContentProps } from '~/components'; +import { BlockComponent } from '~/hooks'; +import { getBlockComponent } from '../blocksConfig'; +import { UnknownBlock } from './UnknownBlock'; -export function RenderContent({ content, ...attributes }: RenderContentProps): JSX.Element { +type RenderContentProps = { + contentBlock: BlockComponent; +}; + +export function RenderContent({ contentBlock, ...attributes }: RenderContentProps) { + const BlockComponent = getBlockComponent(contentBlock) || UnknownBlock; return ( -
- {content.map(({ fields }, index) => ( - - {(() => { - switch (fields.component) { - case 'Hero': { - return ( - - ); - } - case 'Heading': { - return ; - } - case 'Card': { - return ; - } - case 'Display': { - return ; - } - case 'ProductSlider': { - return ( - - ); - } - case 'Page': { - return ; - } - default: { - return

component {fields.component} is not registered

; - } - } - })()} -
- ))} +
+
); } diff --git a/apps/web/components/RenderContent/UnknownBlock.tsx b/apps/web/components/RenderContent/UnknownBlock.tsx new file mode 100644 index 0000000..ab5347b --- /dev/null +++ b/apps/web/components/RenderContent/UnknownBlock.tsx @@ -0,0 +1,17 @@ +import { BlockComponent } from '~/hooks'; + +type UnknownBlockProps = { + contentBlock: BlockComponent; +}; + +export const UnknownBlock = ({ contentBlock }: UnknownBlockProps) => { + return ( +
+

Unknown Content Block

+

Type: {contentBlock.contentType}

+

ID: {contentBlock.id}

+ {/*
{JSON.stringify(contentBlock.fields, null, 2)}
*/} +
+
+ ); +}; diff --git a/apps/web/components/RenderContent/types.ts b/apps/web/components/RenderContent/types.ts index 35b0c2d..3e66740 100644 --- a/apps/web/components/RenderContent/types.ts +++ b/apps/web/components/RenderContent/types.ts @@ -1,5 +1,21 @@ -import type { ContentDynamicPage } from '~/hooks'; +/* eslint-disable no-use-before-define */ +type Component = { + __typename: string; + id: string; + type: string; + fields: ContentField[]; +}; + +type ContentField = { + type: string; + key: string; + value: string; + reference?: Component | null; +}; -export type RenderContentProps = { - content: ContentDynamicPage['content']; +type ContentBlock = { + __typename: string; + id: string; + type: string; + fields: ContentField[]; }; diff --git a/apps/web/components/RichText/RichText.tsx b/apps/web/components/RichText/RichText.tsx new file mode 100644 index 0000000..4cedd79 --- /dev/null +++ b/apps/web/components/RichText/RichText.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { BlockComponent } from '~/hooks'; +import withShopify from '~/sdk/shopify/withShopify'; + +type Props = { + contentBlock: BlockComponent; +}; + +export const RichText = ({ contentBlock }: Props) => { + return ( +
+

Rich Text Block

+

Type: {contentBlock.contentType}

+

ID: {contentBlock.id}

+ {/*
{JSON.stringify(contentBlock.fields, null, 2)}
*/} +
+
+ ); +}; + +export const RichTextBlock = withShopify({ wrapperFn: (v) => v, isDebug: true })(RichText); diff --git a/apps/web/components/RichText/index.ts b/apps/web/components/RichText/index.ts new file mode 100644 index 0000000..735dd7d --- /dev/null +++ b/apps/web/components/RichText/index.ts @@ -0,0 +1 @@ +export * from './RichText'; diff --git a/apps/web/components/blocksConfig.ts b/apps/web/components/blocksConfig.ts new file mode 100644 index 0000000..9c682bf --- /dev/null +++ b/apps/web/components/blocksConfig.ts @@ -0,0 +1,21 @@ +import { DisplayBlock, CategoryCardBlock, HeroBlock, ProductSliderBlock } from '~/components'; +import { BlockComponent } from '~/hooks'; +import { RichTextBlock } from './RichText'; + +const contentMap = { + // display: () => DisplayBlock, + collection_card: () => CategoryCardBlock, + // hero: () => HeroBlock, + // richtext_block: () => RichTextBlock, + // product_slider: () => ProductSliderBlock, +}; + +type ContentMapKey = keyof typeof contentMap; + +export const getBlockComponent = (contentBlock: BlockComponent) => { + const getComponent = contentMap[contentBlock.contentType as ContentMapKey]; + if (!getComponent) { + return; + } + return getComponent(); +}; diff --git a/apps/web/components/ui/CategoryCard/CategoryCard.tsx b/apps/web/components/ui/CategoryCard/CategoryCard.tsx index d227312..3efd249 100644 --- a/apps/web/components/ui/CategoryCard/CategoryCard.tsx +++ b/apps/web/components/ui/CategoryCard/CategoryCard.tsx @@ -1,6 +1,7 @@ import Image from 'next/image'; import Link from 'next/link'; import type { CategoryCardProps } from '~/components'; +import withShopify, { ShopifyBlock } from '~/sdk/shopify/withShopify'; export function CategoryCard({ items, ...attributes }: CategoryCardProps) { return ( @@ -30,3 +31,20 @@ export function CategoryCard({ items, ...attributes }: CategoryCardProps) {
); } + +// SpecificFieldsType would be your custom type for the fields in BlockComponent +type SpecificFieldsType = { + // ... define the fields structure here +}; + +const wrapperFn = (contentBlock: ShopifyBlock): CategoryCardProps => { + // Convert Shopify content to the format required by CategoryCard + // ... + + return { + items: [{ id: 123 }], + // ... props for CategoryCard + }; +}; + +export const CategoryCardBlock = withShopify({ wrapperFn, isDebug: true })(CategoryCard); diff --git a/apps/web/components/ui/Display/Display.tsx b/apps/web/components/ui/Display/Display.tsx index ad8deea..1d43670 100644 --- a/apps/web/components/ui/Display/Display.tsx +++ b/apps/web/components/ui/Display/Display.tsx @@ -3,6 +3,7 @@ import Link from 'next/link'; import { SfButton } from '@storefront-ui/react'; import classNames from 'classnames'; import type { DisplayProps } from '~/components'; +import withShopify from '~/sdk/shopify/withShopify'; export function Display({ items, ...attributes }: DisplayProps) { return ( @@ -45,3 +46,5 @@ export function Display({ items, ...attributes }: DisplayProps) { ); } + +export const DisplayBlock = withShopify({ wrapperFn: (v) => v, isDebug: true })(Display); diff --git a/apps/web/components/ui/Hero/Hero.tsx b/apps/web/components/ui/Hero/Hero.tsx index f38efad..8539b5a 100644 --- a/apps/web/components/ui/Hero/Hero.tsx +++ b/apps/web/components/ui/Hero/Hero.tsx @@ -2,6 +2,7 @@ import Image from 'next/image'; import Link from 'next/link'; import { SfButton } from '@storefront-ui/react'; import type { HeroProps } from '~/components'; +import withShopify from '~/sdk/shopify/withShopify'; export function Hero({ image, @@ -48,3 +49,5 @@ export function Hero({ ); } + +export const HeroBlock = withShopify({ wrapperFn: (v) => v, isDebug: true })(Hero); diff --git a/apps/web/hooks/useContent/getPageQuery.ts b/apps/web/hooks/useContent/getPageQuery.ts new file mode 100644 index 0000000..67f7722 --- /dev/null +++ b/apps/web/hooks/useContent/getPageQuery.ts @@ -0,0 +1,77 @@ +export const getPageQuery = `#graphql +query GetPage($slug: String!) { + page(handle: $slug) { + id + title + handle + seo { + title + description + } + content: metafield(namespace: "custom", key: "content") { + id + key + namespace + value + references(first: 50) { + edges { + node { + __typename + ... on Metaobject { + id + type + fields { + type + key + value + reference { + __typename + ... on Collection { + id + title + slug: handle + } + ... on Product { + id + title + slug: handle + } + ... on Metaobject { + id + handle + type + fields { + key + type + value + reference { + __typename + ... on Page { + id + title + slug: handle + } + ... on MediaImage { + id + alt + image { + url + altText + width + height + id + } + } + } + } + } + } + } + } + } + } + } + } + } +} +`; diff --git a/apps/web/hooks/useContent/response.json b/apps/web/hooks/useContent/response.json new file mode 100644 index 0000000..29784c1 --- /dev/null +++ b/apps/web/hooks/useContent/response.json @@ -0,0 +1,409 @@ +[ + { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28210495768", + "type": "display", + "fields": [ + { + "type": "metaobject_reference", + "key": "card_1", + "value": "gid://shopify/Metaobject/28210233624", + "reference": { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28210233624", + "handle": "summit-adventures", + "fields": [ + { + "key": "card_color", + "type": "color", + "value": "#cae9f4", + "reference": null + }, + { + "key": "cta_text", + "type": "single_line_text_field", + "value": "Discover More", + "reference": null + }, + { + "key": "description", + "type": "rich_text_field", + "value": "{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"value\":\"Explore our latest range of high-performance snowboards, designed for the thrill-seekers.\"}]}]}", + "reference": null + }, + { + "key": "image", + "type": "file_reference", + "value": "gid://shopify/MediaImage/36458269212952", + "reference": { + "__typename": "MediaImage", + "id": "gid://shopify/MediaImage/36458269212952", + "alt": "", + "image": { + "url": "https://cdn.shopify.com/s/files/1/0819/1279/1320/files/DALL_E_2023-12-24_00.23.59_-_A_majestic_snowy_mountain_range_under_a_clear_blue_sky_with_sharp_peaks_covered_in_pristine_snow._The_scene_is_ideal_for_snowboarding_showcasing_the.png?v=1703352282", + "altText": null, + "width": 1024, + "height": 1024, + "id": "gid://shopify/ImageSource/36468219937048" + } + } + }, + { + "key": "page", + "type": "page_reference", + "value": "gid://shopify/OnlineStorePage/126943297816", + "reference": { + "__typename": "Page", + "id": "gid://shopify/Page/126943297816", + "title": "Contact Us", + "slug": "contact-us" + } + }, + { + "key": "reverse", + "type": "boolean", + "value": "false", + "reference": null + }, + { + "key": "subtitle", + "type": "single_line_text_field", + "value": "Reach New Heights", + "reference": null + }, + { + "key": "title", + "type": "single_line_text_field", + "value": "Summit Adventures", + "reference": null + } + ] + } + }, + { + "type": "metaobject_reference", + "key": "card_2", + "value": "gid://shopify/Metaobject/28210397464", + "reference": { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28210397464", + "handle": "trick-master-series", + "fields": [ + { + "key": "card_color", + "type": "color", + "value": "#afffe9", + "reference": null + }, + { + "key": "cta_text", + "type": "single_line_text_field", + "value": "View Collection", + "reference": null + }, + { + "key": "description", + "type": "rich_text_field", + "value": "{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"value\":\"Conquer the slopes with our trick-oriented boards, perfect for both beginners and pros.\"}]}]}", + "reference": null + }, + { + "key": "image", + "type": "file_reference", + "value": "gid://shopify/MediaImage/36458269376792", + "reference": { + "__typename": "MediaImage", + "id": "gid://shopify/MediaImage/36458269376792", + "alt": "", + "image": { + "url": "https://cdn.shopify.com/s/files/1/0819/1279/1320/files/DALL_E_2023-12-24_00.24.06_-_An_image_of_a_snowboarder_mid-air_performing_an_impressive_trick_against_a_snowy_mountain_landscape_backdrop._The_snowboarder_is_captured_in_dynamic_m.png?v=1703352283", + "altText": null, + "width": 1024, + "height": 1024, + "id": "gid://shopify/ImageSource/36468220100888" + } + } + }, + { + "key": "page", + "type": "page_reference", + "value": "gid://shopify/OnlineStorePage/126943297816", + "reference": { + "__typename": "Page", + "id": "gid://shopify/Page/126943297816", + "title": "Contact Us", + "slug": "contact-us" + } + }, + { + "key": "reverse", + "type": "boolean", + "value": "false", + "reference": null + }, + { + "key": "subtitle", + "type": "single_line_text_field", + "value": "Unleash Your Skills", + "reference": null + }, + { + "key": "title", + "type": "single_line_text_field", + "value": "Trick Master Series", + "reference": null + } + ] + } + }, + { + "type": "metaobject_reference", + "key": "card_3", + "value": "gid://shopify/Metaobject/28210430232", + "reference": { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28210430232", + "handle": "winter-comfort", + "fields": [ + { + "key": "card_color", + "type": "color", + "value": "#fabdff", + "reference": null + }, + { + "key": "cta_text", + "type": "single_line_text_field", + "value": "Shop Apparel", + "reference": null + }, + { + "key": "description", + "type": "rich_text_field", + "value": "{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"value\":\"Check out our exclusive winter apparel, combining comfort with cutting-edge style\"}]}]}", + "reference": null + }, + { + "key": "image", + "type": "file_reference", + "value": "gid://shopify/MediaImage/36458269344024", + "reference": { + "__typename": "MediaImage", + "id": "gid://shopify/MediaImage/36458269344024", + "alt": "", + "image": { + "url": "https://cdn.shopify.com/s/files/1/0819/1279/1320/files/DALL_E_2023-12-24_00.24.10_-_An_illustration_of_a_cozy_inviting_mountainside_chalet_surrounded_by_snow-covered_trees_set_in_a_serene_winter_evening_atmosphere._The_chalet_exudes.png?v=1703352283", + "altText": null, + "width": 1024, + "height": 1024, + "id": "gid://shopify/ImageSource/36468220068120" + } + } + }, + { + "key": "page", + "type": "page_reference", + "value": "gid://shopify/OnlineStorePage/126943297816", + "reference": { + "__typename": "Page", + "id": "gid://shopify/Page/126943297816", + "title": "Contact Us", + "slug": "contact-us" + } + }, + { + "key": "reverse", + "type": "boolean", + "value": "false", + "reference": null + }, + { + "key": "subtitle", + "type": "single_line_text_field", + "value": "Style & Warmth", + "reference": null + }, + { + "key": "title", + "type": "single_line_text_field", + "value": "Winter Comfort", + "reference": null + } + ] + } + } + ] + }, + { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28210004248", + "type": "collection_card", + "fields": [ + { + "type": "collection_reference", + "key": "collection_1", + "value": "gid://shopify/Collection/459157668120", + "reference": { + "__typename": "Collection", + "id": "gid://shopify/Collection/459157668120", + "title": "Oxygen", + "slug": "oxygen" + } + }, + { + "type": "collection_reference", + "key": "collection_2", + "value": "gid://shopify/Collection/458402726168", + "reference": { + "__typename": "Collection", + "id": "gid://shopify/Collection/458402726168", + "title": "Hydrogen", + "slug": "hydrogen" + } + }, + { + "type": "collection_reference", + "key": "collection_3", + "value": "gid://shopify/Collection/459220877592", + "reference": { + "__typename": "Collection", + "id": "gid://shopify/Collection/459220877592", + "title": "Featured Items", + "slug": "hidden-homepage-featured-items" + } + }, + { + "type": "single_line_text_field", + "key": "title", + "value": "Explore Our Signature Collections", + "reference": null + } + ] + }, + { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28209479960", + "type": "hero", + "fields": [ + { + "type": "rich_text_field", + "key": "description", + "value": "{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"value\":\"Embark on an exhilarating journey with our exclusive snowboard collection. Designed for the bold and the adventurous, each board promises unmatched performance and style. Conquer the slopes and experience the thrill of the mountains like never before\"}]}]}", + "reference": null + }, + { + "type": "product_reference", + "key": "primary_button_link", + "value": "gid://shopify/Product/8610554806552", + "reference": { + "__typename": "Product", + "id": "gid://shopify/Product/8610554806552", + "title": "The Complete Snowboard", + "slug": "the-complete-snowboard" + } + }, + { + "type": "single_line_text_field", + "key": "primary_button_text", + "value": "Order Now", + "reference": null + }, + { + "type": "collection_reference", + "key": "secondary_button_link", + "value": "gid://shopify/Collection/458402693400", + "reference": { + "__typename": "Collection", + "id": "gid://shopify/Collection/458402693400", + "title": "Automated Collection", + "slug": "automated-collection" + } + }, + { + "type": "single_line_text_field", + "key": "secondary_button_text", + "value": "Explore Our Boards", + "reference": null + }, + { + "type": "single_line_text_field", + "key": "subtitle", + "value": "Ride the Peak", + "reference": null + }, + { + "type": "single_line_text_field", + "key": "title", + "value": "Unleash the Mountain Spirit", + "reference": null + } + ] + }, + { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28208759064", + "type": "richtext_block", + "fields": [ + { + "type": "rich_text_field", + "key": "description", + "value": "{\"type\":\"root\",\"children\":[{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"value\":\"Oxygen Collection: Glide with Grace\",\"bold\":true},{\"type\":\"text\",\"value\":\"\\nElevate your mountain experience with our Oxygen Collection, a premium line of snowboards designed for those who seek harmony between agility and power. Each board in this collection is a masterpiece of craftsmanship, embodying the spirit of the high alps. The Oxygen range features ultra-lightweight cores, combined with a dynamic flex pattern, to offer unparalleled control and responsiveness. The boards' sleek design, inspired by the crisp air of mountain peaks, promises a smooth glide over fresh powder or groomed trails alike. Whether carving sharp turns or cruising down serene slopes, the Oxygen Collection ensures a ride that's as exhilarating as it is effortless.\"}]},{\"type\":\"paragraph\",\"children\":[{\"type\":\"text\",\"value\":\"Hydrogen Collection: Unleash the Power\",\"bold\":true},{\"type\":\"text\",\"value\":\"\\nDiscover the raw energy of the slopes with our Hydrogen Collection, a series of snowboards that redefine the boundaries of speed and strength. This collection is for the bold, the adventurers who chase the thrill of the descent. The Hydrogen snowboards boast a robust construction with reinforced edges, tailored for high-impact performance. Their innovative shape allows for aggressive carving and swift navigation through challenging terrains. As you traverse icy paths or tackle steep drops, the Hydrogen Collection provides stability and confidence, leaving a trail of adrenaline in your wake. Embrace the power, and make your mark on the mountains with Hydrogen.\"}]}]}", + "reference": null + }, + { + "type": "single_line_text_field", + "key": "title", + "value": "Explore our collections", + "reference": null + } + ] + }, + { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28208693528", + "type": "product_slider", + "fields": [ + { + "type": "collection_reference", + "key": "collection", + "value": "gid://shopify/Collection/459157668120", + "reference": { + "__typename": "Collection", + "id": "gid://shopify/Collection/459157668120", + "title": "Oxygen", + "slug": "oxygen" + } + }, + { + "type": "single_line_text_field", + "key": "title", + "value": "Oxygen", + "reference": null + } + ] + }, + { + "__typename": "Metaobject", + "id": "gid://shopify/Metaobject/28208726296", + "type": "product_slider", + "fields": [ + { + "type": "collection_reference", + "key": "collection", + "value": "gid://shopify/Collection/458402726168", + "reference": { + "__typename": "Collection", + "id": "gid://shopify/Collection/458402726168", + "title": "Hydrogen", + "slug": "hydrogen" + } + }, + { + "type": "single_line_text_field", + "key": "title", + "value": "Hydrogen", + "reference": null + } + ] + } +] diff --git a/apps/web/hooks/useContent/types.ts b/apps/web/hooks/useContent/types.ts index e92c31d..a57ba7e 100644 --- a/apps/web/hooks/useContent/types.ts +++ b/apps/web/hooks/useContent/types.ts @@ -1,31 +1,142 @@ -import type { SfProduct } from '@vue-storefront/unified-data-model'; -import type { HeadingProps } from '~/components/Heading/types'; -import type { PageProps } from '~/components/Page/types'; -import type { ProductSliderProps } from '~/components/ProductSlider/types'; -import type { CategoryCardProps } from '~/components/ui/CategoryCard/types'; -import type { DisplayProps } from '~/components/ui/Display/types'; -import type { HeroProps } from '~/components/ui/Hero/types'; - -type EntryFields = Array<{ - fields: TFields; -}>; - -type WithComponentField = TProps & { - component: TComponent; -}; - -export type DynamicContentFields = - | WithComponentField - | WithComponentField - | WithComponentField - | WithComponentField - | WithComponentField & { items: SfProduct[] }, 'ProductSlider'> - | WithComponentField - | WithComponentField; - -export interface ContentDynamicPage { - component: 'Page'; - content: EntryFields; - name: string; - url: string; -} +/* eslint-disable no-use-before-define */ +export type Edge = { + node: Metaobject; +}; + +export type Reference = CollectionReference | ProductReference | PageReference | MediaImageReference; + +export type ContentReferences = { + edges: Edge[]; +}; + +export type Field = { + type: string; + key: string; + value: string; + reference?: Reference; +}; + +export type Metaobject = { + __typename: string; + id: string; + type: string; + fields: Field[]; + handle?: string; +}; + +export type Seo = { + title: string; + description: string; +}; + +export type ContentMetafield = { + id: string; + key: string; + namespace: string; + value: string; + references: ContentReferences; +}; + +export type PageData = { + id: string; + title: string; + handle: string; + seo: Seo; + content: ContentMetafield; +}; + +export type LandingPageProps = { + pageData: PageData; + content: Metaobject[]; +}; + +export type CollectionReference = { + __typename: 'Collection'; + id: string; + title: string; + slug: string; +}; + +export type ProductReference = { + __typename: 'Product'; + id: string; + title: string; + slug: string; +}; + +export type PageReference = { + __typename: 'Page'; + id: string; + title: string; + slug: string; +}; + +export type MediaImageReference = { + __typename: 'MediaImage'; + id: string; + alt: string; + image: { + url: string; + altText: string; + width: number; + height: number; + id: string; + }; +}; + +export type RawImage = { + __typename: 'MediaImage'; + id: string; + alt: string; + image: { + url: string; + altText: string | null; + width: number; + height: number; + id: string; + }; +}; + +export type RawPage = { + __typename: 'Page'; + id: string; + title: string; + slug: string; +}; + +export type RawProduct = { + __typename: 'Product'; + id: string; + title: string; + slug: string; +}; + +export type RawCollection = { + __typename: 'Collection'; + id: string; + title: string; + slug: string; +}; + +export type GeneralContentField = { + type: string; + key: string; + value: string; + reference?: GeneralComponent | RawImage | RawPage | RawProduct | RawCollection; +}; + +export type GeneralComponent = { + __typename: string; + id: string; + type: string; + fields: GeneralContentField[]; +}; + +export type GeneralContentBlock = { + __typename: 'Metaobject'; + id: string; + type: string; + fields: GeneralContentField[]; +}; + +export type ContentResponse = GeneralContentBlock[]; diff --git a/apps/web/hooks/useContent/useContent.ts b/apps/web/hooks/useContent/useContent.ts index 5a073d9..da8f755 100644 --- a/apps/web/hooks/useContent/useContent.ts +++ b/apps/web/hooks/useContent/useContent.ts @@ -1,23 +1,107 @@ +/* eslint-disable no-use-before-define */ import { QueryClient, useQuery } from '@tanstack/react-query'; import { sdk } from '~/sdk'; +import { getPageQuery } from './getPageQuery'; +import { ContentResponse, GeneralContentBlock, RawCollection, RawImage, RawPage, RawProduct } from './types'; -export async function prefetchContent(url: string): Promise { - const queryClient = new QueryClient(); - // TODO [>0.2]: revert and switch to Shopify SDK - await queryClient.prefetchQuery(['content', url], () => sdk.commerce.getContent({ url })); +// GraphQL query to fetch all pages slugs +const getAllPagesQuery = `#graphql + { + pages(first: 50) { + edges { + node { + handle + } + } + } + } +`; - return queryClient; +// Fetches specific page content +export async function fetchPage(slug: string) { + const query = getPageQuery; // Assumes getPageQuery constructs the appropriate GraphQL query + const response = await sdk.shopify.customQuery({ query, variables: { slug } }); + return response.data.page; } -/** - * Hook for getting content data - * @param {string} url Content url - */ +// Fetches all pages' slugs +export async function fetchAllPages() { + const response = await sdk.shopify.customQuery({ query: getAllPagesQuery }); + return response.data.pages.edges.map((edge: { node: { handle: string } }) => edge.node.handle); +} -export function useContent(url: string) { - // TODO [>0.2]: revert and switch to Shopify SDK - return useQuery(['content', url], () => sdk.commerce.getContent({ url }), { +// Updated useContent hook +export function useContent(slug: string) { + return useQuery(['content', slug], () => fetchPage(slug), { refetchOnMount: false, refetchOnWindowFocus: false, }); } + +// Prefetch content for a specific URL +export async function prefetchContent(slug: string): Promise { + const queryClient = new QueryClient(); + await queryClient.prefetchQuery(['content', slug], () => fetchPage(slug)); + return queryClient; +} + +type ImageComponent = { + contentType: 'MediaImage'; + id: string; + alt: string; + url: string; + altText: string | null; + width: number; + height: number; +}; + +export type BlockComponent = { + contentType: string; + id: string; + fields: { [fieldName: string]: string | number | boolean | ContentComponent }; +}; + +export type ContentComponent = BlockComponent | ImageComponent | RawPage | RawProduct | RawCollection; + +export function processContent(response: ContentResponse): ContentComponent[] { + return response.map((block) => processBlock(block)); +} + +export function processBlock( + block: GeneralContentBlock | RawImage | RawPage | RawProduct | RawCollection, +): ContentComponent { + switch (block.__typename) { + case 'MediaImage': { + return { + contentType: block.__typename, + alt: block.alt, + ...block.image, + } as ImageComponent; + } + case 'Page': + case 'Product': + case 'Collection': { + // Directly return the standard component structure + return block as ContentComponent; + } + case 'Metaobject': { + const component: BlockComponent = { + id: block.id, + contentType: block.type, + fields: {}, + }; + + for (const field of block.fields) { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const fieldValue = field.reference ? processBlock(field.reference) : field.value; + component.fields[field.key] = fieldValue; + } + + return component; + } + default: { + return block as unknown as BlockComponent; + } + } +} diff --git a/apps/web/layouts/DefaultLayout.tsx b/apps/web/layouts/DefaultLayout.tsx index 8a62347..147c5e8 100644 --- a/apps/web/layouts/DefaultLayout.tsx +++ b/apps/web/layouts/DefaultLayout.tsx @@ -1,4 +1,5 @@ import type { PropsWithChildren } from 'react'; +import Head from 'next/head'; import Link from 'next/link'; import { SfButton, SfIconExpandMore, SfIconShoppingCart } from '@storefront-ui/react'; import { useTranslation } from 'next-i18next'; @@ -19,6 +20,10 @@ import { Product } from '~/sdk/shopify/types'; type LayoutPropsType = PropsWithChildren & { breadcrumbs?: Breadcrumb[]; product?: Product; + seo?: { + title: string; + description?: string; + }; }; const CartButton = () => { @@ -42,11 +47,17 @@ const CartButton = () => { ); }; -export function DefaultLayout({ children, breadcrumbs = [], product }: LayoutPropsType): JSX.Element { +export function DefaultLayout({ children, seo, breadcrumbs = [], product }: LayoutPropsType): JSX.Element { const { t } = useTranslation(); return ( + {seo ? ( + + {seo?.title} + {seo?.description ? : null} + + ) : null} - Vue Storefront React Boilerplate + Vue Storefront Shopify Boilerplate
diff --git a/apps/web/pages/landing/[landing].tsx b/apps/web/pages/landing/[landing].tsx new file mode 100644 index 0000000..b4ba578 --- /dev/null +++ b/apps/web/pages/landing/[landing].tsx @@ -0,0 +1,79 @@ +import { Fragment, ReactElement } from 'react'; +import { GetStaticPaths, GetStaticProps, NextPage } from 'next'; +import { serverSideTranslations } from 'next-i18next/serverSideTranslations'; +import { RenderContent } from '~/components'; +import { fetchPage, fetchAllPages, processContent, ContentComponent } from '~/hooks'; +import { PageData, ContentReferences, Metaobject, ContentResponse } from '~/hooks/useContent/types'; +import { DefaultLayout } from '~/layouts'; + +function flattenEdges({ edges }: ContentReferences): Metaobject[] { + if (!edges || !Array.isArray(edges)) { + return []; + } + + return edges.map((edge) => edge.node); +} + +export const getStaticPaths: GetStaticPaths = async (): Promise<{ + paths: { params: { landing: string } }[]; + fallback: boolean | 'blocking'; +}> => { + const allPages: string[] = await fetchAllPages(); + const paths = allPages.map((page) => ({ params: { landing: page } })); + + return { paths, fallback: 'blocking' }; +}; + +export const getStaticProps: GetStaticProps = async ({ + params, + locale, +}): Promise< + { props: { pageData: PageData; content: ContentComponent[] }; revalidate: number } | { notFound: true } +> => { + const slug = params?.landing; + if (!slug) { + return { notFound: true }; + } + + const pageData: PageData = await fetchPage(slug as string); + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const rawContent: ContentResponse = flattenEdges(pageData?.content.references); + const content = processContent(rawContent); + + if (!pageData) { + return { notFound: true }; + } + + return { + props: { + ...(await serverSideTranslations(locale as string, ['common', 'footer'])), + pageData, + content, + }, + revalidate: 60, + }; +}; + +type LandingPageProps = { + pageData: PageData; + content: ContentComponent[]; +}; + +const LandingPage = ({ pageData, content }: LandingPageProps): ReactElement => { + return ( + + {content && ( +
+ {content.map((contentBlock) => ( + + + + ))} +
+ )} +
+ ); +}; + +export default LandingPage; diff --git a/apps/web/sdk/shopify/withShopify.tsx b/apps/web/sdk/shopify/withShopify.tsx new file mode 100644 index 0000000..b291795 --- /dev/null +++ b/apps/web/sdk/shopify/withShopify.tsx @@ -0,0 +1,55 @@ +import React, { ComponentType, ForwardedRef } from 'react'; + +export type ShopifyBlock = { + contentType: string; + id: string; + fields: FieldsType; +}; + +type DebugBlockProps = { + contentBlock: ShopifyBlock; + name: string; +}; + +export const DebugBlock = ({ contentBlock, name }: DebugBlockProps) => { + const { fields, ...rest } = contentBlock; + return ( +
+

{name}

+

Type: {contentBlock.contentType}

+

ID: {contentBlock.id}

+
{JSON.stringify(rest, null, 2)}
+
{JSON.stringify(fields, null, 2)}
+
+
+ ); +}; + +type WrapperFunction = (props: ShopifyBlock) => object; + +interface WithShopifyOptions { + wrapperFn: WrapperFunction; + isDebug?: boolean; +} + +const withShopify = + ({ wrapperFn, isDebug }: WithShopifyOptions) => + (WrappedComponent: ComponentType) => { + const wrappedComponentName = WrappedComponent.displayName || WrappedComponent.name || 'Component'; + + const WithShopify = React.forwardRef, { contentBlock: ShopifyBlock }>( + (props, reference) => { + if (isDebug) { + return ; + } + const baseProps = wrapperFn(props.contentBlock); + return ; + }, + ); + + WithShopify.displayName = `withShopify(${wrappedComponentName})`; + + return WithShopify; + }; + +export default withShopify; diff --git a/yarn.lock b/yarn.lock index 4678e6f..3596c47 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2044,6 +2044,11 @@ resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== +"@thebeyondgroup/shopify-rich-text-renderer@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@thebeyondgroup/shopify-rich-text-renderer/-/shopify-rich-text-renderer-1.0.0.tgz#64d370d1f2ca2d71e36c842d9d4a278dd0324825" + integrity sha512-7NChTgzGB4LPf8Gk0RgtGdEr0yuSZ/wfCLHQ9UfIIcL7tcq+Q9joX4GL24RZGEOPNoYVcVtBq6NA77jckYg56g== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf"