Skip to content

Commit

Permalink
dont force login
Browse files Browse the repository at this point in the history
added example comments

removed comment

added README

type updates
  • Loading branch information
dustinfirman committed Apr 2, 2024
1 parent 428f7e1 commit 60dd2fc
Show file tree
Hide file tree
Showing 12 changed files with 164 additions and 43 deletions.
42 changes: 42 additions & 0 deletions examples/b2b/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Hydrogen example: B2B

> Note:
>
> This example is currently Unstable
This is an example implementation of a B2B storefront using Hydrogen. It includes the following high level changes.

1. Retrieving company location data from a logged in customer using the [Customer Account API](https://shopify.dev/docs/api/customer/2024-04/queries/customer)
2. Displaying a list of company locations and setting a `companyLocationId` in session
3. Using a storefront `customerAccessToken` and `companyLocationId` to update cart and get B2B specific rules and pricing
4. Using a storefront `customerAccessToken` and `companyLocationId` to [contextualize queries](https://shopify.dev/docs/api/storefront#directives) and get B2B pricing, volume pricing, and quantity rules

## Install

Setup a new project with this example:

```bash
npm create @shopify/hydrogen@latest -- --template custom-cart-method
```

## Requirements

- Your store is on a [Shopify Plus plan](https://help.shopify.com/manual/intro-to-shopify/pricing-plans/plans-features/shopify-plus-plan).
- Your store is using [new customer accounts](https://help.shopify.com/en/manual/customers/customer-accounts/new-customer-accounts).
- You have access to a customer which has permission to order for a [B2B company](https://help.shopify.com/en/manual/b2b).

## Key files

This folder contains the minimal set of files needed to showcase the implementation.
More files were changed, but these are the ones requried to implement a basic headless B2B storefront.
Files that aren’t included by default with Hydrogen and that you’ll need to
create are labeled with 🆕.

| File | Description |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [`app/root.tsx`](app/root.tsx) | Includes a customer query to determine if the the logged in session is for a B2B buyer. Set `companyLocationId` in session if there is only one location available to buy for |
| 🆕 [`app/graphql/CustomerLocationsQuery.ts`](app/graphql/CustomerLocationsQuery.ts) | Customer query to fetch company locations |
| 🆕 [`app/components/LocationSelector.tsx`](app/components/LocationSelector.tsx) | Component to choose a Company location to buy for. Rendered if there is no `companyLocationId` set in session |
| [`app/routes/products.$handle.tsx`](app/routes/products.$handle.tsx) | Added buyer context to the product and product varient queries. Includes logic and components to display quantity rules and quantity price breaks |
| 🆕 [`app/components/PriceBreaks.tsx`](app/components/PriceBreaks.tsx) | Component rendered on the product page to highlight quantity price breaks |
| 🆕 [`app/components/QuantityRules.tsx`](app/components/QuantityRules.tsx) | Component rendered on the product page to highlight quantity rules |
12 changes: 8 additions & 4 deletions examples/b2b/app/components/Header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import {useState, Suspense} from 'react';
import type {HeaderQuery} from 'storefrontapi.generated';
import type {LayoutProps} from './Layout';
import {useRootLoaderData} from '~/root';
import type {
CompanyLocation,
CompanyLocationConnection,
} from '@shopify/hydrogen-react/customer-account-api-types';

/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
Expand Down Expand Up @@ -156,21 +160,21 @@ function CartToggle({cart}: Pick<HeaderProps, 'cart'>) {
/********** EXAMPLE UPDATE STARTS ************/
function LocationDropdown({company}: Pick<HeaderProps, 'company'>) {
const locations = company?.locations?.edges
? company.locations.edges.map((location) => {
? company.locations.edges.map((location: CompanyLocationConnection) => {
return {...location.node};
})
: [];

const [selectedLocation, setSelectedLocation] = useState(
company.locations.edges[0].node.id ?? undefined,
company?.locations?.edges?.[0]?.node?.id ?? undefined,
);

const setLocation = async (event: React.ChangeEvent<HTMLSelectElement>) => {
const locationId = event.target.value;
setSelectedLocation(locationId);
};

if (locations.length === 1) return null;
if (locations.length === 1 || !company) return null;

return (
<CartForm route="/cart" action={CartForm.ACTIONS.BuyerIdentityUpdate}>
Expand All @@ -187,7 +191,7 @@ function LocationDropdown({company}: Pick<HeaderProps, 'company'>) {
value={selectedLocation}
style={{marginRight: '4px'}}
>
{locations.map((location) => {
{locations.map((location: CompanyLocation) => {
return (
<option
defaultValue={selectedLocation}
Expand Down
7 changes: 4 additions & 3 deletions examples/b2b/app/components/LocationSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import {CartForm} from '@shopify/hydrogen';
import type {
Company,
CompanyLocation,
CompanyLocationConnection,
} from '@shopify/hydrogen-react/customer-account-api-types';

export function LocationSelector({company}: {company: Company}) {
const locations = company?.locations?.edges
? company.locations.edges.map((loc) => {
return {...loc.node};
? company.locations.edges.map((location: CompanyLocationConnection) => {
return {...location.node};
})
: [];

Expand Down Expand Up @@ -42,7 +43,7 @@ export function LocationSelector({company}: {company: Company}) {
<CartForm route="/cart" action={CartForm.ACTIONS.BuyerIdentityUpdate}>
<fieldset>
<legend>Choose a location:</legend>
{locations.map((location) => {
{locations.map((location: CompanyLocation) => {
return (
<div key={location.id}>
<LocationItem location={location} />
Expand Down
58 changes: 39 additions & 19 deletions examples/b2b/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,15 @@ export const useRootLoaderData = () => {
return root?.data as SerializeFrom<typeof loader>;
};

export async function loader({request, context}: LoaderFunctionArgs) {
const {storefront, customerAccount, cart, session} = context;
export async function loader({context}: LoaderFunctionArgs) {
const {storefront, customerAccount, cart} = context;
const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN;

/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
const isLoggedIn = await customerAccount.isLoggedIn();
/********** EXAMPLE UPDATE END ************/
/***********************************************/
const cartPromise = cart.get();

// defer the footer query (below the fold)
Expand All @@ -93,35 +97,46 @@ export async function loader({request, context}: LoaderFunctionArgs) {
},
});

/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
// B2B buyer context
let companyLocationId = (await customerAccount.UNSTABLE_getBuyer())
?.companyLocationId;
let companyLocationId;
let company: Company;

const customer = await customerAccount.query(CUSTOMER_LOCATIONS_QUERY);
const company: Company =
customer?.data?.customer?.companyContacts?.edges?.[0]?.node?.company;
if (isLoggedIn) {
companyLocationId = (await customerAccount.UNSTABLE_getBuyer())
?.companyLocationId;

const customer = await customerAccount.query(CUSTOMER_LOCATIONS_QUERY);
company =
customer?.data?.customer?.companyContacts?.edges?.[0]?.node?.company;
}

if (!companyLocationId && company?.locations?.edges?.length === 1) {
companyLocationId = company.locations.edges[0].node.id;

customerAccount.UNSTABLE_setBuyer({
companyLocationId,
});

//updateBuyerIdentity
}

const showLocationSelector = Boolean(company && !companyLocationId);
/********** EXAMPLE UPDATE END ************/
/***********************************************/

return defer(
{
cart: cartPromise,
footer: footerPromise,
header: await headerPromise,
/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
isLoggedIn,
publicStoreDomain,
company,
showLocationSelector,
/********** EXAMPLE UPDATE END ************/
/***********************************************/
},
{
headers: {
Expand All @@ -144,16 +159,21 @@ export default function App() {
<Links />
</head>
<body>
{data.showLocationSelector ? (
<main>
<LocationSelector company={data.company} />
</main>
) : (
<Layout {...data}>
<Outlet />
</Layout>
)}

{
/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
data.showLocationSelector ? (
<main>
<LocationSelector company={data.company} />
</main>
) : (
<Layout {...data}>
<Outlet />
</Layout>
)
/********** EXAMPLE UPDATE END ************/
/***********************************************/
}
<ScrollRestoration nonce={nonce} />
<Scripts nonce={nonce} />
<LiveReload nonce={nonce} />
Expand Down
4 changes: 4 additions & 0 deletions examples/b2b/app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ const FEATURED_COLLECTION_QUERY = `#graphql
}
` as const;

/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
const RECOMMENDED_PRODUCTS_QUERY = `#graphql
fragment RecommendedProduct on Product {
id
Expand Down Expand Up @@ -143,3 +145,5 @@ const RECOMMENDED_PRODUCTS_QUERY = `#graphql
}
}
` as const;
/********** EXAMPLE UPDATE END ************/
/***********************************************/
4 changes: 4 additions & 0 deletions examples/b2b/app/routes/api.predictive-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,8 @@ export function normalizePredictiveSearchResults(
return {results, totalResults};
}

/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
const PREDICTIVE_SEARCH_QUERY = `#graphql
fragment PredictiveArticle on Article {
__typename
Expand Down Expand Up @@ -319,3 +321,5 @@ const PREDICTIVE_SEARCH_QUERY = `#graphql
}
}
` as const;
/********** EXAMPLE UPDATE END ************/
/***********************************************/
5 changes: 3 additions & 2 deletions examples/b2b/app/routes/cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {CartForm} from '@shopify/hydrogen';
import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen';
import {CartMain} from '~/components/Cart';
import {useRootLoaderData} from '~/root';
import type {CartBuyerIdentityInput} from '@shopify/hydrogen-react/storefront-api-types';
import type {CartBuyerIdentityInput} from '@shopify/hydrogen/storefront-api-types';

export const meta: MetaFunction = () => {
return [{title: `Hydrogen | Cart`}];
Expand All @@ -21,7 +21,8 @@ export async function action({request, context}: ActionFunctionArgs) {
/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/

const companyLocationId = inputs.companyLocationId as string; // fix this type properly
const companyLocationId =
inputs.companyLocationId as CartBuyerIdentityInput['companyLocationId'];

if (companyLocationId) {
context.customerAccount.UNSTABLE_setBuyer({companyLocationId});
Expand Down
4 changes: 4 additions & 0 deletions examples/b2b/app/routes/collections.$handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,8 @@ const PRODUCT_ITEM_FRAGMENT = `#graphql
` as const;

// NOTE: https://shopify.dev/docs/api/storefront/2022-04/objects/collection
/***********************************************/
/********** EXAMPLE UPDATE STARTS ************/
const COLLECTION_QUERY = `#graphql
${PRODUCT_ITEM_FRAGMENT}
query Collection(
Expand Down Expand Up @@ -182,3 +184,5 @@ const COLLECTION_QUERY = `#graphql
}
}
` as const;
/********** EXAMPLE UPDATE END ************/
/***********************************************/
Loading

0 comments on commit 60dd2fc

Please sign in to comment.