Skip to content

Commit

Permalink
Adding an example for B2B (#1886)
Browse files Browse the repository at this point in the history
* b2b example start

working queries

contextualized queries

quantity and volume prices

added header selector

login redirect

handle single location buyers

convert location selector to form

convert location dropdown to form

update input type

* remove getBuyer and move it into context automatically

move token exchange and add get set buyer

move set buyer into customerAccount

remove buyer expire

remove locations and use cart instead

automatically get buyeridentity from customerAccount

* add type, dont merge this thou, its based on unstable

* dont force login

added example comments

removed comment

added README

type updates

fixed tests

added more tests

updated types to 2024-04

simplified example

doc update

updated props to unstable

* fixes after rebase

* regenerate graphql types

* fixing types

* more type fixes

* add dts file

* fix more types

* add generated types

* cart handles quantity rules

* catch error on cart

* check for b2b customer using a provider

* fixed types

* cart update

* convert location selector to list of forms

* decouple caapi from storefront

* finished addressing feedback

* fixed types and linting

* removed unused test

* readme update

* addressed comments

* Clean up cart changes

* clean up buyer identity update

* more clean up

* removed unused imports

* update type import

* fixed typecheck

* reconstruct buyer

* add example fix for CLI

* sync server file with skeleton more closetly

* addressed comments

* update cart on logout and changeset

* added redirect issue doc

* updated changeset summary

---------

Co-authored-by: Michelle Chen <michelle.chen@shopify.com>
Co-authored-by: Helen Lin <helen.lin@shopify.com>
  • Loading branch information
3 people committed May 7, 2024
1 parent a9c6de0 commit ae262b6
Show file tree
Hide file tree
Showing 41 changed files with 4,822 additions and 41 deletions.
5 changes: 5 additions & 0 deletions .changeset/rich-rivers-nail.md
@@ -0,0 +1,5 @@
---
'@shopify/hydrogen': patch
---

Adding support for B2B to the customer account client and cart handler to store and manage [buyer context](https://shopify.dev/docs/api/storefront/2024-04/input-objects/BuyerInput). Currently Unstable.
2 changes: 2 additions & 0 deletions .graphqlrc.yml
Expand Up @@ -8,11 +8,13 @@ projects:
- 'examples/**/app/**/*.{graphql,js,ts,jsx,tsx}'
- '!templates/**/app/graphql/**/*.{graphql,js,ts,jsx,tsx}'
- '!examples/**/app/graphql/**/*.{graphql,js,ts,jsx,tsx}'
- '!packages/hydrogen/src/customer/**/*.{graphql,js,ts,jsx,tsx}'
customer-account:
schema: 'packages/hydrogen-react/customer-account.schema.json'
documents:
- 'templates/**/app/graphql/customer-account/**/*.{graphql,js,ts,jsx,tsx}'
- 'examples/**/app/graphql/customer-account/**/*.{graphql,js,ts,jsx,tsx}'
- 'packages/hydrogen/src/customer/**/*.{graphql,js,ts,jsx,tsx}'
admin:
schema: 'packages/cli/admin.schema.json'
documents: 'packages/cli/src/**/graphql/admin/**/*.ts'
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Expand Up @@ -16,6 +16,7 @@ These are some of the most commonly used Hydrogen examples. Browse the folders i

| Example | Details |
| ------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| [B2B](/examples/b2b/) | Hydrogen example of a headless B2B store front |
| [Custom Cart Method](/examples/custom-cart-method/) | How to implementation custom cart method by showing in-line product option edit in cart. |
| [Express](/examples/express/) | Hydrogen example using NodeJS [Express](https://expressjs.com/). |
| [Infinite Scroll](/examples/infinite-scroll/) | [Infinite scroll](https://shopify.dev/docs/custom-storefronts/hydrogen/data-fetching/pagination#automatically-load-pages-on-scroll) within a product collection page using the [Pagination component](https://shopify.dev/docs/api/hydrogen/2024-01/components/pagination). |
Expand Down
1 change: 1 addition & 0 deletions examples/b2b/.gitignore
@@ -0,0 +1 @@
.shopify
45 changes: 45 additions & 0 deletions examples/b2b/README.md
@@ -0,0 +1,45 @@
# Hydrogen example: B2B

> [!NOTE]
> This example is currently Unstable. There is a known issue where setting too many [Customer Account API callback URIs](https://shopify.dev/docs/custom-storefronts/building-with-the-customer-account-api/hydrogen#update-the-application-setup) will cause the hydrogen session to exceed the browsers maximum cookie length. This is because our current implementation relies on encoding redirect URIs in the token. We are aware of this issue and are actively working towards a future where this is not a problem. As a workaround you can remove unneeded callback URIs or use a different storefront.
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 such as [volume pricing and quantity rules](https://help.shopify.com/en/manual/b2b/catalogs/quantity-pricing)
4. Using a storefront `customerAccessToken` and `companyLocationId` to [contextualize queries](https://shopify.dev/docs/api/storefront#directives) using the `buyer` argument on the product display page

> [!NOTE]
> Only queries on the product display page, `app/routes/products.$handle.tsx`, were contextualized in this example. For a production storefront, all queries for product data should be contextualized.
## Install

Setup a new project with this example:

```bash
npm create @shopify/hydrogen@latest -- --template b2b
```

## 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.
Not all queries where contextualized for B2B. `app/routes/products.$handle.tsx` provides
reference on how to contextualize storefront queries. Files that aren’t included by default
with Hydrogen and that you’ll need to create are labeled with 🆕.

| File | Description |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------- |
| [`app/routes/b2blocations.tsx`](app/routes/b2blocations.tsx) | Includes a customer query to get B2B data. Set `companyLocationId` in session if there is only one location available to buy for the customer |
| [`app/components/B2BLocationProvider.tsx`](app/components/B2BLocationProvider.tsx) | Provides context on if the current logged in customer is a B2B customer and keeping track of the location modal open status. |
| 🆕 [`app/graphql/CustomerLocationsQuery.ts`](app/graphql/CustomerLocationsQuery.ts) | Customer query to fetch company locations |
| 🆕 [`app/components/B2BLocationSelector.tsx`](app/components/B2BLocationSelector.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 |
51 changes: 51 additions & 0 deletions examples/b2b/app/components/B2BLocationProvider.tsx
@@ -0,0 +1,51 @@
import {createContext, useContext, useEffect, useState, useMemo} from 'react';
import {useFetcher} from '@remix-run/react';
import {CustomerCompany} from '../root';

export type B2BLocationContextValue = {
company?: CustomerCompany;
companyLocationId?: string;
modalOpen?: boolean;
setModalOpen: (b: boolean) => void;
};

const defaultB2BLocationContextValue = {
company: undefined,
companyLocationId: undefined,
modalOpen: undefined,
setModalOpen: () => {},
};

const B2BLocationContext = createContext<B2BLocationContextValue>(
defaultB2BLocationContextValue,
);

export function B2BLocationProvider({children}: {children: React.ReactNode}) {
const fetcher = useFetcher<B2BLocationContextValue>();
const [modalOpen, setModalOpen] = useState(fetcher?.data?.modalOpen);

useEffect(() => {
if (fetcher.data || fetcher.state === 'loading') return;

fetcher.load('/b2blocations');
}, [fetcher]);

const value = useMemo<B2BLocationContextValue>(() => {
return {
...defaultB2BLocationContextValue,
...fetcher.data,
modalOpen: modalOpen ?? fetcher?.data?.modalOpen,
setModalOpen,
};
}, [fetcher, modalOpen]);

return (
<B2BLocationContext.Provider value={value}>
{children}
</B2BLocationContext.Provider>
);
}

export function useB2BLocation(): B2BLocationContextValue {
return useContext(B2BLocationContext);
}
68 changes: 68 additions & 0 deletions examples/b2b/app/components/B2BLocationSelector.tsx
@@ -0,0 +1,68 @@
import {CartForm} from '@shopify/hydrogen';
import type {
CustomerCompanyLocation,
CustomerCompanyLocationConnection,
} from '~/root';
import {useB2BLocation} from './B2BLocationProvider';

export function B2BLocationSelector() {
const {company, modalOpen, setModalOpen} = useB2BLocation();

const locations = company?.locations?.edges
? company.locations.edges.map(
(location: CustomerCompanyLocationConnection) => {
return {...location.node};
},
)
: [];

if (!company || !modalOpen) return null;

return (
<div className="modal">
<div className="modal-content">
<h2>Logged in for {company.name}</h2>
<legend>Choose a location:</legend>
<div className="location-list">
{locations.map((location: CustomerCompanyLocation) => {
const addressLines =
location?.shippingAddress?.formattedAddress ?? [];
return (
<CartForm
key={location.id}
route="/cart"
action={CartForm.ACTIONS.BuyerIdentityUpdate}
inputs={{
buyerIdentity: {companyLocationId: location.id},
}}
>
{(fetcher) => (
<label>
<button
onClick={(event) => {
setModalOpen(false);
fetcher.submit(event.currentTarget.form, {
method: 'POST',
});
}}
className="location-item"
>
<div>
<p>
<strong>{location.name}</strong>
</p>
{addressLines.map((line: string) => (
<p key={line}>{line}</p>
))}
</div>
</button>
</label>
)}
</CartForm>
);
})}
</div>
</div>
</div>
);
}

0 comments on commit ae262b6

Please sign in to comment.