Skip to content

Commit

Permalink
Merge pull request #52 from buildheadless/pdp
Browse files Browse the repository at this point in the history
Add AddToCartForm and Cart route
  • Loading branch information
thomasKn authored Dec 7, 2023
2 parents 497b819 + b30f8be commit 6fdd21b
Show file tree
Hide file tree
Showing 14 changed files with 640 additions and 532 deletions.
2 changes: 1 addition & 1 deletion create-headless/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"picocolors": "^1.0.0",
"proxy-agent": "^6.3.1",
"recursive-readdir": "^2.2.3",
"sanity": "^3.21.0",
"sanity": "^3.21.1",
"tar-fs": "^3.0.4"
}
}
755 changes: 261 additions & 494 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions templates/hydrogen-theme/app/components/icons/Icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {cx} from 'class-variance-authority';
import {twMerge} from 'tailwind-merge';

export type IconProps = JSX.IntrinsicElements['svg'] & {
direction?: 'down' | 'left' | 'right' | 'up';
};

export function Icon({
children,
className,
fill = 'currentColor',
stroke,
...props
}: IconProps) {
return (
<svg
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
{...props}
className={twMerge(cx('h-5 w-5', className))}
fill={fill}
stroke={stroke}
>
{children}
</svg>
);
}
15 changes: 15 additions & 0 deletions templates/hydrogen-theme/app/components/icons/IconBag.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type {IconProps} from './Icon';

import {Icon} from './Icon';

export function IconBag(props: IconProps) {
return (
<Icon {...props}>
<title>Bag</title>
<path
d="M8.125 5a1.875 1.875 0 0 1 3.75 0v.375h-3.75V5Zm-1.25.375V5a3.125 3.125 0 1 1 6.25 0v.375h3.5V15A2.625 2.625 0 0 1 14 17.625H6A2.625 2.625 0 0 1 3.375 15V5.375h3.5ZM4.625 15V6.625h10.75V15c0 .76-.616 1.375-1.375 1.375H6c-.76 0-1.375-.616-1.375-1.375Z"
fillRule="evenodd"
/>
</Icon>
);
}
58 changes: 58 additions & 0 deletions templates/hydrogen-theme/app/components/layout/CartCount.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {Await, Link} from '@remix-run/react';
import {cx} from 'class-variance-authority';
import {Suspense, useMemo} from 'react';

import {useIsHydrated} from '~/hooks/useIsHydrated';
import {useLocalePath} from '~/hooks/useLocalePath';
import {useRootLoaderData} from '~/hooks/useRootLoaderData';

import {IconBag} from '../icons/IconBag';

export function CartCount() {
const rootData = useRootLoaderData();

return (
<Suspense fallback={<Badge count={0} />}>
<Await resolve={rootData?.cart}>
{(cart) => <Badge count={cart?.totalQuantity || 0} />}
</Await>
</Suspense>
);
}

function Badge(props: {count: number}) {
const {count} = props;
const isHydrated = useIsHydrated();
const path = useLocalePath({path: '/cart'});

const BadgeCounter = useMemo(
() => (
<>
<IconBag className="h-6 w-6" />
{isHydrated && count > 0 && (
<div
className={cx([
'absolute right-[-8px] top-0 flex items-center justify-center',
'inverted-color-scheme',
'aspect-square h-auto min-w-[1.35rem] rounded-full p-1',
'text-center text-[.7rem] leading-[0] subpixel-antialiased',
])}
>
<span>{count}</span>
</div>
)}
</>
),
[count, isHydrated],
);

return (
<Link
className="focus:ring-primary/5 relative flex h-8 w-8 items-center justify-center"
prefetch="intent"
to={path}
>
{BadgeCounter}
</Link>
);
}
6 changes: 5 additions & 1 deletion templates/hydrogen-theme/app/components/layout/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {useSettingsCssVars} from '~/hooks/useSettingsCssVars';

import {headerVariants} from '../cva/header';
import {Navigation} from '../navigation/Navigation';
import {CartCount} from './CartCount';
import {Logo} from './Logo';

