Skip to content

Commit

Permalink
feat: visually editable PDP
Browse files Browse the repository at this point in the history
  • Loading branch information
agurtovoy committed Jan 3, 2025
1 parent 0056753 commit 203bfc2
Show file tree
Hide file tree
Showing 10 changed files with 301 additions and 10 deletions.
5 changes: 4 additions & 1 deletion core/app/[locale]/(default)/product/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ import { getFormatter, getTranslations, setRequestLocale } from 'next-intl/serve

import { Stream } from '@/vibes/soul/lib/streamable';
import { FeaturedProductsCarousel } from '@/vibes/soul/sections/featured-products-carousel';
import { ProductDetail } from '@/vibes/soul/sections/product-detail';
import { pricesTransformer } from '~/data-transformers/prices-transformer';
import { productCardTransformer } from '~/data-transformers/product-card-transformer';
import { productOptionsTransformer } from '~/data-transformers/product-options-transformer';
import { ProductDetail } from '~/lib/makeswift/components/product-detail';

import { addToCart } from './_actions/add-to-cart';
import { ProductSchema } from './_components/product-schema';
Expand Down Expand Up @@ -100,6 +100,7 @@ const getProduct = async (productPromise: ReturnType<typeof getProductData>) =>
description: (
<div className="prose" dangerouslySetInnerHTML={{ __html: product.description }} />
),
plainTextDescription: product.plainTextDescription,
href: product.path,
images: product.defaultImage
? [{ src: product.defaultImage.url, alt: product.defaultImage.altText }, ...images]
Expand Down Expand Up @@ -232,6 +233,8 @@ export default async function Product(props: Props) {
incrementLabel={t('ProductDetails.increaseQuantity')}
prefetch={true}
product={getProduct(productPromise)}
productId={productId}
productTitle={productPromise.then((product) => product.name)}
quantityLabel={t('ProductDetails.quantity')}
thumbnailLabel={t('ProductDetails.thumbnail')}
/>
Expand Down
5 changes: 3 additions & 2 deletions core/lib/makeswift/component.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@ import { getComponentSnapshot } from '~/lib/makeswift/client';

export const Component = async ({
snapshotId,
label,
...props
}: {
type: string;
label: string;
label: string | Promise<string>;
snapshotId: string;
}) => {
const snapshot = await getComponentSnapshot(snapshotId);

return <MakeswiftComponent snapshot={snapshot} {...props} />;
return <MakeswiftComponent label={await label} snapshot={snapshot} {...props} />;
};
1 change: 1 addition & 0 deletions core/lib/makeswift/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import './components/site-footer/site-footer.makeswift';
import './components/site-header/site-header.makeswift';
import './components/slideshow/slideshow.makeswift';
import './components/sticky-sidebar/sticky-sidebar.makeswift';
import './components/product-detail/register';

import './components/site-theme/register';

Expand Down
121 changes: 121 additions & 0 deletions core/lib/makeswift/components/product-detail/client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use client';

import React, {
type ComponentPropsWithoutRef,
createContext,
forwardRef,
type PropsWithChildren,
type ReactNode,
type Ref,
useCallback,
useContext,
} from 'react';

import { type Streamable } from '@/vibes/soul/lib/streamable';
import { ProductDetail, type ProductDetailProduct } from '@/vibes/soul/sections/product-detail';
import {
computedStremableProp,
useComputedStremableProp,
} from '~/lib/makeswift/hooks/use-computed-stremable-prop';
import { useStreamablePropsMemo } from '~/lib/makeswift/hooks/use-streamable-props-memo';
import { mergeSections } from '~/lib/makeswift/utils/merge-sections';

type VibesProductDetailProps = ComponentPropsWithoutRef<typeof ProductDetail>;
export type ProductDetail = ProductDetailProduct & {
plainTextDescription?: string;
};

export type Props = VibesProductDetailProps & {
product: Streamable<ProductDetail>;
};

const PropsContext = createContext<Props | null>(null);

export const PropsContextProvider = ({ value, children }: PropsWithChildren<{ value: Props }>) => (
<PropsContext.Provider value={value}>{children}</PropsContext.Provider>
);

export const DescriptionSource = {
CatalogPlainText: 'CatalogPlainText',
CatalogRichText: 'CatalogRichText',
Custom: 'Custom',
} as const;

type DescriptionSource = (typeof DescriptionSource)[keyof typeof DescriptionSource];

interface EditableProps {
summaryText: string | undefined;
description: { source: DescriptionSource; slot: ReactNode };
accordions: Exclude<Awaited<ProductDetailProduct['accordions']>, undefined>;
}

const ProductDetailImpl = ({
summaryText,
description,
accordions,
...props
}: VibesProductDetailProps & EditableProps) => {
const getProductDescription = useCallback(
(product: ProductDetail): ProductDetail['description'] => {
switch (description.source) {
case DescriptionSource.CatalogPlainText:
return product.plainTextDescription;

case DescriptionSource.CatalogRichText:
return product.description;

case DescriptionSource.Custom:
return description.slot;
}
},
[description.source, description.slot],
);

const getProductAccordions = useCallback(
(product: ProductDetail): ProductDetail['accordions'] =>
product.accordions != null
? computedStremableProp(product.accordions, (productAccordions) =>
mergeSections(productAccordions, accordions, (left, right) => ({
...left,
content: right.content,
})),
)
: undefined,
[accordions],
);

const memoizedProps = useStreamablePropsMemo(props);
const computedProduct = useComputedStremableProp(
memoizedProps.product,
useCallback(
(product: ProductDetailProduct) => ({
...product,
summary: summaryText,
description: getProductDescription(product),
accordions: getProductAccordions(product),
}),
[summaryText, getProductDescription, getProductAccordions],
),
);

return <ProductDetail {...{ ...memoizedProps, product: computedProduct }} />;
};

export const MakeswiftProductDetail = forwardRef(
(props: EditableProps, ref: Ref<HTMLDivElement>) => {
const passedProps = useContext(PropsContext);

if (passedProps == null) {
// eslint-disable-next-line no-console
console.error('No context provided for MakeswiftProductDetail');

return <p ref={ref}>There was an error rendering the product detail.</p>;
}

return (
<div className="flex flex-col" ref={ref}>
<ProductDetailImpl {...{ ...passedProps, ...props }} />
</div>
);
},
);
16 changes: 16 additions & 0 deletions core/lib/makeswift/components/product-detail/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Component } from '~/lib/makeswift/component';

