diff --git a/shopify/actions/address/createAddress.ts b/shopify/actions/address/createAddress.ts new file mode 100644 index 000000000..d0745fcf7 --- /dev/null +++ b/shopify/actions/address/createAddress.ts @@ -0,0 +1,50 @@ +import type { AppContext } from "../../mod.ts"; +import { CreateAddress } from "../../utils/storefront/queries.ts"; +import { + CustomerAddressCreatePayload, + MutationCustomerAddressCreateArgs, +} from "../../utils/storefront/storefront.graphql.gen.ts"; +import { getUserCookie } from "../../utils/user.ts"; + +interface Props { + address: string; + country: string; + province: string; + city: string; + zip: string; +} + +/** + * @title Shopify Integration + * @description Create Address + */ +const action = async ( + props: Props, + req: Request, + ctx: AppContext, +): Promise => { + const customerAccessToken = getUserCookie(req.headers); + + if (!customerAccessToken) return null; + + const { storefront } = ctx; + const { address, ...rest } = props; + + const data = await storefront.query< + CustomerAddressCreatePayload, + MutationCustomerAddressCreateArgs + >({ + variables: { + customerAccessToken, + address: { + address1: address, + ...rest, + }, + }, + ...CreateAddress, + }); + + return data; +}; + +export default action; diff --git a/shopify/actions/address/deleteAddress.ts b/shopify/actions/address/deleteAddress.ts new file mode 100644 index 000000000..f42e66b54 --- /dev/null +++ b/shopify/actions/address/deleteAddress.ts @@ -0,0 +1,43 @@ +import type { AppContext } from "../../mod.ts"; +import { DeleteAddress } from "../../utils/storefront/queries.ts"; +import { + CustomerAddressDeletePayload, + MutationCustomerAddressDeleteArgs, +} from "../../utils/storefront/storefront.graphql.gen.ts"; +import { getUserCookie } from "../../utils/user.ts"; + +interface Props { + addressId: string; +} + +/** + * @title Shopify Integration + * @description Delete Address + */ +const action = async ( + props: Props, + req: Request, + ctx: AppContext, +): Promise => { + const customerAccessToken = getUserCookie(req.headers); + + if (!customerAccessToken) return null; + + const { addressId } = props; + const { storefront } = ctx; + + const data = await storefront.query< + CustomerAddressDeletePayload, + MutationCustomerAddressDeleteArgs + >({ + variables: { + customerAccessToken, + id: addressId, + }, + ...DeleteAddress, + }); + + return data; +}; + +export default action; diff --git a/shopify/actions/address/setDefaultAddress.ts b/shopify/actions/address/setDefaultAddress.ts new file mode 100644 index 000000000..17d7f15ce --- /dev/null +++ b/shopify/actions/address/setDefaultAddress.ts @@ -0,0 +1,43 @@ +import type { AppContext } from "../../mod.ts"; +import { SetDefaultAddress } from "../../utils/storefront/queries.ts"; +import { + MutationCustomerDefaultAddressUpdateArgs, + CustomerDefaultAddressUpdatePayload, +} from "../../utils/storefront/storefront.graphql.gen.ts"; +import { getUserCookie } from "../../utils/user.ts"; + +interface Props { + addressId: string; +} + +/** + * @title Shopify Integration + * @description Set Default Address + */ +const action = async ( + props: Props, + req: Request, + ctx: AppContext, +): Promise => { + const customerAccessToken = getUserCookie(req.headers); + + if (!customerAccessToken) return null; + + const { addressId } = props; + const { storefront } = ctx; + + const data = await storefront.query< + CustomerDefaultAddressUpdatePayload, + MutationCustomerDefaultAddressUpdateArgs + >({ + variables: { + customerAccessToken, + addressId + }, + ...SetDefaultAddress, + }); + + return data; +}; + +export default action; diff --git a/shopify/actions/address/updateAddress.ts b/shopify/actions/address/updateAddress.ts new file mode 100644 index 000000000..6d4140418 --- /dev/null +++ b/shopify/actions/address/updateAddress.ts @@ -0,0 +1,62 @@ +import type { AppContext } from "../../mod.ts"; +import { UpdateAddress } from "../../utils/storefront/queries.ts"; +import { + CustomerAddressUpdatePayload, + MutationCustomerAddressUpdateArgs, +} from "../../utils/storefront/storefront.graphql.gen.ts"; +import { getUserCookie } from "../../utils/user.ts"; + +interface Props { + addressId: string; + address: string; + country: string; + province: string; + city: string; + zip: string; +} + +/** + * @title Shopify Integration + * @description Update Address + */ +const action = async ( + props: Props, + req: Request, + ctx: AppContext, +): Promise => { + const customerAccessToken = getUserCookie(req.headers); + + if (!customerAccessToken) return null; + + const { + addressId, + address: address1, + country, + province, + city, + zip, + } = props; + const { storefront } = ctx; + + const data = await storefront.query< + CustomerAddressUpdatePayload, + MutationCustomerAddressUpdateArgs + >({ + variables: { + id: addressId, + customerAccessToken, + address: { + address1, + country, + province, + city, + zip, + }, + }, + ...UpdateAddress, + }); + + return data; +}; + +export default action; diff --git a/shopify/actions/user/sendPasswordResetEmail.ts b/shopify/actions/user/sendPasswordResetEmail.ts new file mode 100644 index 000000000..22c4e69cc --- /dev/null +++ b/shopify/actions/user/sendPasswordResetEmail.ts @@ -0,0 +1,34 @@ +import type { AppContext } from "../../mod.ts"; +import { SendPasswordResetEmail } from "../../utils/storefront/queries.ts"; +import { + MutationCustomerRecoverArgs, + CustomerRecoverPayload, +} from "../../utils/storefront/storefront.graphql.gen.ts"; + +interface Props { + email: string; +} + +/** + * @title Shopify Integration + * @description Send Password Reset Email + */ +const action = async ( + props: Props, + _req: Request, + ctx: AppContext, +): Promise => { + const { storefront } = ctx; + + const data = await storefront.query< + CustomerRecoverPayload, + MutationCustomerRecoverArgs + >({ + variables: props, + ...SendPasswordResetEmail, + }); + + return data; +}; + +export default action; diff --git a/shopify/actions/user/updateUser.ts b/shopify/actions/user/updateUser.ts new file mode 100644 index 000000000..e20b7851b --- /dev/null +++ b/shopify/actions/user/updateUser.ts @@ -0,0 +1,45 @@ +import type { AppContext } from "../../mod.ts"; +import { UpdateCustomerInfo } from "../../utils/storefront/queries.ts"; +import { + CustomerUpdatePayload, + MutationCustomerUpdateArgs, +} from "../../utils/storefront/storefront.graphql.gen.ts"; +import { getUserCookie } from "../../utils/user.ts"; + +interface Props { + email: string; + firstName: string; + lastName: string; + acceptsMarketing?: boolean; +} + +/** + * @title Shopify Integration + * @description Update Customer Info + */ +const action = async ( + props: Props, + req: Request, + ctx: AppContext, +): Promise => { + const customerAccessToken = getUserCookie(req.headers); + + if (!customerAccessToken) return null; + + const { storefront } = ctx; + + const data = await storefront.query< + CustomerUpdatePayload, + MutationCustomerUpdateArgs + >({ + variables: { + customerAccessToken, + customer: { ...props } + }, + ...UpdateCustomerInfo, + }); + + return data; +}; + +export default action; diff --git a/shopify/loaders/orders/list.ts b/shopify/loaders/orders/list.ts new file mode 100644 index 000000000..1d7848326 --- /dev/null +++ b/shopify/loaders/orders/list.ts @@ -0,0 +1,133 @@ +import { PageInfo } from "../../../commerce/types.ts"; +import { AppContext } from "../../../shopify/mod.ts"; +import { OrdersByCustomer } from "../../utils/storefront/queries.ts"; +import { getUserCookie } from "../../utils/user.ts"; +import { + Order, + QueryRoot, + QueryRootCustomerArgs, + QueryRootLocationsArgs, +} from "../../utils/storefront/storefront.graphql.gen.ts"; + +export interface Props { + /** + * @title Items per page + * @description number of products per page to display + */ + count: number; + /** + * @title Starting page query parameter offset. + * @description Set the starting page offset. Default to 1. + */ + pageOffset?: number; + /** + * @hide + * @description it is hidden because only page prop is not sufficient, we need cursors + */ + page?: number; + /** + * @hide + * @description at admin user do not know cursor, it is useful to invokes like show more products + */ + startCursor?: string; + /** + * @hide + * @description at admin user do not know cursor, it is useful to invokes like show more products + */ + endCursor?: string; +} + +/** + * @title Shopify Integration + * @description Order List loader + */ +const loader = async ( + props: Props, + req: Request, + ctx: AppContext, +): Promise<{ + orders: Order[]; + pageInfo: PageInfo; +}> => { + const { storefront } = ctx; + const customerAccessToken = getUserCookie(req.headers); + + if (!customerAccessToken) { + ctx.response.status = 401; + return { + orders: [], + pageInfo: { + nextPage: undefined, + previousPage: undefined, + currentPage: 1, + records: 0, + recordPerPage: props.count, + }, + }; + } + + const url = new URL(req.url); + const searchParams = url.searchParams; + + const { count = 12, pageOffset = 1 } = props; + const pageParam = searchParams.get("page") + ? Number(searchParams.get("page")) - pageOffset + : 0; + + const page = props.page ?? pageParam; + const startCursor = props.startCursor ?? searchParams.get("startCursor") ?? + ""; + const endCursor = props.endCursor ?? searchParams.get("endCursor") ?? ""; + + const variables = { + customerAccessToken, + ...(startCursor && { after: startCursor, first: count }), + ...(endCursor && { before: endCursor, last: count }), + ...(!startCursor && !endCursor && { first: count }), + }; + + const data = await storefront.query< + QueryRoot, + QueryRootCustomerArgs & QueryRootLocationsArgs + >({ + ...OrdersByCustomer, + variables, + }); + + const orders = data.customer?.orders?.nodes ?? []; + const pageInfo = data.customer?.orders?.pageInfo; + + if (!pageInfo) { + throw new Error("Missing pageInfo from Shopify response"); + } + + const nextPage = new URLSearchParams(searchParams); + const previousPage = new URLSearchParams(searchParams); + + if (pageInfo.hasNextPage) { + nextPage.set("page", (page + pageOffset + 1).toString()); + nextPage.set("startCursor", pageInfo.endCursor ?? ""); + nextPage.delete("endCursor"); + } + + if (pageInfo.hasPreviousPage) { + previousPage.set("page", (page + pageOffset - 1).toString()); + previousPage.set("endCursor", pageInfo.startCursor ?? ""); + previousPage.delete("startCursor"); + } + + const currentPage = Math.max(1, page + pageOffset); + + return { + orders, + pageInfo: { + nextPage: pageInfo.hasNextPage ? `?${nextPage}` : undefined, + previousPage: pageInfo.hasPreviousPage ? `?${previousPage}` : undefined, + currentPage, + records: data.customer?.orders.totalCount ?? 0, + recordPerPage: count, + }, + }; +}; + +export default loader; diff --git a/shopify/manifest.gen.ts b/shopify/manifest.gen.ts index bd569b7e6..0bcaf3bae 100644 --- a/shopify/manifest.gen.ts +++ b/shopify/manifest.gen.ts @@ -2,45 +2,59 @@ // This file SHOULD be checked into source version control. // This file is automatically updated during development when running `dev.ts`. -import * as $$$$$$$$$0 from "./actions/cart/addItems.ts"; -import * as $$$$$$$$$1 from "./actions/cart/updateCoupons.ts"; -import * as $$$$$$$$$2 from "./actions/cart/updateItems.ts"; -import * as $$$$$$$$$3 from "./actions/order/draftOrderCalculate.ts"; -import * as $$$$$$$$$4 from "./actions/user/signIn.ts"; -import * as $$$$$$$$$5 from "./actions/user/signUp.ts"; +import * as $$$$$$$$$0 from "./actions/address/createAddress.ts"; +import * as $$$$$$$$$1 from "./actions/address/deleteAddress.ts"; +import * as $$$$$$$$$2 from "./actions/address/setDefaultAddress.ts"; +import * as $$$$$$$$$3 from "./actions/address/updateAddress.ts"; +import * as $$$$$$$$$4 from "./actions/cart/addItems.ts"; +import * as $$$$$$$$$5 from "./actions/cart/updateCoupons.ts"; +import * as $$$$$$$$$6 from "./actions/cart/updateItems.ts"; +import * as $$$$$$$$$7 from "./actions/order/draftOrderCalculate.ts"; +import * as $$$$$$$$$8 from "./actions/user/sendPasswordResetEmail.ts"; +import * as $$$$$$$$$9 from "./actions/user/signIn.ts"; +import * as $$$$$$$$$10 from "./actions/user/signUp.ts"; +import * as $$$$$$$$$11 from "./actions/user/updateUser.ts"; import * as $$$$0 from "./handlers/sitemap.ts"; import * as $$$4 from "./loaders/cart.ts"; import * as $$$5 from "./loaders/config.ts"; +import * as $$$6 from "./loaders/orders/list.ts"; import * as $$$0 from "./loaders/ProductDetailsPage.ts"; import * as $$$1 from "./loaders/ProductList.ts"; import * as $$$2 from "./loaders/ProductListingPage.ts"; -import * as $$$6 from "./loaders/proxy.ts"; +import * as $$$7 from "./loaders/proxy.ts"; import * as $$$3 from "./loaders/RelatedProducts.ts"; -import * as $$$7 from "./loaders/shop.ts"; -import * as $$$8 from "./loaders/user.ts"; +import * as $$$8 from "./loaders/shop.ts"; +import * as $$$9 from "./loaders/user.ts"; const manifest = { "loaders": { "shopify/loaders/cart.ts": $$$4, "shopify/loaders/config.ts": $$$5, + "shopify/loaders/orders/list.ts": $$$6, "shopify/loaders/ProductDetailsPage.ts": $$$0, "shopify/loaders/ProductList.ts": $$$1, "shopify/loaders/ProductListingPage.ts": $$$2, - "shopify/loaders/proxy.ts": $$$6, + "shopify/loaders/proxy.ts": $$$7, "shopify/loaders/RelatedProducts.ts": $$$3, - "shopify/loaders/shop.ts": $$$7, - "shopify/loaders/user.ts": $$$8, + "shopify/loaders/shop.ts": $$$8, + "shopify/loaders/user.ts": $$$9, }, "handlers": { "shopify/handlers/sitemap.ts": $$$$0, }, "actions": { - "shopify/actions/cart/addItems.ts": $$$$$$$$$0, - "shopify/actions/cart/updateCoupons.ts": $$$$$$$$$1, - "shopify/actions/cart/updateItems.ts": $$$$$$$$$2, - "shopify/actions/order/draftOrderCalculate.ts": $$$$$$$$$3, - "shopify/actions/user/signIn.ts": $$$$$$$$$4, - "shopify/actions/user/signUp.ts": $$$$$$$$$5, + "shopify/actions/address/createAddress.ts": $$$$$$$$$0, + "shopify/actions/address/deleteAddress.ts": $$$$$$$$$1, + "shopify/actions/address/setDefaultAddress.ts": $$$$$$$$$2, + "shopify/actions/address/updateAddress.ts": $$$$$$$$$3, + "shopify/actions/cart/addItems.ts": $$$$$$$$$4, + "shopify/actions/cart/updateCoupons.ts": $$$$$$$$$5, + "shopify/actions/cart/updateItems.ts": $$$$$$$$$6, + "shopify/actions/order/draftOrderCalculate.ts": $$$$$$$$$7, + "shopify/actions/user/sendPasswordResetEmail.ts": $$$$$$$$$8, + "shopify/actions/user/signIn.ts": $$$$$$$$$9, + "shopify/actions/user/signUp.ts": $$$$$$$$$10, + "shopify/actions/user/updateUser.ts": $$$$$$$$$11, }, "name": "shopify", "baseUrl": import.meta.url, diff --git a/shopify/utils/storefront/queries.ts b/shopify/utils/storefront/queries.ts index bd5e2bc3e..229abf1d5 100644 --- a/shopify/utils/storefront/queries.ts +++ b/shopify/utils/storefront/queries.ts @@ -455,6 +455,83 @@ export const FetchCustomerInfo = { }`, }; +export const FetchCustomerAddresses = { + query: gql`query FetchCustomerAddresses($customerAccessToken: String!) { + customer(customerAccessToken: $customerAccessToken) { + addresses(first: 10) { + edges { + node { + address1 + city + country + id + province + zip + } + } + } + } + }`, +}; + +export const OrdersByCustomer = { + query: gql` + query OrdersByCustomer( + $customerAccessToken: String!, + $first: Int, + $after: String + ) { + customer(customerAccessToken: $customerAccessToken) { + orders(first: $first, after: $after, reverse: true) { + pageInfo { + startCursor + hasPreviousPage + hasNextPage + endCursor + } + totalCount + nodes { + id + name + orderNumber + processedAt + financialStatus + fulfillmentStatus + totalPrice { + amount + currencyCode + } + lineItems(first: 10) { + nodes { + title + quantity + variant { + id + title + image { + url + altText + } + price { + amount + currencyCode + } + product { + id + title + handle + vendor + } + } + } + } + } + } + } + } + `, +}; + export const AddItemToCart = { fragments: [Cart], query: gql`mutation AddItemToCart($cartId: ID!, $lines: [CartLineInput!]!) { @@ -490,6 +567,31 @@ export const RegisterAccount = { }`, }; +export const UpdateCustomerInfo = { + query: gql` + mutation UpdateCustomerInfo( + $customerAccessToken: String!, + $customer: CustomerUpdateInput! + ) { + customerUpdate( + customerAccessToken: $customerAccessToken, + customer: $customer + ) { + customer { + id + } + customerUserErrors { + code + message + } + userErrors { + message + } + } + } + `, +}; + export const AddCoupon = { fragments: [Cart], query: gql`mutation AddCoupon($cartId: ID!, $discountCodes: [String!]!) { @@ -528,3 +630,109 @@ export const SignInWithEmailAndPassword = { } }`, }; + +export const SendPasswordResetEmail = { + query: gql`mutation SendPasswordResetEmail($email: String!) { + customerRecover(email: $email) { + customerUserErrors { + code + message + } + userErrors { + message + } + } + }`, +}; + +export const CreateAddress = { + query: gql`mutation CreateAddress( + $customerAccessToken: String!, + $address: MailingAddressInput! + ) { + customerAddressCreate( + customerAccessToken: $customerAccessToken, + address: $address + ) { + customerAddress { + id + } + customerUserErrors { + code + message + } + } + }`, +}; + +export const UpdateAddress = { + query: gql` + mutation UpdateAddress( + $id: ID!, + $customerAccessToken: String!, + $address: MailingAddressInput! + ) { + customerAddressUpdate( + id: $id, + customerAccessToken: $customerAccessToken, + address: $address + ) { + customerAddress { + id + } + customerUserErrors { + code + message + } + userErrors { + message + } + } + } + `, +}; + +export const SetDefaultAddress = { + query: gql`mutation SetDefaultAddress( + $customerAccessToken: String!, + $addressId: ID! + ) { + customerDefaultAddressUpdate( + customerAccessToken: $customerAccessToken, + addressId: $addressId + ) { + customer { + defaultAddress { + id + } + } + customerUserErrors { + code + message + } + } + }`, +}; + +export const DeleteAddress = { + query: gql` + mutation DeleteAddress( + $customerAccessToken: String!, + $id: ID! + ) { + customerAddressDelete( + customerAccessToken: $customerAccessToken, + id: $id + ) { + deletedCustomerAddressId + customerUserErrors { + code + message + } + userErrors { + message + } + } + } + `, +}; diff --git a/shopify/utils/storefront/storefront.graphql.gen.ts b/shopify/utils/storefront/storefront.graphql.gen.ts index 5e8bf5c02..4a44b1825 100644 --- a/shopify/utils/storefront/storefront.graphql.gen.ts +++ b/shopify/utils/storefront/storefront.graphql.gen.ts @@ -7909,6 +7909,22 @@ export type FetchCustomerInfoQueryVariables = Exact<{ export type FetchCustomerInfoQuery = { customer?: { id: string, email?: string | null, firstName?: string | null, lastName?: string | null } | null }; +export type FetchCustomerAddressesQueryVariables = Exact<{ + customerAccessToken: Scalars['String']['input']; +}>; + + +export type FetchCustomerAddressesQuery = { customer?: { addresses: { edges: Array<{ node: { address1?: string | null, city?: string | null, country?: string | null, id: string, province?: string | null, zip?: string | null } }> } } | null }; + +export type OrdersByCustomerQueryVariables = Exact<{ + customerAccessToken: Scalars['String']['input']; + first?: InputMaybe; + after?: InputMaybe; +}>; + + +export type OrdersByCustomerQuery = { customer?: { orders: { totalCount: any, pageInfo: { startCursor?: string | null, hasPreviousPage: boolean, hasNextPage: boolean, endCursor?: string | null }, nodes: Array<{ id: string, name: string, orderNumber: number, processedAt: any, financialStatus?: OrderFinancialStatus | null, fulfillmentStatus: OrderFulfillmentStatus, totalPrice: { amount: any, currencyCode: CurrencyCode }, lineItems: { nodes: Array<{ title: string, quantity: number, variant?: { id: string, title: string, image?: { url: any, altText?: string | null } | null, price: { amount: any, currencyCode: CurrencyCode }, product: { id: string, title: string, handle: string, vendor: string } } | null }> } }> } } | null }; + export type AddItemToCartMutationVariables = Exact<{ cartId: Scalars['ID']['input']; lines: Array | CartLineInput; @@ -7941,6 +7957,14 @@ export type RegisterAccountMutationVariables = Exact<{ export type RegisterAccountMutation = { customerCreate?: { customer?: { id: string } | null, customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }> } | null }; +export type UpdateCustomerInfoMutationVariables = Exact<{ + customerAccessToken: Scalars['String']['input']; + customer: CustomerUpdateInput; +}>; + + +export type UpdateCustomerInfoMutation = { customerUpdate?: { customer?: { id: string } | null, customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }>, userErrors: Array<{ message: string }> } | null }; + export type AddCouponMutationVariables = Exact<{ cartId: Scalars['ID']['input']; discountCodes: Array | Scalars['String']['input']; @@ -7990,3 +8014,43 @@ export type SignInWithEmailAndPasswordMutationVariables = Exact<{ export type SignInWithEmailAndPasswordMutation = { customerAccessTokenCreate?: { customerAccessToken?: { accessToken: string, expiresAt: any } | null, customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }> } | null }; + +export type SendPasswordResetEmailMutationVariables = Exact<{ + email: Scalars['String']['input']; +}>; + + +export type SendPasswordResetEmailMutation = { customerRecover?: { customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }>, userErrors: Array<{ message: string }> } | null }; + +export type CreateAddressMutationVariables = Exact<{ + customerAccessToken: Scalars['String']['input']; + address: MailingAddressInput; +}>; + + +export type CreateAddressMutation = { customerAddressCreate?: { customerAddress?: { id: string } | null, customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }> } | null }; + +export type UpdateAddressMutationVariables = Exact<{ + id: Scalars['ID']['input']; + customerAccessToken: Scalars['String']['input']; + address: MailingAddressInput; +}>; + + +export type UpdateAddressMutation = { customerAddressUpdate?: { customerAddress?: { id: string } | null, customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }>, userErrors: Array<{ message: string }> } | null }; + +export type SetDefaultAddressMutationVariables = Exact<{ + customerAccessToken: Scalars['String']['input']; + addressId: Scalars['ID']['input']; +}>; + + +export type SetDefaultAddressMutation = { customerDefaultAddressUpdate?: { customer?: { defaultAddress?: { id: string } | null } | null, customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }> } | null }; + +export type DeleteAddressMutationVariables = Exact<{ + customerAccessToken: Scalars['String']['input']; + id: Scalars['ID']['input']; +}>; + + +export type DeleteAddressMutation = { customerAddressDelete?: { deletedCustomerAddressId?: string | null, customerUserErrors: Array<{ code?: CustomerErrorCode | null, message: string }>, userErrors: Array<{ message: string }> } | null };