mirror of
https://github.com/vercel/commerce.git
synced 2025-07-22 20:26:49 +00:00
add order details page
This commit is contained in:
@@ -1,12 +1,59 @@
|
||||
//you need to remain this as type so as not to confuse with the actual function
|
||||
import type { NextRequest, NextResponse as NextResponseType } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getNonce } from 'lib/shopify/customer/auth-utils';
|
||||
import {
|
||||
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
|
||||
SHOPIFY_USER_AGENT,
|
||||
SHOPIFY_CLIENT_ID
|
||||
} from './constants';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const CUSTOMER_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL!;
|
||||
export const CUSTOMER_API_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || '';
|
||||
export const ORIGIN_URL = process.env.SHOPIFY_ORIGIN_URL || '';
|
||||
export const USER_AGENT = '*';
|
||||
|
||||
export async function generateCodeVerifier() {
|
||||
const randomCode = generateRandomCode();
|
||||
return base64UrlEncode(randomCode);
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(codeVerifier: string) {
|
||||
const digestOp = await crypto.subtle.digest(
|
||||
{ name: 'SHA-256' },
|
||||
new TextEncoder().encode(codeVerifier)
|
||||
);
|
||||
const hash = convertBufferToString(digestOp);
|
||||
return base64UrlEncode(hash);
|
||||
}
|
||||
|
||||
function generateRandomCode() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return String.fromCharCode.apply(null, Array.from(array));
|
||||
}
|
||||
|
||||
function base64UrlEncode(str: string) {
|
||||
const base64 = btoa(str);
|
||||
// This is to ensure that the encoding does not have +, /, or = characters in it.
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
|
||||
function convertBufferToString(hash: ArrayBuffer) {
|
||||
const uintArray = new Uint8Array(hash);
|
||||
const numberArray = Array.from(uintArray);
|
||||
return String.fromCharCode(...numberArray);
|
||||
}
|
||||
|
||||
export async function generateRandomString() {
|
||||
const timestamp = Date.now().toString();
|
||||
const randomString = Math.random().toString(36).substring(2);
|
||||
return timestamp + randomString;
|
||||
}
|
||||
|
||||
export async function getNonce(token: string) {
|
||||
const [header, payload, signature] = token.split('.');
|
||||
const decodedHeader = JSON.parse(atob(header || ''));
|
||||
const decodedPayload = JSON.parse(atob(payload || ''));
|
||||
return {
|
||||
header: decodedHeader,
|
||||
payload: decodedPayload,
|
||||
signature
|
||||
};
|
||||
}
|
||||
|
||||
export async function initialAccessToken(
|
||||
request: NextRequest,
|
||||
@@ -58,29 +105,30 @@ export async function initialAccessToken(
|
||||
headersNew.append('User-Agent', userAgent);
|
||||
headersNew.append('Origin', newOrigin || '');
|
||||
const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`;
|
||||
console.log('sending request to', tokenRequestUrl);
|
||||
|
||||
const response = await fetch(tokenRequestUrl, {
|
||||
method: 'POST',
|
||||
headers: headersNew,
|
||||
body
|
||||
});
|
||||
const data = await response.json();
|
||||
console.log('data initial access token', data);
|
||||
console.log('ok', response.ok);
|
||||
if (!response.ok) {
|
||||
console.log('data response error auth', data.error);
|
||||
const error = await response.text();
|
||||
console.log('data response error auth', error);
|
||||
console.log('response auth', response.status);
|
||||
return { success: false, message: `Response error auth` };
|
||||
}
|
||||
const data = await response.json();
|
||||
if (data?.errors) {
|
||||
const errorMessage = data?.errors?.[0]?.message ?? 'Unknown error auth';
|
||||
return { success: false, message: `${errorMessage}` };
|
||||
}
|
||||
const nonce = await getNonce(data?.id_token || '');
|
||||
const nonceValue = nonce.payload.nonce;
|
||||
const shopNonce = request.cookies.get('shop_nonce');
|
||||
const shopNonceValue = shopNonce?.value;
|
||||
console.log('sent nonce', nonce);
|
||||
console.log('original nonce', shopNonceValue);
|
||||
if (nonce !== shopNonceValue) {
|
||||
//make equal === to force error for testing
|
||||
if (nonceValue !== shopNonceValue) {
|
||||
console.log('Error nonce match');
|
||||
return { success: false, message: `Error: Nonce mismatch` };
|
||||
}
|
||||
@@ -134,18 +182,16 @@ export async function refreshToken({ request, origin }: { request: NextRequest;
|
||||
console.log('Error: No Refresh Token');
|
||||
return { success: false, message: `no_refresh_token` };
|
||||
}
|
||||
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
|
||||
const clientId = SHOPIFY_CLIENT_ID;
|
||||
const userAgent = SHOPIFY_USER_AGENT;
|
||||
const clientId = CUSTOMER_API_CLIENT_ID;
|
||||
newBody.append('grant_type', 'refresh_token');
|
||||
newBody.append('refresh_token', refreshTokenValue);
|
||||
newBody.append('client_id', clientId);
|
||||
const headers = {
|
||||
'content-type': 'application/x-www-form-urlencoded',
|
||||
'User-Agent': userAgent,
|
||||
'User-Agent': USER_AGENT,
|
||||
Origin: origin
|
||||
};
|
||||
const tokenRequestUrl = `${customerAccountApiUrl}/auth/oauth/token`;
|
||||
const tokenRequestUrl = `${CUSTOMER_API_URL}/auth/oauth/token`;
|
||||
const response = await fetch(tokenRequestUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
@@ -164,7 +210,7 @@ export async function refreshToken({ request, origin }: { request: NextRequest;
|
||||
const customerAccessToken = await exchangeAccessToken(
|
||||
access_token,
|
||||
clientId,
|
||||
customerAccountApiUrl,
|
||||
CUSTOMER_API_URL,
|
||||
origin
|
||||
);
|
||||
// console.log("Customer Access Token in refresh request", customerAccessToken)
|
||||
@@ -203,7 +249,7 @@ export async function checkExpires({
|
||||
return { ranRefresh: isExpired, success: true };
|
||||
}
|
||||
|
||||
export function removeAllCookies(response: NextResponseType) {
|
||||
export function removeAllCookies(response: NextResponse) {
|
||||
//response.cookies.delete('shop_auth_token') //never set. We don't use it anywhere.
|
||||
response.cookies.delete('shop_customer_token');
|
||||
response.cookies.delete('shop_refresh_token');
|
||||
@@ -234,7 +280,7 @@ export async function createAllCookies({
|
||||
expiresAt,
|
||||
id_token
|
||||
}: {
|
||||
response: NextResponseType;
|
||||
response: NextResponse;
|
||||
customerAccessToken: string;
|
||||
expires_in: number;
|
||||
refresh_token: string;
|
||||
@@ -284,3 +330,181 @@ export async function createAllCookies({
|
||||
|
||||
return response;
|
||||
}
|
||||
export async function isLoggedIn(request: NextRequest, origin: string) {
|
||||
const customerToken = request.cookies.get('shop_customer_token');
|
||||
const customerTokenValue = customerToken?.value;
|
||||
const refreshToken = request.cookies.get('shop_refresh_token');
|
||||
const refreshTokenValue = refreshToken?.value;
|
||||
|
||||
console.log('customer token', customerTokenValue);
|
||||
const newHeaders = new Headers(request.headers);
|
||||
if (!customerTokenValue && !refreshTokenValue) {
|
||||
const redirectUrl = new URL(`${origin}`);
|
||||
const response = NextResponse.redirect(`${redirectUrl}`);
|
||||
return removeAllCookies(response);
|
||||
}
|
||||
|
||||
const expiresToken = request.cookies.get('shop_expires_at');
|
||||
const expiresTokenValue = expiresToken?.value;
|
||||
if (!expiresTokenValue) {
|
||||
const redirectUrl = new URL(`${origin}`);
|
||||
const response = NextResponse.redirect(`${redirectUrl}`);
|
||||
return removeAllCookies(response);
|
||||
//return { success: false, message: `no_expires_at` }
|
||||
}
|
||||
const isExpired = await checkExpires({
|
||||
request: request,
|
||||
expiresAt: expiresTokenValue,
|
||||
origin: origin
|
||||
});
|
||||
console.log('is Expired?', isExpired);
|
||||
//only execute the code below to reset the cookies if it was expired!
|
||||
if (isExpired.ranRefresh) {
|
||||
const isSuccess = isExpired?.refresh?.success;
|
||||
if (!isSuccess) {
|
||||
const redirectUrl = new URL(`${origin}`);
|
||||
const response = NextResponse.redirect(`${redirectUrl}`);
|
||||
return removeAllCookies(response);
|
||||
//return { success: false, message: `no_refresh_token` }
|
||||
} else {
|
||||
const refreshData = isExpired?.refresh?.data;
|
||||
//console.log ("refresh data", refreshData)
|
||||
console.log('We used the refresh token, so now going to reset the token and cookies');
|
||||
const newCustomerAccessToken = refreshData?.customerAccessToken;
|
||||
const expires_in = refreshData?.expires_in;
|
||||
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
|
||||
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
|
||||
newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`);
|
||||
const resetCookieResponse = NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
return await createAllCookies({
|
||||
response: resetCookieResponse,
|
||||
customerAccessToken: newCustomerAccessToken,
|
||||
expires_in,
|
||||
refresh_token: refreshData?.refresh_token,
|
||||
expiresAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
newHeaders.set('x-shop-customer-token', `${customerTokenValue}`);
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//when we are running on the production website we just get the origin from the request.nextUrl
|
||||
export function getOrigin(request: NextRequest) {
|
||||
const nextOrigin = request.nextUrl.origin;
|
||||
console.log('Current Origin', nextOrigin);
|
||||
//when running localhost, we want to use fake origin otherwise we use the real origin
|
||||
let newOrigin = nextOrigin;
|
||||
if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') {
|
||||
newOrigin = ORIGIN_URL;
|
||||
} else {
|
||||
newOrigin = nextOrigin;
|
||||
}
|
||||
console.log('New Origin', newOrigin);
|
||||
return newOrigin;
|
||||
}
|
||||
|
||||
export async function authorize(request: NextRequest, origin: string) {
|
||||
const clientId = CUSTOMER_API_CLIENT_ID;
|
||||
const newHeaders = new Headers(request.headers);
|
||||
/***
|
||||
STEP 1: Get the initial access token or deny access
|
||||
****/
|
||||
const dataInitialToken = await initialAccessToken(request, origin, CUSTOMER_API_URL, clientId);
|
||||
console.log('data initial token', dataInitialToken);
|
||||
if (!dataInitialToken.success) {
|
||||
console.log('Error: Access Denied. Check logs', dataInitialToken.message);
|
||||
newHeaders.set('x-shop-access', 'denied');
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data;
|
||||
/***
|
||||
STEP 2: Get a Customer Access Token
|
||||
****/
|
||||
const customerAccessToken = await exchangeAccessToken(
|
||||
access_token,
|
||||
clientId,
|
||||
CUSTOMER_API_URL,
|
||||
origin || ''
|
||||
);
|
||||
console.log('customer access token', customerAccessToken);
|
||||
if (!customerAccessToken.success) {
|
||||
console.log('Error: Customer Access Token');
|
||||
newHeaders.set('x-shop-access', 'denied');
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
//console.log("customer access Token", customerAccessToken.data.access_token)
|
||||
/**STEP 3: Set Customer Access Token cookies
|
||||
We are setting the cookies here b/c if we set it on the request, and then redirect
|
||||
it doesn't see to set sometimes
|
||||
**/
|
||||
newHeaders.set('x-shop-access', 'allowed');
|
||||
/*
|
||||
const authResponse = NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders,
|
||||
},
|
||||
})
|
||||
*/
|
||||
const accountUrl = new URL(`${origin}/account`);
|
||||
const authResponse = NextResponse.redirect(`${accountUrl}`);
|
||||
|
||||
//sets an expires time 2 minutes before expiration which we can use in refresh strategy
|
||||
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
|
||||
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
|
||||
console.log('expires at', expiresAt);
|
||||
|
||||
return await createAllCookies({
|
||||
response: authResponse,
|
||||
customerAccessToken: customerAccessToken?.data?.access_token,
|
||||
expires_in,
|
||||
refresh_token,
|
||||
expiresAt,
|
||||
id_token
|
||||
});
|
||||
}
|
||||
|
||||
export async function logout(request: NextRequest, origin: string) {
|
||||
//console.log("New Origin", newOrigin)
|
||||
const idToken = request.cookies.get('shop_id_token');
|
||||
const idTokenValue = idToken?.value;
|
||||
//revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now
|
||||
|
||||
//if there is no idToken, then sending to logout url will redirect shopify, so just
|
||||
//redirect to login here and delete cookies (presumably they don't even exist)
|
||||
if (!idTokenValue) {
|
||||
const logoutUrl = new URL(`${origin}/login`);
|
||||
const response = NextResponse.redirect(`${logoutUrl}`);
|
||||
return removeAllCookies(response);
|
||||
}
|
||||
|
||||
//console.log ("id toke value", idTokenValue)
|
||||
const logoutUrl = new URL(
|
||||
`${CUSTOMER_API_URL}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
|
||||
);
|
||||
//console.log ("logout url", logoutUrl)
|
||||
const logoutResponse = NextResponse.redirect(logoutUrl);
|
||||
return removeAllCookies(logoutResponse);
|
||||
}
|
@@ -1,48 +0,0 @@
|
||||
// @ts-nocheck
|
||||
export async function generateCodeVerifier() {
|
||||
const randomCode = generateRandomCode();
|
||||
return base64UrlEncode(randomCode);
|
||||
}
|
||||
export async function generateCodeChallenge(codeVerifier: string) {
|
||||
const digestOp = await crypto.subtle.digest(
|
||||
{ name: 'SHA-256' },
|
||||
new TextEncoder().encode(codeVerifier)
|
||||
);
|
||||
const hash = convertBufferToString(digestOp);
|
||||
return base64UrlEncode(hash);
|
||||
}
|
||||
function generateRandomCode() {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return String.fromCharCode.apply(null, Array.from(array));
|
||||
}
|
||||
function base64UrlEncode(str: string) {
|
||||
const base64 = btoa(str);
|
||||
// This is to ensure that the encoding does not have +, /, or = characters in it.
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
|
||||
}
|
||||
function convertBufferToString(hash: ArrayBuffer) {
|
||||
const uintArray = new Uint8Array(hash);
|
||||
const numberArray = Array.from(uintArray);
|
||||
return String.fromCharCode(...numberArray);
|
||||
}
|
||||
|
||||
export async function generateRandomString() {
|
||||
const timestamp = Date.now().toString();
|
||||
const randomString = Math.random().toString(36).substring(2);
|
||||
return timestamp + randomString;
|
||||
}
|
||||
|
||||
export async function getNonce(token: string) {
|
||||
return decodeJwt(token).payload.nonce;
|
||||
}
|
||||
function decodeJwt(token: string) {
|
||||
const [header, payload, signature] = token.split('.');
|
||||
const decodedHeader = JSON.parse(atob(header || ''));
|
||||
const decodedPayload = JSON.parse(atob(payload || ''));
|
||||
return {
|
||||
header: decodedHeader,
|
||||
payload: decodedPayload,
|
||||
signature
|
||||
};
|
||||
}
|
@@ -1,10 +0,0 @@
|
||||
export const TAGS = {
|
||||
customer: 'customer'
|
||||
};
|
||||
|
||||
//ENVs
|
||||
export const SHOPIFY_CUSTOMER_ACCOUNT_API_URL = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL || '';
|
||||
export const SHOPIFY_CLIENT_ID = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID || '';
|
||||
export const SHOPIFY_CUSTOMER_API_VERSION = process.env.SHOPIFY_CUSTOMER_API_VERSION || '';
|
||||
export const SHOPIFY_USER_AGENT = '*';
|
||||
export const SHOPIFY_ORIGIN = process.env.SHOPIFY_ORIGIN_URL || '';
|
@@ -1,285 +0,0 @@
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
checkExpires,
|
||||
removeAllCookies,
|
||||
initialAccessToken,
|
||||
exchangeAccessToken,
|
||||
createAllCookies
|
||||
} from './auth-helpers';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
import { parseJSON } from 'lib/shopify/customer/utils/parse-json';
|
||||
import {
|
||||
SHOPIFY_CUSTOMER_ACCOUNT_API_URL,
|
||||
SHOPIFY_USER_AGENT,
|
||||
SHOPIFY_CUSTOMER_API_VERSION,
|
||||
SHOPIFY_CLIENT_ID,
|
||||
SHOPIFY_ORIGIN
|
||||
} from './constants';
|
||||
|
||||
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
||||
const customerAccountApiUrl = SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
|
||||
const apiVersion = SHOPIFY_CUSTOMER_API_VERSION;
|
||||
const userAgent = SHOPIFY_USER_AGENT;
|
||||
const customerEndpoint = `${customerAccountApiUrl}/account/customer/api/${apiVersion}/graphql`;
|
||||
|
||||
//NEVER CACHE THIS! Doesn't see to be cached anyway b/c
|
||||
//https://nextjs.org/docs/app/building-your-application/data-fetching/fetching-caching-and-revalidating#opting-out-of-data-caching
|
||||
//The fetch request comes after the usage of headers or cookies.
|
||||
//and we always send this anyway after getting a cookie for the customer
|
||||
export async function shopifyCustomerFetch<T>({
|
||||
customerToken,
|
||||
query,
|
||||
tags,
|
||||
variables
|
||||
}: {
|
||||
cache?: RequestCache;
|
||||
customerToken: string;
|
||||
query: string;
|
||||
tags?: string[];
|
||||
variables?: ExtractVariables<T>;
|
||||
}): Promise<{ status: number; body: T } | never> {
|
||||
try {
|
||||
const customerOrigin = SHOPIFY_ORIGIN;
|
||||
const result = await fetch(customerEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
Origin: customerOrigin,
|
||||
Authorization: customerToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(query && { query }),
|
||||
...(variables && { variables })
|
||||
}),
|
||||
cache: 'no-store',
|
||||
...(tags && { next: { tags } })
|
||||
});
|
||||
|
||||
const body = await result.json();
|
||||
|
||||
if (!result.ok) {
|
||||
//the statuses here could be different, a 401 means
|
||||
//https://shopify.dev/docs/api/customer#endpoints
|
||||
//401 means the token is bad
|
||||
console.log('Error in Customer Fetch Status', body.errors);
|
||||
if (result.status === 401) {
|
||||
// clear session because current access token is invalid
|
||||
const errorMessage = 'unauthorized';
|
||||
throw errorMessage; //this should throw in the catch below in the non-shopify catch
|
||||
}
|
||||
let errors;
|
||||
try {
|
||||
errors = parseJSON(body);
|
||||
} catch (_e) {
|
||||
errors = [{ message: body }];
|
||||
}
|
||||
throw errors;
|
||||
}
|
||||
|
||||
//this just throws an error and the error boundary is called
|
||||
if (body.errors) {
|
||||
//throw 'Error'
|
||||
console.log('Error in Customer Fetch', body.errors[0]);
|
||||
throw body.errors[0];
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
};
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
throw {
|
||||
cause: e.cause?.toString() || 'unknown',
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function isLoggedIn(request: NextRequest, origin: string) {
|
||||
const customerToken = request.cookies.get('shop_customer_token');
|
||||
const customerTokenValue = customerToken?.value;
|
||||
const refreshToken = request.cookies.get('shop_refresh_token');
|
||||
const refreshTokenValue = refreshToken?.value;
|
||||
const newHeaders = new Headers(request.headers);
|
||||
if (!customerTokenValue && !refreshTokenValue) {
|
||||
const redirectUrl = new URL(`${origin}`);
|
||||
const response = NextResponse.redirect(`${redirectUrl}`);
|
||||
return removeAllCookies(response);
|
||||
}
|
||||
|
||||
const expiresToken = request.cookies.get('shop_expires_at');
|
||||
const expiresTokenValue = expiresToken?.value;
|
||||
if (!expiresTokenValue) {
|
||||
const redirectUrl = new URL(`${origin}`);
|
||||
const response = NextResponse.redirect(`${redirectUrl}`);
|
||||
return removeAllCookies(response);
|
||||
//return { success: false, message: `no_expires_at` }
|
||||
}
|
||||
const isExpired = await checkExpires({
|
||||
request: request,
|
||||
expiresAt: expiresTokenValue,
|
||||
origin: origin
|
||||
});
|
||||
console.log('is Expired?', isExpired);
|
||||
//only execute the code below to reset the cookies if it was expired!
|
||||
if (isExpired.ranRefresh) {
|
||||
const isSuccess = isExpired?.refresh?.success;
|
||||
if (!isSuccess) {
|
||||
const redirectUrl = new URL(`${origin}`);
|
||||
const response = NextResponse.redirect(`${redirectUrl}`);
|
||||
return removeAllCookies(response);
|
||||
//return { success: false, message: `no_refresh_token` }
|
||||
} else {
|
||||
const refreshData = isExpired?.refresh?.data;
|
||||
//console.log ("refresh data", refreshData)
|
||||
console.log('We used the refresh token, so now going to reset the token and cookies');
|
||||
const newCustomerAccessToken = refreshData?.customerAccessToken;
|
||||
const expires_in = refreshData?.expires_in;
|
||||
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
|
||||
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
|
||||
newHeaders.set('x-shop-customer-token', `${newCustomerAccessToken}`);
|
||||
const resetCookieResponse = NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
return await createAllCookies({
|
||||
response: resetCookieResponse,
|
||||
customerAccessToken: newCustomerAccessToken,
|
||||
expires_in,
|
||||
refresh_token: refreshData?.refresh_token,
|
||||
expiresAt
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
newHeaders.set('x-shop-customer-token', `${customerTokenValue}`);
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//when we are running on the production website we just get the origin from the request.nextUrl
|
||||
export function getOrigin(request: NextRequest) {
|
||||
const nextOrigin = request.nextUrl.origin;
|
||||
//console.log("Current Origin", nextOrigin)
|
||||
//when running localhost, we want to use fake origin otherwise we use the real origin
|
||||
let newOrigin = nextOrigin;
|
||||
if (nextOrigin === 'https://localhost:3000' || nextOrigin === 'http://localhost:3000') {
|
||||
newOrigin = SHOPIFY_ORIGIN;
|
||||
} else {
|
||||
newOrigin = nextOrigin;
|
||||
}
|
||||
return newOrigin;
|
||||
}
|
||||
|
||||
export async function authorizeFn(request: NextRequest, origin: string) {
|
||||
const clientId = SHOPIFY_CLIENT_ID;
|
||||
const newHeaders = new Headers(request.headers);
|
||||
/***
|
||||
STEP 1: Get the initial access token or deny access
|
||||
****/
|
||||
const dataInitialToken = await initialAccessToken(
|
||||
request,
|
||||
origin,
|
||||
customerAccountApiUrl,
|
||||
clientId
|
||||
);
|
||||
if (!dataInitialToken.success) {
|
||||
console.log('Error: Access Denied. Check logs', dataInitialToken.message);
|
||||
newHeaders.set('x-shop-access', 'denied');
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
const { access_token, expires_in, id_token, refresh_token } = dataInitialToken.data;
|
||||
/***
|
||||
STEP 2: Get a Customer Access Token
|
||||
****/
|
||||
const customerAccessToken = await exchangeAccessToken(
|
||||
access_token,
|
||||
clientId,
|
||||
customerAccountApiUrl,
|
||||
origin || ''
|
||||
);
|
||||
if (!customerAccessToken.success) {
|
||||
console.log('Error: Customer Access Token');
|
||||
newHeaders.set('x-shop-access', 'denied');
|
||||
return NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders
|
||||
}
|
||||
});
|
||||
}
|
||||
//console.log("customer access Token", customerAccessToken.data.access_token)
|
||||
/**STEP 3: Set Customer Access Token cookies
|
||||
We are setting the cookies here b/c if we set it on the request, and then redirect
|
||||
it doesn't see to set sometimes
|
||||
**/
|
||||
newHeaders.set('x-shop-access', 'allowed');
|
||||
/*
|
||||
const authResponse = NextResponse.next({
|
||||
request: {
|
||||
// New request headers
|
||||
headers: newHeaders,
|
||||
},
|
||||
})
|
||||
*/
|
||||
const accountUrl = new URL(`${origin}/account`);
|
||||
const authResponse = NextResponse.redirect(`${accountUrl}`);
|
||||
|
||||
//sets an expires time 2 minutes before expiration which we can use in refresh strategy
|
||||
//const test_expires_in = 180 //to test to see if it expires in 60 seconds!
|
||||
const expiresAt = new Date(new Date().getTime() + (expires_in! - 120) * 1000).getTime() + '';
|
||||
|
||||
return await createAllCookies({
|
||||
response: authResponse,
|
||||
customerAccessToken: customerAccessToken?.data?.access_token,
|
||||
expires_in,
|
||||
refresh_token,
|
||||
expiresAt,
|
||||
id_token
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutFn(request: NextRequest, origin: string) {
|
||||
//console.log("New Origin", newOrigin)
|
||||
const idToken = request.cookies.get('shop_id_token');
|
||||
const idTokenValue = idToken?.value;
|
||||
//revalidateTag(TAGS.customer); //this causes some strange error in Nextjs about invariant, so removing for now
|
||||
|
||||
//if there is no idToken, then sending to logout url will redirect shopify, so just
|
||||
//redirect to login here and delete cookies (presumably they don't even exist)
|
||||
if (!idTokenValue) {
|
||||
const logoutUrl = new URL(`${origin}/login`);
|
||||
const response = NextResponse.redirect(`${logoutUrl}`);
|
||||
return removeAllCookies(response);
|
||||
}
|
||||
|
||||
//console.log ("id toke value", idTokenValue)
|
||||
const logoutUrl = new URL(
|
||||
`${customerAccountApiUrl}/auth/logout?id_token_hint=${idTokenValue}&post_logout_redirect_uri=${origin}`
|
||||
);
|
||||
//console.log ("logout url", logoutUrl)
|
||||
const logoutResponse = NextResponse.redirect(logoutUrl);
|
||||
return removeAllCookies(logoutResponse);
|
||||
}
|
@@ -1,97 +0,0 @@
|
||||
//https://shopify.dev/docs/api/customer/2024-01/queries/customer
|
||||
export const CUSTOMER_ME_QUERY = /* GraphQL */ `
|
||||
query customer {
|
||||
customer {
|
||||
emailAddress {
|
||||
emailAddress
|
||||
}
|
||||
firstName
|
||||
lastName
|
||||
tags
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CUSTOMER_FRAGMENT = `#graphql
|
||||
fragment OrderCard on Order {
|
||||
id
|
||||
number
|
||||
processedAt
|
||||
financialStatus
|
||||
fulfillments(first: 1) {
|
||||
nodes {
|
||||
status
|
||||
}
|
||||
}
|
||||
totalPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
lineItems(first: 2) {
|
||||
edges {
|
||||
node {
|
||||
title
|
||||
image {
|
||||
altText
|
||||
height
|
||||
url
|
||||
width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment AddressPartial on CustomerAddress {
|
||||
id
|
||||
formatted
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
territoryCode
|
||||
zoneCode
|
||||
city
|
||||
zip
|
||||
phoneNumber
|
||||
}
|
||||
|
||||
fragment CustomerDetails on Customer {
|
||||
firstName
|
||||
lastName
|
||||
phoneNumber {
|
||||
phoneNumber
|
||||
}
|
||||
emailAddress {
|
||||
emailAddress
|
||||
}
|
||||
defaultAddress {
|
||||
...AddressPartial
|
||||
}
|
||||
addresses(first: 6) {
|
||||
edges {
|
||||
node {
|
||||
...AddressPartial
|
||||
}
|
||||
}
|
||||
}
|
||||
orders(first: 250, sortKey: PROCESSED_AT, reverse: true) {
|
||||
edges {
|
||||
node {
|
||||
...OrderCard
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
` as const;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
|
||||
export const CUSTOMER_DETAILS_QUERY = `#graphql
|
||||
query CustomerDetails {
|
||||
customer {
|
||||
...CustomerDetails
|
||||
}
|
||||
}
|
||||
${CUSTOMER_FRAGMENT}
|
||||
` as const;
|
@@ -1,36 +0,0 @@
|
||||
export type Maybe<T> = T | null;
|
||||
|
||||
export type Connection<T> = {
|
||||
edges: Array<Edge<T>>;
|
||||
};
|
||||
|
||||
export type Edge<T> = {
|
||||
node: T;
|
||||
};
|
||||
|
||||
export type CustomerData = {
|
||||
data: {
|
||||
customer: {
|
||||
emailAddress: {
|
||||
emailAddress: string;
|
||||
};
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
tags: any[];
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type GenericObject = { [key: string]: any };
|
||||
|
||||
export type CustomerDetailsData = {
|
||||
data: {
|
||||
customer: {
|
||||
emailAddress: {
|
||||
emailAddress: string;
|
||||
};
|
||||
// Using GenericObject to type 'orders' since the fields are not known in advance
|
||||
orders: Connection<GenericObject>;
|
||||
};
|
||||
};
|
||||
};
|
@@ -1,7 +0,0 @@
|
||||
export function parseJSON(json: any) {
|
||||
if (String(json).includes('__proto__')) return JSON.parse(json, noproto);
|
||||
return JSON.parse(json);
|
||||
}
|
||||
function noproto(k: string, v: string) {
|
||||
if (k !== '__proto__') return v;
|
||||
}
|
18
lib/shopify/fragments/customer-address.ts
Normal file
18
lib/shopify/fragments/customer-address.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
const customerAddress = /* GraphQL */ `
|
||||
fragment CustomerAddress on CustomerAddress {
|
||||
id
|
||||
formatted
|
||||
firstName
|
||||
lastName
|
||||
company
|
||||
address1
|
||||
address2
|
||||
territoryCode
|
||||
zoneCode
|
||||
city
|
||||
zip
|
||||
phoneNumber
|
||||
}
|
||||
`;
|
||||
|
||||
export default customerAddress;
|
36
lib/shopify/fragments/customer-details.ts
Normal file
36
lib/shopify/fragments/customer-details.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import customerAddress from './customer-address';
|
||||
import orderCard from './order-card';
|
||||
|
||||
const customerDetailsFragment = /* GraphQL */ `
|
||||
${customerAddress}
|
||||
${orderCard}
|
||||
|
||||
fragment CustomerDetails on Customer {
|
||||
firstName
|
||||
lastName
|
||||
phoneNumber {
|
||||
phoneNumber
|
||||
}
|
||||
emailAddress {
|
||||
emailAddress
|
||||
}
|
||||
defaultAddress {
|
||||
...CustomerAddress
|
||||
}
|
||||
addresses(first: 6) {
|
||||
edges {
|
||||
node {
|
||||
...CustomerAddress
|
||||
}
|
||||
}
|
||||
}
|
||||
orders(first: 20, sortKey: PROCESSED_AT, reverse: true) {
|
||||
edges {
|
||||
node {
|
||||
...OrderCard
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
export default customerDetailsFragment;
|
35
lib/shopify/fragments/order-card.ts
Normal file
35
lib/shopify/fragments/order-card.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
const orderCard = /* GraphQL */ `
|
||||
fragment OrderCard on Order {
|
||||
id
|
||||
number
|
||||
name
|
||||
processedAt
|
||||
financialStatus
|
||||
fulfillments(first: 1) {
|
||||
edges {
|
||||
node {
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
totalPrice {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
lineItems(first: 20) {
|
||||
edges {
|
||||
node {
|
||||
title
|
||||
image {
|
||||
altText
|
||||
height
|
||||
url
|
||||
width
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default orderCard;
|
@@ -11,7 +11,7 @@ import {
|
||||
YEAR_FILTER_ID
|
||||
} from 'lib/constants';
|
||||
import { isShopifyError } from 'lib/type-guards';
|
||||
import { ensureStartsWith, normalizeUrl, parseMetaFieldValue } from 'lib/utils';
|
||||
import { ensureStartsWith, normalizeUrl, parseJSON, parseMetaFieldValue } from 'lib/shopify/utils';
|
||||
import { revalidatePath, revalidateTag } from 'next/cache';
|
||||
import { headers } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
@@ -38,16 +38,21 @@ import {
|
||||
getProductsQuery
|
||||
} from './queries/product';
|
||||
import {
|
||||
Address,
|
||||
Cart,
|
||||
CartAttributeInput,
|
||||
CartItem,
|
||||
Collection,
|
||||
Connection,
|
||||
Customer,
|
||||
Filter,
|
||||
Image,
|
||||
Menu,
|
||||
Metaobject,
|
||||
Money,
|
||||
Order,
|
||||
Fulfillment,
|
||||
Transaction,
|
||||
Page,
|
||||
PageInfo,
|
||||
Product,
|
||||
@@ -60,6 +65,9 @@ import {
|
||||
ShopifyCollectionProductsOperation,
|
||||
ShopifyCollectionsOperation,
|
||||
ShopifyCreateCartOperation,
|
||||
ShopifyCustomerOperation,
|
||||
ShopifyCustomerOrderOperation,
|
||||
ShopifyCustomerOrdersOperation,
|
||||
ShopifyFilter,
|
||||
ShopifyImageOperation,
|
||||
ShopifyMenuOperation,
|
||||
@@ -75,13 +83,31 @@ import {
|
||||
ShopifyProductsOperation,
|
||||
ShopifyRemoveFromCartOperation,
|
||||
ShopifySetCartAttributesOperation,
|
||||
ShopifyUpdateCartOperation
|
||||
ShopifyUpdateCartOperation,
|
||||
ShopifyCustomer,
|
||||
ShopifyOrder,
|
||||
ShopifyAddress,
|
||||
ShopifyMoneyV2,
|
||||
LineItem
|
||||
} from './types';
|
||||
import { getCustomerQuery } from './queries/customer';
|
||||
import { getCustomerOrdersQuery } from './queries/orders';
|
||||
import { getCustomerOrderQuery } from './queries/order';
|
||||
|
||||
const domain = process.env.SHOPIFY_STORE_DOMAIN
|
||||
? ensureStartsWith(process.env.SHOPIFY_STORE_DOMAIN, 'https://')
|
||||
: '';
|
||||
const endpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||
|
||||
const customerApiUrl = process.env.SHOPIFY_CUSTOMER_ACCOUNT_API_URL;
|
||||
const customerApiVersion = process.env.SHOPIFY_CUSTOMER_API_VERSION;
|
||||
|
||||
const storefrontEndpoint = `${domain}${SHOPIFY_GRAPHQL_API_ENDPOINT}`;
|
||||
const customerEndpoint = `${customerApiUrl}/account/customer/api/${customerApiVersion}/graphql`;
|
||||
|
||||
const userAgent = '*';
|
||||
const placeholderProductImage =
|
||||
'https://cdn.shopify.com/shopifycloud/customer-account-web/production/assets/8bc6556601c510713d76.svg';
|
||||
|
||||
const key = process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN!;
|
||||
|
||||
type ExtractVariables<T> = T extends { variables: object } ? T['variables'] : never;
|
||||
@@ -100,7 +126,7 @@ export async function shopifyFetch<T>({
|
||||
variables?: ExtractVariables<T>;
|
||||
}): Promise<{ status: number; body: T } | never> {
|
||||
try {
|
||||
const result = await fetch(endpoint, {
|
||||
const result = await fetch(storefrontEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -142,6 +168,80 @@ export async function shopifyFetch<T>({
|
||||
}
|
||||
}
|
||||
|
||||
export async function shopifyCustomerFetch<T>({
|
||||
query,
|
||||
variables
|
||||
}: {
|
||||
query: string;
|
||||
variables?: ExtractVariables<T>;
|
||||
}): Promise<{ status: number; body: T } | never> {
|
||||
const headersList = headers();
|
||||
const customerToken = headersList.get('x-shop-customer-token') || '';
|
||||
|
||||
try {
|
||||
const result = await fetch(customerEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': userAgent,
|
||||
Origin: domain,
|
||||
Authorization: customerToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(query && { query }),
|
||||
...(variables && { variables })
|
||||
}),
|
||||
cache: 'no-store'
|
||||
});
|
||||
|
||||
const body = await result.json();
|
||||
if (!result.ok) {
|
||||
//the statuses here could be different, a 401 means
|
||||
//https://shopify.dev/docs/api/customer#endpoints
|
||||
//401 means the token is bad
|
||||
console.log('Error in Customer Fetch Status', body.errors);
|
||||
if (result.status === 401) {
|
||||
// clear session because current access token is invalid
|
||||
const errorMessage = 'unauthorized';
|
||||
throw errorMessage; //this should throw in the catch below in the non-shopify catch
|
||||
}
|
||||
let errors;
|
||||
try {
|
||||
errors = parseJSON(body);
|
||||
} catch (_e) {
|
||||
errors = [{ message: body }];
|
||||
}
|
||||
throw errors;
|
||||
}
|
||||
|
||||
//this just throws an error and the error boundary is called
|
||||
if (body.errors) {
|
||||
//throw 'Error'
|
||||
console.log('Error in Customer Fetch', body.errors[0]);
|
||||
throw body.errors[0];
|
||||
}
|
||||
|
||||
return {
|
||||
status: result.status,
|
||||
body
|
||||
};
|
||||
} catch (e) {
|
||||
if (isShopifyError(e)) {
|
||||
throw {
|
||||
cause: e.cause?.toString() || 'unknown',
|
||||
status: e.status || 500,
|
||||
message: e.message,
|
||||
query
|
||||
};
|
||||
}
|
||||
|
||||
throw {
|
||||
error: e,
|
||||
query
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const removeEdgesAndNodes = (array: Connection<any>) => {
|
||||
return array.edges.map((edge) => edge?.node);
|
||||
};
|
||||
@@ -316,6 +416,143 @@ const reshapeProducts = (products: ShopifyProduct[]) => {
|
||||
return reshapedProducts;
|
||||
};
|
||||
|
||||
function reshapeCustomer(customer: ShopifyCustomer): Customer {
|
||||
return {
|
||||
firstName: customer.firstName,
|
||||
lastName: customer.lastName,
|
||||
displayName: customer.displayName,
|
||||
emailAddress: customer.emailAddress.emailAddress
|
||||
};
|
||||
}
|
||||
|
||||
function reshapeOrders(orders: ShopifyOrder[]): any[] | Promise<Order[]> {
|
||||
const reshapedOrders: Order[] = [];
|
||||
|
||||
for (const order of orders) {
|
||||
const reshapedOrder = reshapeOrder(order);
|
||||
if (!reshapedOrder) continue;
|
||||
|
||||
reshapedOrders.push(reshapedOrder);
|
||||
}
|
||||
|
||||
return reshapedOrders;
|
||||
}
|
||||
|
||||
function reshapeOrder(shopifyOrder: ShopifyOrder): Order {
|
||||
const reshapeAddress = (address?: ShopifyAddress): Address | undefined => {
|
||||
if (!address) return undefined;
|
||||
return {
|
||||
address1: address.address1,
|
||||
address2: address.address2,
|
||||
firstName: address.firstName,
|
||||
lastName: address.lastName,
|
||||
provinceCode: address.provinceCode,
|
||||
city: address.city,
|
||||
zip: address.zip,
|
||||
country: address.countryCodeV2,
|
||||
company: address.company,
|
||||
phone: address.phone
|
||||
};
|
||||
};
|
||||
|
||||
const reshapeMoney = (money?: ShopifyMoneyV2): Money | undefined => {
|
||||
if (!money) return undefined;
|
||||
return {
|
||||
amount: money.amount || '0.00',
|
||||
currencyCode: money.currencyCode || 'USD'
|
||||
};
|
||||
};
|
||||
|
||||
const orderFulfillments: Fulfillment[] =
|
||||
shopifyOrder.fulfillments?.edges?.map((edge) => ({
|
||||
status: edge.node.status,
|
||||
createdAt: edge.node.createdAt,
|
||||
trackingInformation:
|
||||
edge.node.trackingInformation?.map((tracking) => ({
|
||||
number: tracking.number,
|
||||
company: tracking.company,
|
||||
url: tracking.url
|
||||
})) || [],
|
||||
events:
|
||||
edge.node.events?.edges.map((event) => ({
|
||||
status: event.node.status,
|
||||
happenedAt: event.node.happenedAt
|
||||
})) || [],
|
||||
fulfilledLineItems:
|
||||
edge.node.fulfillmentLineItems?.nodes.map((lineItem) => ({
|
||||
id: lineItem.lineItem.id,
|
||||
quantity: lineItem.quantity,
|
||||
image: {
|
||||
url: lineItem.lineItem.image?.url || placeholderProductImage,
|
||||
altText: lineItem.lineItem.image?.altText || lineItem.lineItem.title,
|
||||
width: 100,
|
||||
height: 100
|
||||
}
|
||||
})) || []
|
||||
})) || [];
|
||||
|
||||
const orderTransactions: Transaction[] = shopifyOrder.transactions?.map((transaction) => ({
|
||||
processedAt: transaction.processedAt,
|
||||
paymentIcon: {
|
||||
url: transaction.paymentIcon.url,
|
||||
altText: transaction.paymentIcon.altText,
|
||||
width: 100,
|
||||
height: 100
|
||||
},
|
||||
paymentDetails: {
|
||||
last4: transaction.paymentDetails.last4,
|
||||
cardBrand: transaction.paymentDetails.cardBrand
|
||||
},
|
||||
transactionAmount: reshapeMoney(transaction.transactionAmount.presentmentMoney)!
|
||||
}));
|
||||
|
||||
const orderLineItems: LineItem[] =
|
||||
shopifyOrder.lineItems?.edges.map((edge) => ({
|
||||
id: edge.node.id,
|
||||
title: edge.node.title,
|
||||
quantity: edge.node.quantity,
|
||||
image: {
|
||||
url: edge.node.image?.url || placeholderProductImage,
|
||||
altText: edge.node.image?.altText || edge.node.title,
|
||||
width: edge.node.image?.width || 62,
|
||||
height: edge.node.image?.height || 62
|
||||
},
|
||||
price: reshapeMoney(edge.node.price),
|
||||
totalPrice: reshapeMoney(edge.node.totalPrice),
|
||||
variantTitle: edge.node.variantTitle,
|
||||
sku: edge.node.sku
|
||||
})) || [];
|
||||
|
||||
const order: Order = {
|
||||
id: shopifyOrder.id.replace('gid://shopify/Order/', ''),
|
||||
name: shopifyOrder.name,
|
||||
processedAt: shopifyOrder.processedAt,
|
||||
fulfillments: orderFulfillments,
|
||||
transactions: orderTransactions,
|
||||
lineItems: orderLineItems,
|
||||
shippingAddress: reshapeAddress(shopifyOrder.shippingAddress),
|
||||
billingAddress: reshapeAddress(shopifyOrder.billingAddress),
|
||||
subtotal: reshapeMoney(shopifyOrder.subtotal),
|
||||
totalShipping: reshapeMoney(shopifyOrder.totalShipping),
|
||||
totalTax: reshapeMoney(shopifyOrder.totalTax),
|
||||
totalPrice: reshapeMoney(shopifyOrder.totalPrice)
|
||||
};
|
||||
|
||||
if (shopifyOrder.customer) {
|
||||
order.customer = reshapeCustomer(shopifyOrder.customer);
|
||||
}
|
||||
|
||||
if (shopifyOrder.shippingLine) {
|
||||
console.log('Shipping Line', shopifyOrder.shippingLine);
|
||||
order.shippingMethod = {
|
||||
name: shopifyOrder.shippingLine?.title,
|
||||
price: reshapeMoney(shopifyOrder.shippingLine.originalPrice)!
|
||||
};
|
||||
}
|
||||
|
||||
return order;
|
||||
}
|
||||
|
||||
export async function createCart(): Promise<Cart> {
|
||||
const res = await shopifyFetch<ShopifyCreateCartOperation>({
|
||||
query: createCartMutation,
|
||||
@@ -650,6 +887,33 @@ export async function getProducts({
|
||||
pageInfo
|
||||
};
|
||||
}
|
||||
|
||||
export async function getCustomer(): Promise<Customer> {
|
||||
const res = await shopifyCustomerFetch<ShopifyCustomerOperation>({
|
||||
query: getCustomerQuery
|
||||
});
|
||||
|
||||
const customer = res.body.data.customer;
|
||||
return reshapeCustomer(customer);
|
||||
}
|
||||
|
||||
export async function getCustomerOrders(): Promise<Order[]> {
|
||||
const res = await shopifyCustomerFetch<ShopifyCustomerOrdersOperation>({
|
||||
query: getCustomerOrdersQuery
|
||||
});
|
||||
|
||||
return reshapeOrders(removeEdgesAndNodes(res.body.data.customer.orders));
|
||||
}
|
||||
|
||||
export async function getCustomerOrder(orderId: string): Promise<Order> {
|
||||
const res = await shopifyCustomerFetch<ShopifyCustomerOrderOperation>({
|
||||
query: getCustomerOrderQuery,
|
||||
variables: { orderId: `gid://shopify/Order/${orderId}` }
|
||||
});
|
||||
|
||||
return reshapeOrder(res.body.data.order);
|
||||
}
|
||||
|
||||
// This is called from `app/api/revalidate.ts` so providers can control revalidation logic.
|
||||
export async function revalidate(req: NextRequest): Promise<NextResponse> {
|
||||
console.log(`Receiving revalidation request from Shopify.`);
|
||||
|
13
lib/shopify/queries/customer.ts
Normal file
13
lib/shopify/queries/customer.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
//https://shopify.dev/docs/api/customer/2024-01/queries/customer
|
||||
export const getCustomerQuery = /* GraphQL */ `
|
||||
query customer {
|
||||
customer {
|
||||
emailAddress {
|
||||
emailAddress
|
||||
}
|
||||
firstName
|
||||
lastName
|
||||
tags
|
||||
}
|
||||
}
|
||||
`;
|
240
lib/shopify/queries/order.ts
Normal file
240
lib/shopify/queries/order.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
|
||||
export const getCustomerOrderQuery = /* GraphQL */ `
|
||||
query getCustomerOrderQuery($orderId: ID!) {
|
||||
customer {
|
||||
emailAddress {
|
||||
emailAddress
|
||||
}
|
||||
displayName
|
||||
}
|
||||
order(id: $orderId) {
|
||||
... on Order {
|
||||
id
|
||||
...Order
|
||||
customer {
|
||||
id
|
||||
emailAddress {
|
||||
emailAddress
|
||||
marketingState
|
||||
}
|
||||
firstName
|
||||
lastName
|
||||
phoneNumber {
|
||||
phoneNumber
|
||||
marketingState
|
||||
}
|
||||
imageUrl
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment Order on Order {
|
||||
id
|
||||
name
|
||||
confirmationNumber
|
||||
processedAt
|
||||
cancelledAt
|
||||
currencyCode
|
||||
transactions {
|
||||
...OrderTransaction
|
||||
}
|
||||
billingAddress {
|
||||
...Address
|
||||
}
|
||||
shippingAddress {
|
||||
...Address
|
||||
}
|
||||
fulfillments(first: 20, sortKey: CREATED_AT, reverse: true, query: "NOT status:CANCELLED") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...Fulfillment
|
||||
}
|
||||
}
|
||||
}
|
||||
lineItems(first: 50) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...LineItem
|
||||
}
|
||||
}
|
||||
}
|
||||
totalPrice {
|
||||
...Price
|
||||
}
|
||||
subtotal {
|
||||
...Price
|
||||
}
|
||||
totalShipping {
|
||||
...Price
|
||||
}
|
||||
totalTax {
|
||||
...Price
|
||||
}
|
||||
financialStatus
|
||||
totalRefunded {
|
||||
...Price
|
||||
}
|
||||
refunds {
|
||||
id
|
||||
createdAt
|
||||
}
|
||||
paymentInformation {
|
||||
paymentCollectionUrl
|
||||
...OrderPaymentInformation
|
||||
}
|
||||
requiresShipping
|
||||
note
|
||||
shippingLine {
|
||||
title
|
||||
originalPrice {
|
||||
...Price
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment OrderTransaction on OrderTransaction {
|
||||
id
|
||||
processedAt
|
||||
paymentIcon {
|
||||
id
|
||||
url
|
||||
altText
|
||||
}
|
||||
paymentDetails {
|
||||
... on CardPaymentDetails {
|
||||
last4
|
||||
cardBrand
|
||||
}
|
||||
}
|
||||
transactionAmount {
|
||||
presentmentMoney {
|
||||
...Price
|
||||
}
|
||||
}
|
||||
giftCardDetails {
|
||||
last4
|
||||
balance {
|
||||
...Price
|
||||
}
|
||||
}
|
||||
status
|
||||
kind
|
||||
transactionParentId
|
||||
type
|
||||
typeDetails {
|
||||
name
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
fragment Price on MoneyV2 {
|
||||
amount
|
||||
currencyCode
|
||||
}
|
||||
|
||||
fragment Address on CustomerAddress {
|
||||
id
|
||||
address1
|
||||
address2
|
||||
firstName
|
||||
lastName
|
||||
provinceCode: zoneCode
|
||||
city
|
||||
zip
|
||||
countryCodeV2: territoryCode
|
||||
company
|
||||
phone: phoneNumber
|
||||
}
|
||||
|
||||
fragment Fulfillment on Fulfillment {
|
||||
id
|
||||
status
|
||||
createdAt
|
||||
estimatedDeliveryAt
|
||||
trackingInformation {
|
||||
number
|
||||
company
|
||||
url
|
||||
}
|
||||
requiresShipping
|
||||
fulfillmentLineItems(first: 20) {
|
||||
nodes {
|
||||
id
|
||||
quantity
|
||||
lineItem {
|
||||
id
|
||||
name
|
||||
title
|
||||
presentmentTitle
|
||||
sku
|
||||
image {
|
||||
id
|
||||
url
|
||||
altText
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
events(first: 20, sortKey: HAPPENED_AT, reverse: true) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
...FulfillmentEvent
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment FulfillmentEvent on FulfillmentEvent {
|
||||
status
|
||||
happenedAt
|
||||
}
|
||||
|
||||
fragment LineItem on LineItem {
|
||||
title
|
||||
image {
|
||||
altText
|
||||
height
|
||||
url
|
||||
width
|
||||
}
|
||||
price {
|
||||
...Price
|
||||
}
|
||||
quantity
|
||||
sku
|
||||
totalPrice {
|
||||
...Price
|
||||
}
|
||||
variantTitle
|
||||
}
|
||||
|
||||
fragment OrderPaymentInformation on OrderPaymentInformation {
|
||||
paymentStatus
|
||||
totalPaidAmount {
|
||||
...Price
|
||||
}
|
||||
totalOutstandingAmount {
|
||||
...Price
|
||||
}
|
||||
paymentTerms {
|
||||
id
|
||||
overdue
|
||||
nextDueAt
|
||||
paymentSchedules(first: 2) {
|
||||
nodes {
|
||||
id
|
||||
dueAt
|
||||
completed
|
||||
amount {
|
||||
...Price
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
15
lib/shopify/queries/orders.ts
Normal file
15
lib/shopify/queries/orders.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import customerDetailsFragment from '../fragments/customer-details';
|
||||
|
||||
const customerFragment = `#graphql
|
||||
`;
|
||||
|
||||
// NOTE: https://shopify.dev/docs/api/customer/latest/queries/customer
|
||||
export const getCustomerOrdersQuery = `#graphql
|
||||
query getCustomerOrdersQuery {
|
||||
customer {
|
||||
...CustomerDetails
|
||||
}
|
||||
}
|
||||
${customerFragment}
|
||||
${customerDetailsFragment}
|
||||
`;
|
@@ -35,6 +35,13 @@ export type Collection = ShopifyCollection & {
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type Customer = {
|
||||
emailAddress: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
export type Image = {
|
||||
url: string;
|
||||
altText: string;
|
||||
@@ -58,6 +65,278 @@ export type PageMetafield = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type Fulfillment = {
|
||||
status: string;
|
||||
createdAt: string;
|
||||
fulfilledLineItems: {
|
||||
id: string;
|
||||
quantity: number;
|
||||
image: Image;
|
||||
}[];
|
||||
trackingInformation: {
|
||||
number: string;
|
||||
company: string;
|
||||
url: string;
|
||||
}[];
|
||||
events: {
|
||||
status: string;
|
||||
happenedAt: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type Transaction = {
|
||||
processedAt: string;
|
||||
paymentIcon: Image;
|
||||
paymentDetails: {
|
||||
last4: string;
|
||||
cardBrand: string;
|
||||
};
|
||||
transactionAmount: Money;
|
||||
};
|
||||
|
||||
export type Address = {
|
||||
address1: string;
|
||||
address2: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
provinceCode: string;
|
||||
city: string;
|
||||
zip: string;
|
||||
country: string;
|
||||
company: string | null;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
export type LineItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
image: Image;
|
||||
price?: Money;
|
||||
quantity?: number;
|
||||
sku?: string;
|
||||
totalPrice?: Money;
|
||||
variantTitle?: string;
|
||||
};
|
||||
|
||||
export type Order = {
|
||||
id: string;
|
||||
name: string;
|
||||
customer?: Customer;
|
||||
processedAt: string;
|
||||
fulfillments: Fulfillment[];
|
||||
transactions: Transaction[];
|
||||
lineItems: LineItem[];
|
||||
shippingAddress: Address;
|
||||
billingAddress: Address;
|
||||
/** the price of all line items, excluding taxes and surcharges */
|
||||
subtotal: Money;
|
||||
totalShipping: Money;
|
||||
totalTax: Money;
|
||||
totalPrice: Money;
|
||||
shippingMethod?: {
|
||||
name: string;
|
||||
price: Money;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyOrder = {
|
||||
id: string;
|
||||
name: string;
|
||||
confirmationNumber: string;
|
||||
customer: ShopifyCustomer;
|
||||
processedAt: string;
|
||||
cancelledAt: string | null;
|
||||
currencyCode: string;
|
||||
transactions: ShopifyOrderTransaction[];
|
||||
billingAddress: ShopifyAddress;
|
||||
shippingAddress: ShopifyAddress;
|
||||
fulfillments: Connection<ShopifyFulfillment>;
|
||||
lineItems: Connection<ShopifyLineItem>;
|
||||
totalPrice: ShopifyMoneyV2;
|
||||
subtotal: ShopifyMoneyV2;
|
||||
totalShipping: ShopifyMoneyV2;
|
||||
totalTax: ShopifyMoneyV2;
|
||||
financialStatus: string;
|
||||
totalRefunded: ShopifyMoneyV2;
|
||||
refunds: ShopifyRefund[];
|
||||
paymentInformation: ShopifyOrderPaymentInformation;
|
||||
requiresShipping: boolean;
|
||||
shippingLine: ShopifyShippingLine;
|
||||
note: string | null;
|
||||
};
|
||||
|
||||
type ShopifyShippingLine = {
|
||||
title: string;
|
||||
originalPrice: ShopifyMoneyV2;
|
||||
};
|
||||
|
||||
type ShopifyOrderTransaction = {
|
||||
id: string;
|
||||
processedAt: string;
|
||||
paymentIcon: ShopifyPaymentIconImage;
|
||||
paymentDetails: ShopifyCardPaymentDetails;
|
||||
transactionAmount: ShopifyMoneyBag;
|
||||
giftCardDetails: ShopifyGiftCardDetails | null;
|
||||
status: string;
|
||||
kind: string;
|
||||
transactionParentId: string | null;
|
||||
type: string;
|
||||
typeDetails: ShopifyTransactionTypeDetails;
|
||||
};
|
||||
|
||||
type ShopifyPaymentIconImage = {
|
||||
id: string;
|
||||
url: string;
|
||||
altText: string;
|
||||
};
|
||||
|
||||
type ShopifyCardPaymentDetails = {
|
||||
last4: string;
|
||||
cardBrand: string;
|
||||
};
|
||||
|
||||
type ShopifyGiftCardDetails = {
|
||||
last4: string;
|
||||
balance: ShopifyMoneyV2;
|
||||
};
|
||||
|
||||
type ShopifyMoneyBag = {
|
||||
presentmentMoney: ShopifyMoneyV2;
|
||||
};
|
||||
|
||||
export type ShopifyMoneyV2 = {
|
||||
amount: string;
|
||||
currencyCode: string;
|
||||
};
|
||||
|
||||
type ShopifyTransactionTypeDetails = {
|
||||
name: string;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
export type ShopifyAddress = {
|
||||
id: string;
|
||||
address1: string;
|
||||
address2: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
provinceCode: string;
|
||||
city: string;
|
||||
zip: string;
|
||||
countryCodeV2: string;
|
||||
company: string | null;
|
||||
phone: string;
|
||||
};
|
||||
|
||||
type ShopifyFulfillment = {
|
||||
id: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
estimatedDeliveryAt: string | null;
|
||||
trackingInformation: ShopifyTrackingInformation[];
|
||||
requiresShipping: boolean;
|
||||
fulfillmentLineItems: ShopifyFulfillmentLineItemConnection;
|
||||
events: Connection<ShopifyFulfillmentEvent>;
|
||||
};
|
||||
|
||||
type ShopifyTrackingInformation = {
|
||||
number: string;
|
||||
company: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
type ShopifyFulfillmentLineItemConnection = {
|
||||
nodes: ShopifyFulfillmentLineItem[];
|
||||
};
|
||||
|
||||
type ShopifyFulfillmentLineItem = {
|
||||
id: string;
|
||||
quantity: number;
|
||||
lineItem: ShopifyLineItem;
|
||||
};
|
||||
|
||||
type ShopifyLineItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
image: ShopifyImage;
|
||||
price: ShopifyMoneyV2;
|
||||
quantity: number;
|
||||
sku: string;
|
||||
totalPrice: ShopifyMoneyV2;
|
||||
variantTitle: string;
|
||||
};
|
||||
|
||||
type ShopifyImage = {
|
||||
altText: string;
|
||||
height: number;
|
||||
url: string;
|
||||
width: number;
|
||||
};
|
||||
|
||||
type ShopifyFulfillmentEventConnection = {
|
||||
edges: ShopifyFulfillmentEventEdge[];
|
||||
};
|
||||
|
||||
type ShopifyFulfillmentEventEdge = {
|
||||
node: ShopifyFulfillmentEvent;
|
||||
};
|
||||
|
||||
type ShopifyFulfillmentEvent = {
|
||||
status: string;
|
||||
happenedAt: string;
|
||||
};
|
||||
|
||||
type ShopifyRefund = {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type ShopifyOrderPaymentInformation = {
|
||||
paymentCollectionUrl: string;
|
||||
paymentStatus: string;
|
||||
totalPaidAmount: ShopifyMoneyV2;
|
||||
totalOutstandingAmount: ShopifyMoneyV2;
|
||||
paymentTerms: ShopifyPaymentTerms | null;
|
||||
};
|
||||
|
||||
type ShopifyPaymentTerms = {
|
||||
id: string;
|
||||
overdue: boolean;
|
||||
nextDueAt: string;
|
||||
paymentSchedules: ShopifyPaymentScheduleConnection;
|
||||
};
|
||||
|
||||
type ShopifyPaymentScheduleConnection = {
|
||||
nodes: ShopifyPaymentSchedule[];
|
||||
};
|
||||
|
||||
type ShopifyPaymentSchedule = {
|
||||
id: string;
|
||||
dueAt: string;
|
||||
completed: boolean;
|
||||
amount: ShopifyMoneyV2;
|
||||
};
|
||||
|
||||
export type ShopifyCustomer = {
|
||||
id: string;
|
||||
emailAddress: ShopifyCustomerEmailAddress;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phoneNumber: ShopifyCustomerPhoneNumber | null;
|
||||
imageUrl: string;
|
||||
displayName: string;
|
||||
};
|
||||
|
||||
type ShopifyCustomerEmailAddress = {
|
||||
emailAddress: string;
|
||||
marketingState: string;
|
||||
};
|
||||
|
||||
type ShopifyCustomerPhoneNumber = {
|
||||
phoneNumber: string;
|
||||
marketingState: string;
|
||||
};
|
||||
|
||||
export const PAGE_TYPES = [
|
||||
'image',
|
||||
'icon_content_section',
|
||||
@@ -399,6 +678,29 @@ export type ShopifyProductsOperation = {
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyCustomerOperation = {
|
||||
data: {
|
||||
customer: ShopifyCustomer;
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyCustomerOrdersOperation = {
|
||||
data: {
|
||||
customer: {
|
||||
orders: Connection<ShopifyOrder>;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type ShopifyCustomerOrderOperation = {
|
||||
data: {
|
||||
order: ShopifyOrder;
|
||||
};
|
||||
variables: {
|
||||
orderId: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CoreChargeOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import clsx, { ClassValue } from 'clsx';
|
||||
import { ReadonlyURLSearchParams } from 'next/navigation';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
import { Menu } from './shopify/types';
|
||||
import { Menu } from './types';
|
||||
|
||||
export const createUrl = (pathname: string, params: URLSearchParams | ReadonlyURLSearchParams) => {
|
||||
const paramsString = params.toString();
|
||||
@@ -14,7 +14,14 @@ export const ensureStartsWith = (stringToCheck: string, startsWith: string) =>
|
||||
stringToCheck.startsWith(startsWith) ? stringToCheck : `${startsWith}${stringToCheck}`;
|
||||
|
||||
export const validateEnvironmentVariables = () => {
|
||||
const requiredEnvironmentVariables = ['SHOPIFY_STORE_DOMAIN', 'SHOPIFY_STOREFRONT_ACCESS_TOKEN'];
|
||||
const requiredEnvironmentVariables = [
|
||||
'SHOPIFY_STORE_DOMAIN',
|
||||
'SHOPIFY_STOREFRONT_ACCESS_TOKEN',
|
||||
'SHOPIFY_CUSTOMER_ACCOUNT_API_CLIENT_ID',
|
||||
'SHOPIFY_CUSTOMER_ACCOUNT_API_URL',
|
||||
'SHOPIFY_CUSTOMER_API_VERSION',
|
||||
'SHOPIFY_ORIGIN_URL'
|
||||
];
|
||||
const missingEnvironmentVariables = [] as string[];
|
||||
|
||||
requiredEnvironmentVariables.forEach((envVar) => {
|
||||
@@ -71,3 +78,12 @@ export const findParentCollection = (menu: Menu[], collection: string): Menu | n
|
||||
}
|
||||
return parentCollection;
|
||||
};
|
||||
|
||||
export function parseJSON(json: any) {
|
||||
if (String(json).includes('__proto__')) return JSON.parse(json, noproto);
|
||||
return JSON.parse(json);
|
||||
}
|
||||
|
||||
function noproto(k: string, v: string) {
|
||||
if (k !== '__proto__') return v;
|
||||
}
|
@@ -1,5 +1,16 @@
|
||||
export const colors = {
|
||||
primary: '#EF6C02',
|
||||
primary: {
|
||||
DEFAULT: '#EF6C02',
|
||||
emphasis: '#C85900',
|
||||
muted: '#E6CCB7'
|
||||
},
|
||||
content: {
|
||||
subtle: '#9ca3af', // gray-400
|
||||
DEFAULT: '#6b7280', // gray-500
|
||||
emphasis: '#374151', // gray-700
|
||||
strong: '#111827', // gray-900
|
||||
inverted: '#ffffff' // white
|
||||
},
|
||||
dark: '#091242',
|
||||
secondary: '#EF6C02',
|
||||
blue: {
|
||||
|
Reference in New Issue
Block a user