import { type Props as ClientProps, PropsContextProvider } from './client';
import { COMPONENT_TYPE } from './register';

type Props = ClientProps & { productId: number; productTitle: Promise<string> };

export const ProductDetail = ({ productId, productTitle, ...props }: Props) => (
<PropsContextProvider value={props}>
<Component
label={(async () => `Detail for ${await productTitle}`)()}
snapshotId={`product-detail-${productId}`}
type={COMPONENT_TYPE}
/>
</PropsContextProvider>
);
46 changes: 46 additions & 0 deletions core/lib/makeswift/components/product-detail/register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { List, Select, Shape, Slot, TextArea, TextInput } from '@makeswift/runtime/controls';

import { runtime } from '~/lib/makeswift/runtime';

import { DescriptionSource, MakeswiftProductDetail } from './client';

export const COMPONENT_TYPE = 'catalyst-makeswift-product-detail-description';

const description = Shape({
label: 'Description',
type: {
source: Select({
label: 'Source',
options: [
{ label: 'Catalog (plain text)', value: DescriptionSource.CatalogPlainText },
{ label: 'Catalog (rich text)', value: DescriptionSource.CatalogRichText },
{ label: 'Custom', value: DescriptionSource.Custom },
],
defaultValue: DescriptionSource.CatalogRichText,
}),
slot: Slot(),
},
});

runtime.registerComponent(MakeswiftProductDetail, {
type: COMPONENT_TYPE,
label: 'MakeswiftProductDetail (private)',
hidden: true,
props: {
summaryText: TextArea({
label: 'Summary',
}),
description,
accordions: List({
label: 'Product info',
type: Shape({
label: 'Product info section',
type: {
title: TextInput({ label: 'Title', defaultValue: 'Section' }),
content: Slot(),
},
}),
getItemLabel: (section) => section?.title || 'Section',
}),
},
});
30 changes: 30 additions & 0 deletions core/lib/makeswift/hooks/use-computed-stremable-prop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useMemo } from 'react';

import { type Streamable } from '@/vibes/soul/lib/streamable';

export const computedStremableProp = <T>(
prop: Streamable<T>,
computation: (prop: NonNullable<T>) => NonNullable<T>,
): Streamable<T> => {
if (prop == null) return prop;

if (prop instanceof Promise) {
return new Promise((resolve, reject) => {
Promise.resolve(prop)
.then((resolvedProp) => {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
resolve(resolvedProp == null ? resolvedProp : computation(resolvedProp));
})
.catch((error: unknown) =>
reject(error instanceof Error ? error : new Error(String(error))),
);
});
}

return computation(prop);
};

