Skip to content

Commit

Permalink
passing customer login to checkout fix (#1719)
Browse files Browse the repository at this point in the history
* pass login param to checkout

* dont use chaining to update cookie

* refactor cart promise

* move to cart.get method

* update roots

* update doc

* fix no cart issue

* add doc

* Improve the Customer Account API README instrunctions

* Fix orders route missing fullfilmentStatus

* Add CSP ngrok domain instructions for connect-src

* Fix ngrok CSP rule

* add buyer identity action back

* remove csp option

* ♻️ the api to pass customerAccount into cart from the server

---------

Co-authored-by: Juan P. Prieto <jp@calltheguys.co>
  • Loading branch information
michenly and juanpprieto authored Feb 8, 2024
1 parent fcecfb2 commit c0ec771
Show file tree
Hide file tree
Showing 18 changed files with 255 additions and 38 deletions.
7 changes: 7 additions & 0 deletions .changeset/soft-sloths-double.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'skeleton': patch
'@shopify/hydrogen': patch
---

🐛 Fix issue where customer login does not persist to checkout
✨ Add `customerAccount` option to `createCartHandler`. Where a `?logged_in=true` will be added to the checkoutUrl for cart query if a customer is logged in.
1 change: 1 addition & 0 deletions packages/cli/src/lib/setups/i18n/replacers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ describe('i18n replacers', () => {
*/
const cart = createCartHandler({
storefront,
customerAccount,
getCartId: cartGetIdDefault(request.headers),
setCartId: cartSetIdDefault(),
cartQueryFragment: CART_QUERY_FRAGMENT,
Expand Down
106 changes: 103 additions & 3 deletions packages/hydrogen/docs/generated/generated_docs_data.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions packages/hydrogen/src/cart/cart-test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import {CacheNone} from '../cache/strategies';

export const CART_ID = 'gid://shopify/Cart/c1-123';
export const NEW_CART_ID = 'c1-new-cart-id';
export const CHECKOUT_URL =
'https://demostore.mock.shop/cart/c/Z2NwLXVzLWNlbnRyYWwxOjAxSE5aSFBWVjhKSEc5NDA5MTlWM0ZTUVJE?key=66f3266a23df83f84f2aee087ec244b2';

function storefrontQuery(
query: string,
Expand All @@ -16,6 +18,7 @@ function storefrontQuery(
return Promise.resolve({
cart: {
id: payload?.variables?.cartId,
checkoutUrl: CHECKOUT_URL,
query,
},
});
Expand Down
4 changes: 4 additions & 0 deletions packages/hydrogen/src/cart/createCartHandler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {Storefront} from '../storefront';
import type {CustomerAccount} from '../customer/types';
import {type CartGetFunction, cartGetDefault} from './queries/cartGetDefault';
import {
type CartCreateFunction,
Expand Down Expand Up @@ -47,6 +48,7 @@ import {

export type CartHandlerOptions = {
storefront: Storefront;
customerAccount?: CustomerAccount;
getCartId: () => string | undefined;
setCartId: (cartId: string) => Headers;
cartQueryFragment?: string;
Expand Down Expand Up @@ -97,6 +99,7 @@ export function createCartHandler<TCustomMethods extends CustomMethodsBase>(
getCartId,
setCartId,
storefront,
customerAccount,
cartQueryFragment,
cartMutateFragment,
} = options;
Expand All @@ -113,6 +116,7 @@ export function createCartHandler<TCustomMethods extends CustomMethodsBase>(
const methods: HydrogenCart = {
get: cartGetDefault({
storefront,
customerAccount,
getCartId,
cartFragment: cartQueryFragment,
}),
Expand Down
40 changes: 40 additions & 0 deletions packages/hydrogen/src/cart/queries/cartGetDefault.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {describe, it, expect} from 'vitest';
import {cartGetDefault} from './cartGetDefault';
import {CART_ID, mockCreateStorefrontClient} from '../cart-test-helper';
import type {CustomerAccount} from '../../customer/types';

describe('cartGetDefault', () => {
it('should return a default cart get implementation', async () => {
Expand Down Expand Up @@ -40,4 +41,43 @@ describe('cartGetDefault', () => {
// @ts-expect-error
expect(result.query).toContain(cartFragment);
});

it('should return a cartId passed in', async () => {
const cartGet = cartGetDefault({
storefront: mockCreateStorefrontClient(),
getCartId: () => CART_ID,
});

const result = await cartGet({cartId: 'gid://shopify/Cart/c1-456'});

expect(result).toHaveProperty('id', 'gid://shopify/Cart/c1-456');
});

describe('run with customerAccount option', () => {
it('should add logged_in search param to checkout link if customer is logged in', async () => {
const cartGet = cartGetDefault({
storefront: mockCreateStorefrontClient(),
customerAccount: {
isLoggedIn: () => Promise.resolve(true),
} as CustomerAccount,
getCartId: () => CART_ID,
});

const result = await cartGet();
expect(result?.checkoutUrl).toContain('logged_in=true');
});

it('should NOT add logged_in search param to checkout link if customer is NOT logged in', async () => {
const cartGet = cartGetDefault({
storefront: mockCreateStorefrontClient(),
customerAccount: {
isLoggedIn: () => Promise.resolve(false),
} as CustomerAccount,
getCartId: () => CART_ID,
});

const result = await cartGet();
expect(result?.checkoutUrl).not.toContain('logged_in=true');
});
});
});
55 changes: 42 additions & 13 deletions packages/hydrogen/src/cart/queries/cartGetDefault.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {StorefrontApiErrors, formatAPIResult} from '../../storefront';
import type {CustomerAccount} from '../../customer/types';
import type {CartQueryOptions, CartReturn} from './cart-types';
import type {
Cart,
Expand Down Expand Up @@ -33,27 +34,55 @@ export type CartGetFunction = (
cartInput?: CartGetProps,
) => Promise<CartReturn | null>;

export function cartGetDefault(options: CartQueryOptions): CartGetFunction {
type CartGetOptions = CartQueryOptions & {
/**
* The customer account client instance created by [`createCustomerAccountClient`](docs/api/hydrogen/latest/utilities/createcustomeraccountclient).
*/
customerAccount?: CustomerAccount;
};

export function cartGetDefault({
storefront,
customerAccount,
getCartId,
cartFragment,
}: CartGetOptions): CartGetFunction {
return async (cartInput?: CartGetProps) => {
const cartId = options.getCartId();
const cartId = getCartId();

if (!cartId) return null;

const {cart, errors} = await options.storefront.query<{
cart: Cart;
errors: StorefrontApiErrors;
}>(CART_QUERY(options.cartFragment), {
variables: {
cartId,
...cartInput,
},
cache: options.storefront.CacheNone(),
});
const [isCustomerLoggedIn, {cart, errors}] = await Promise.all([
customerAccount ? customerAccount.isLoggedIn() : false,
storefront.query<{
cart: Cart;
errors: StorefrontApiErrors;
}>(CART_QUERY(cartFragment), {
variables: {
cartId,
...cartInput,
},
cache: storefront.CacheNone(),
}),
]);

return formatAPIResult(cart, errors);
return formatAPIResult(
addCustomerLoggedInParam(isCustomerLoggedIn, cart),
errors,
);
};
}

function addCustomerLoggedInParam(isCustomerLoggedIn: boolean, cart: Cart) {
if (isCustomerLoggedIn && cart && cart.checkoutUrl) {
const finalCheckoutUrl = new URL(cart.checkoutUrl);
finalCheckoutUrl.searchParams.set('logged_in', 'true');
cart.checkoutUrl = finalCheckoutUrl.toString();
}

return cart;
}

//! @see https://shopify.dev/docs/api/storefront/latest/queries/cart
const CART_QUERY = (cartFragment = DEFAULT_CART_FRAGMENT) => `#graphql
query CartQuery(
Expand Down
4 changes: 2 additions & 2 deletions packages/hydrogen/src/customer/BadRequest.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
export class BadRequest extends Response {
constructor(message?: string, helpMessage?: string) {
constructor(message?: string, helpMessage?: string, headers?: HeadersInit) {
// A lot of things can go wrong when configuring the customer account api
// oauth flow. In dev mode, log a helper message.
if (helpMessage && process.env.NODE_ENV === 'development') {
console.error('Customer Account API Error: ' + helpMessage);
}

super(`Bad request: ${message}`, {status: 400});
super(`Bad request: ${message}`, {status: 400, headers});
}
}
3 changes: 3 additions & 0 deletions packages/hydrogen/src/customer/auth.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ export async function checkExpires({
throw new BadRequest(
'Unauthorized',
'Login before querying the Customer Account API.',
{
'Set-Cookie': await session.commit(),
},
);
}
}
Expand Down
15 changes: 14 additions & 1 deletion packages/hydrogen/src/customer/customer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ export function createCustomerAccountClient({
if (response.status === 401) {
// clear session because current access token is invalid
clearSession(session);
throw authStatusHandler();

const authFailResponse = authStatusHandler();
if (authFailResponse instanceof Response) {
authFailResponse.headers.set('Set-Cookie', await session.commit());
}
throw authFailResponse;
}

/**
Expand Down Expand Up @@ -302,17 +307,25 @@ export function createCustomerAccountClient({

if (!code || !state) {
clearSession(session);

throw new BadRequest(
'Unauthorized',
'No code or state parameter found in the redirect URL.',
{
'Set-Cookie': await session.commit(),
},
);
}

if (session.get(CUSTOMER_ACCOUNT_SESSION_KEY)?.state !== state) {
clearSession(session);

throw new BadRequest(
'Unauthorized',
'The session state does not match the state parameter. Make sure that the session is configured correctly and passed to `createCustomerAccountClient`.',
{
'Set-Cookie': await session.commit(),
},
);
}

Expand Down
8 changes: 5 additions & 3 deletions templates/demo-store/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,11 @@ export const useRootLoaderData = () => {
};

export async function loader({request, context}: LoaderFunctionArgs) {
const {storefront, cart} = context;
const {storefront, customerAccount, cart} = context;
const layout = await getLayoutData(context);
const isLoggedInPromise = context.customerAccount.isLoggedIn();

const isLoggedInPromise = customerAccount.isLoggedIn();
const cartPromise = cart.get();

const seo = seoPayload.root({shop: layout.shop, url: request.url});

Expand All @@ -83,7 +85,7 @@ export async function loader({request, context}: LoaderFunctionArgs) {
isLoggedIn: isLoggedInPromise,
layout,
selectedLocale: storefront.i18n,
cart: cart.get(),
cart: cartPromise,
analytics: {
shopifySalesChannel: ShopifySalesChannel.hydrogen,
shopId: layout.shop.id,
Expand Down
6 changes: 1 addition & 5 deletions templates/demo-store/app/routes/($locale).cart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,7 @@ import {useRootLoaderData} from '~/root';
export async function action({request, context}: ActionFunctionArgs) {
const {cart} = context;

const [formData, customerAccessToken] = await Promise.all([
request.formData(),
context.customerAccount.getAccessToken(),
]);
const formData = await request.formData();

const {action, inputs} = CartForm.getFormInput(formData);
invariant(action, 'No cartAction defined');
Expand Down Expand Up @@ -51,7 +48,6 @@ export async function action({request, context}: ActionFunctionArgs) {
case CartForm.ACTIONS.BuyerIdentityUpdate:
result = await cart.updateBuyerIdentity({
...inputs.buyerIdentity,
customerAccessToken,
});
break;
default:
Expand Down
1 change: 1 addition & 0 deletions templates/demo-store/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ export default {

const cart = createCartHandler({
storefront,
customerAccount,
getCartId: cartGetIdDefault(request.headers),
setCartId: cartSetIdDefault(),
});
Expand Down
27 changes: 25 additions & 2 deletions templates/skeleton/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,46 @@ npm run dev

## Setup for using Customer Account API (`/account` section)

### Enabled new Customer Account Experience

1. Go to your Shopify admin => Settings => Customer accounts => New customer account

### Setup public domain using ngrok

1. Setup a [ngrok](https://ngrok.com/) account and add a permanent domain (ie. `https://<your-ngrok-domain>.app`).
1. Install the [ngrok CLI](https://ngrok.com/download) to use in terminal
1. Start ngrok using `ngrok http --domain=<your-ngrok-domain>.app 3000`

> [!IMPORTANT]
> To successfully interact with the Customer Account API routes you will need to use the ngrok domain during development instead of localhost
### Include public domain in Customer Account API settings

1. Go to your Shopify admin => `Hydrogen` or `Headless` app/channel => Customer Account API => Application setup
1. Edit `Callback URI(s)` to include `https://<your-ngrok-domain>.app/account/authorize`
1. Edit `Javascript origin(s)` to include your public domain `https://<your-ngrok-domain>.app` or keep it blank
1. Edit `Logout URI` to include your public domain `https://<your-ngrok-domain>.app` or keep it blank

### Add the ngrok domain to the CSP policy

Modify your `/app/entry.server.tsx` to allow the ngrok domain as a connect-src

```diff
- const {nonce, header, NonceProvider} = createContentSecurityPolicy()
+ const {nonce, header, NonceProvider} = createContentSecurityPolicy({
+ connectSrc: [
+ 'wss://<your-ngrok-domain>.app:*', // Your ngrok websocket domain
+ ],
+ });
```

### Prepare Environment variables

Run [`npx shopify hydrogen link`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#link) or [`npx shopify hydrogen env pull`](https://shopify.dev/docs/custom-storefronts/hydrogen/cli#env-pull) to link this app to your own test shop.

Alternatly, the values of the required environment varaibles "PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID" and "PUBLIC_CUSTOMER_ACCOUNT_API_URL" can be found in customer account api settings in the Hydrogen admin channel.
Alternately, the values of the required environment variables "PUBLIC_CUSTOMER_ACCOUNT_API_CLIENT_ID" and "PUBLIC_CUSTOMER_ACCOUNT_API_URL" can be found in customer account api settings in the Hydrogen admin channel.

> [!IMPORTANT]
> Note that `mock.shop` doesn't supply these variables automatically and your own test shop is required for using Customer Account API
🗒️ Note that mock.shop doesn't supply these variables automatically.
> [!NOTE]
> B2B features such as contextual pricing is not available in SF API with Customer Account API login. If you require this feature, we suggest using the [legacy-customer-account-flow](https://github.com/Shopify/hydrogen/tree/main/examples/legacy-customer-account-flow). This feature should be available in the Customer Account API in the 2024-04 release.
2 changes: 0 additions & 2 deletions templates/skeleton/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,6 @@ export async function loader({context}: LoaderFunctionArgs) {
const publicStoreDomain = context.env.PUBLIC_STORE_DOMAIN;

const isLoggedInPromise = customerAccount.isLoggedIn();

// defer the cart query by not awaiting it
const cartPromise = cart.get();

// defer the footer query (below the fold)
Expand Down
4 changes: 2 additions & 2 deletions templates/skeleton/app/routes/account.orders._index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,7 @@ function EmptyOrders() {
}

function OrderItem({order}: {order: OrderItemFragment}) {
const fulfillmentStatus = flattenConnection(order.fulfillments)[0].status;
const fulfillmentStatus = flattenConnection(order.fulfillments)[0]?.status;
return (
<>
<fieldset>
Expand All @@ -104,7 +104,7 @@ function OrderItem({order}: {order: OrderItemFragment}) {
</Link>
<p>{new Date(order.processedAt).toDateString()}</p>
<p>{order.financialStatus}</p>
<p>{fulfillmentStatus}</p>
{fulfillmentStatus && <p>{fulfillmentStatus}</p>}
<Money data={order.totalPrice} />
<Link to={`/account/orders/${btoa(order.id)}`}>View Order →</Link>
</fieldset>
Expand Down
Loading

0 comments on commit c0ec771

Please sign in to comment.