export function Header() {
Expand Down Expand Up @@ -44,7 +45,10 @@ export function Header() {
}
/>
</Link>
<Navigation data={header?.menu} />
<div className="flex items-center gap-3">
<Navigation data={header?.menu} />
<CartCount />
</div>
</div>
</div>
</header>
Expand Down
63 changes: 55 additions & 8 deletions templates/hydrogen-theme/app/components/product/AddToCartForm.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type {ProductVariantFragmentFragment} from 'storefrontapi.generated';

import {ShopPayButton} from '@shopify/hydrogen';
import {Form, useFetchers, useSubmit} from '@remix-run/react';
import {CartForm, ShopPayButton} from '@shopify/hydrogen';
import {cx} from 'class-variance-authority';
import {useState} from 'react';
import {useCallback, useState} from 'react';
import {Button} from 'react-aria-components';

import {useIsInIframe} from '~/hooks/useIsInIframe';
Expand All @@ -22,6 +23,33 @@ export function AddToCartForm(props: {
const selectedVariant = useSelectedVariant({variants});
const isOutOfStock = !selectedVariant?.availableForSale;
const [quantity, setQuantity] = useState(1);
const submit = useSubmit();
const fetchers = useFetchers();

const addToCartFetcher = fetchers.find(
(fetcher) => fetcher.key === CartForm.ACTIONS.LinesAdd,
);

const loading =
addToCartFetcher?.state === 'loading' ||
addToCartFetcher?.state === 'submitting';

const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();

const formData = new FormData(event.currentTarget);

submit(formData, {
action: '/cart',
fetcherKey: CartForm.ACTIONS.LinesAdd,
method: 'post',
navigate: false,
preventScrollReset: true,
});
},
[submit],
);

return (
selectedVariant && (
Expand All @@ -33,25 +61,44 @@ export function AddToCartForm(props: {
/>
)}
<div className="grid gap-3">
<div>
<Form method="post" onSubmit={(e) => handleSubmit(e)}>
<input
name={CartForm.INPUT_NAME}
type="hidden"
value={JSON.stringify({
action: CartForm.ACTIONS.LinesAdd,
inputs: {
lines: {
merchandiseId: selectedVariant?.id!,
quantity,
},
},
})}
/>
<Button
className="inverted-color-scheme w-full rounded px-3 py-2 disabled:opacity-50"
isDisabled={isOutOfStock}
className={cx([
'inverted-color-scheme w-full rounded px-3 py-2',
isOutOfStock && 'opacity-50',
])}
isDisabled={isOutOfStock || loading}
type="submit"
>
{isOutOfStock ? (
<span>{themeContent?.product?.soldOut}</span>
) : (
<span>{themeContent?.product?.addToCart}</span>
)}
</Button>
</div>
</Form>
{!isInIframe && showShopPay && (
<div className="h-10">
<ShopPayButton
className={cx([
'h-full',
isOutOfStock &&
'pointer-events-none cursor-default opacity-50',
loading || isOutOfStock
? 'pointer-events-none cursor-default'
: '',
isOutOfStock && 'opacity-50',
])}
variantIdsAndQuantities={[
{
Expand Down
11 changes: 11 additions & 0 deletions templates/hydrogen-theme/app/hooks/useIsHydrated.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {useEffect, useState} from 'react';

export function useIsHydrated() {
const [isHydrated, setHydrated] = useState<boolean>(false);

useEffect(() => {
setHydrated(true);
}, []);

return isHydrated;
}
13 changes: 13 additions & 0 deletions templates/hydrogen-theme/app/hooks/useLocalePath.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import {useLocale} from './useLocale';

export function useLocalePath(props: {path: string}) {
const locale = useLocale();
const {path} = props;
const pathPrefix = locale?.pathPrefix;

if (pathPrefix) {
return `${pathPrefix}${path}`;
}

return path;
}
34 changes: 20 additions & 14 deletions templates/hydrogen-theme/app/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,20 +46,6 @@ export function getVariantUrl({
return path + (searchString ? '?' + searchParams.toString() : '');
}

/**
* Validates that a url is local to the current request.
*/
export function isLocalPath(request: Request, url: string) {
// Our domain, based on the current request path
const currentUrl = new URL(request.url);

// If url is relative, the 2nd argument will act as the base domain.
const urlToCheck = new URL(url, currentUrl.origin);

// If the origins don't match the slug is not on our domain.
return currentUrl.origin === urlToCheck.origin;
}

/**
* A not found response. Sets the status code.
*/
Expand All @@ -68,3 +54,23 @@ export const notFound = (message = 'Not Found') =>
status: 404,
statusText: 'Not Found',
});

/**
* Validates that a url is local
* @param url
* @returns `true` if local `false`if external domain
*/
export function isLocalPath(url: string) {
try {
// We don't want to redirect cross domain,
// doing so could create fishing vulnerability
// If `new URL()` succeeds, it's a fully qualified
// url which is cross domain. If it fails, it's just
// a path, which will be the current domain.
new URL(url);
} catch (e) {
return true;
}

return false;
}
3 changes: 1 addition & 2 deletions templates/hydrogen-theme/app/qroq/blocks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {q, z} from 'groqd';
import {q} from 'groqd';

/*
|--------------------------------------------------------------------------
Expand All @@ -20,7 +20,6 @@ export const ADD_TO_CART_BUTTON_BLOCK = q.object({
_type: q.literal('addToCartButton'),
quantitySelector: q.boolean().nullable(),
shopPayButton: q.boolean().nullable(),
size: z.enum(['small', 'medium', 'large']).nullable(),
});

export const PRICE_BLOCK = q.object({
Expand Down
Loading

0 comments on commit 6fdd21b

Please sign in to comment.