export const useComputedStremableProp = <T>(
prop: Streamable<T>,
computation: (prop: NonNullable<T>) => NonNullable<T>,
) => useMemo((): Streamable<T> => computedStremableProp(prop, computation), [prop, computation]);
68 changes: 68 additions & 0 deletions core/lib/makeswift/hooks/use-streamable-props-memo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { type ReactElement, useMemo } from 'react';

interface Options {
maxDepth?: number;
}

function isReactElement(value: object): value is ReactElement {
return '$$typeof' in value && typeof value.$$typeof === 'symbol';
}

function streamablePropsMemoProxy<T extends object>(props: T, { maxDepth = 2 }: Options = {}): T {
if (maxDepth <= 0) return props;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const resolvedProps: Record<string | symbol, any> = {};

const proxyHandler: ProxyHandler<T> = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
get(target: T, prop: string | symbol): any {
if (!(prop in target)) {
return undefined;
}

if (prop in resolvedProps) {
return resolvedProps[prop];
}

// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const targetProp = prop in target ? target[prop as keyof T] : undefined;

if (targetProp == null) {
return targetProp;
}

if (targetProp instanceof Promise) {
return new Promise((resolve, reject) => {
Promise.resolve(targetProp)
.then((value) => {
resolve(
(resolvedProps[prop] =
typeof value !== 'object' || value == null
? value
: streamablePropsMemoProxy(value, { maxDepth: maxDepth - 1 })),
);
})
.catch((error: unknown) =>
reject(error instanceof Error ? error : new Error(String(error))),
);
});
}

if (typeof targetProp !== 'object' || isReactElement(targetProp)) return targetProp;

return (resolvedProps[prop] = streamablePropsMemoProxy(targetProp, {
maxDepth: maxDepth - 1,
}));
},
};

return new Proxy<T>(props, proxyHandler);
}

export const useStreamablePropsMemo = <T extends object>(props: T, { maxDepth }: Options = {}) =>
useMemo(
(): T => streamablePropsMemoProxy(props, { maxDepth }),
// eslint-disable-next-line react-hooks/exhaustive-deps, @typescript-eslint/no-unsafe-assignment
[...Object.entries(props).flatMap((e) => e), maxDepth],
);
9 changes: 9 additions & 0 deletions core/vibes/soul/lib/streamable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@ import { Suspense, use } from 'react';

export type Streamable<T> = T | Promise<T>;

export function all<T extends readonly unknown[] | []>(
streamables: T,
): Streamable<{ -readonly [P in keyof T]: Awaited<T[P]> }> {
return streamables.some((streamable) => streamable instanceof Promise)
? Promise.all(streamables)
: // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
(streamables as { -readonly [P in keyof T]: Awaited<T[P]> });
}

export function useStreamable<T>(streamable: Streamable<T>): T {
return streamable instanceof Promise ? use(streamable) : streamable;
}
Expand Down
10 changes: 3 additions & 7 deletions core/vibes/soul/sections/product-detail/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Stream, Streamable } from '@/vibes/soul/lib/streamable';
import { all, Stream, type Streamable } from '@/vibes/soul/lib/streamable';
import { Accordion, Accordions } from '@/vibes/soul/primitives/accordions';
import { Breadcrumb, Breadcrumbs } from '@/vibes/soul/primitives/breadcrumbs';
import { Price, PriceLabel } from '@/vibes/soul/primitives/price-label';
Expand All @@ -8,7 +8,7 @@ import { ProductGallery } from '@/vibes/soul/sections/product-detail/product-gal
import { ProductDetailForm, ProductDetailFormAction } from './product-detail-form';
import { Field } from './schema';

interface ProductDetailProduct {
export interface ProductDetailProduct {
id: string;
title: string;
href: string;
Expand Down Expand Up @@ -106,11 +106,7 @@ export function ProductDetail<F extends Field>({

<Stream
fallback={<ProductDetailFormSkeleton />}
value={Promise.all([
streamableFields,
streamableCtaLabel,
streamableCtaDisabled,
])}
value={all([streamableFields, streamableCtaLabel, streamableCtaDisabled])}
>
{([fields, ctaLabel, ctaDisabled]) => (
<ProductDetailForm
Expand Down

0 comments on commit 203bfc2

Please sign in to comment.