Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

passing customer login to checkout fix #1719

Merged
merged 15 commits into from
Feb 8, 2024
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
115 changes: 111 additions & 4 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
Loading