├── app ├── assets │ ├── svg │ │ └── .gitkeep │ ├── fonts │ │ └── .gitkeep │ ├── scripts │ │ └── .gitkeep │ └── styles │ │ └── app.css ├── composables │ └── .gitkeep ├── pages │ ├── index.vue │ ├── account │ │ ├── orders │ │ │ └── index.vue │ │ ├── addresses │ │ │ └── index.vue │ │ ├── recover.vue │ │ ├── index.vue │ │ ├── login.vue │ │ └── reset.vue │ └── products │ │ └── [handle].vue ├── layouts │ ├── default.vue │ └── account.vue ├── plugins │ └── shopify-init.ts ├── middleware │ └── account.global.ts ├── components │ ├── shopify │ │ ├── product │ │ │ ├── form │ │ │ │ ├── form-header.vue │ │ │ │ ├── form-add-to-cart.vue │ │ │ │ ├── form-size-options.vue │ │ │ │ ├── form-color-options.vue │ │ │ │ └── form-details.vue │ │ │ ├── product-recommendations.vue │ │ │ ├── product-media-gallery.vue │ │ │ ├── product-media-lightbox.vue │ │ │ ├── product-form.vue │ │ │ └── product-media-carousel.vue │ │ ├── common │ │ │ ├── shopify-video.vue │ │ │ ├── price-display.vue │ │ │ └── shopify-image.vue │ │ ├── search │ │ │ ├── suggestions │ │ │ │ ├── suggested-link.vue │ │ │ │ └── suggested-product-card.vue │ │ │ ├── search-menu.vue │ │ │ ├── search-menu-mobile.vue │ │ │ └── search-menu-desktop.vue │ │ ├── product-card │ │ │ ├── product-card-media.vue │ │ │ ├── product-card.vue │ │ │ └── product-card-tags.vue │ │ ├── cart │ │ │ ├── cart-summary.vue │ │ │ ├── cart-drawer.vue │ │ │ └── cart-line.vue │ │ ├── account │ │ │ ├── account-orders.vue │ │ │ ├── account-address.vue │ │ │ └── account-menu.vue │ │ ├── modals │ │ │ ├── delete-address-modal.vue │ │ │ └── locale-modal.vue │ │ └── filter │ │ │ └── filter-menu.vue │ ├── app │ │ ├── app-header.vue │ │ ├── nav │ │ │ ├── nav-mobile.vue │ │ │ └── nav-desktop.vue │ │ ├── menus │ │ │ └── mobile-menu.vue │ │ └── app-footer.vue │ └── klaviyo │ │ ├── klaviyo-newsletter.vue │ │ └── klaviyo-back-in-stock-modal.vue ├── app.vue ├── utils │ ├── validators.ts │ ├── formatters.ts │ └── modifiers.ts └── stores │ ├── app.ts │ ├── shop.ts │ └── auth.ts ├── modules ├── klaviyo │ ├── runtime │ │ ├── composables │ │ │ └── use-klaviyo.ts │ │ ├── server │ │ │ └── klaviyo.post.ts │ │ └── resources │ │ │ └── http │ │ │ └── subscribe.ts │ └── index.ts └── shopify │ ├── runtime │ ├── resources │ │ ├── graphql │ │ │ ├── storefront │ │ │ │ ├── fragments │ │ │ │ │ ├── money.ts │ │ │ │ │ ├── language.ts │ │ │ │ │ ├── image.ts │ │ │ │ │ ├── pageInfo.ts │ │ │ │ │ ├── priceRange.ts │ │ │ │ │ ├── country.ts │ │ │ │ │ ├── mediaImage.ts │ │ │ │ │ ├── model3d.ts │ │ │ │ │ ├── video.ts │ │ │ │ │ ├── buyerIdentity.ts │ │ │ │ │ ├── mailingAddress.ts │ │ │ │ │ ├── filter.ts │ │ │ │ │ ├── productOption.ts │ │ │ │ │ ├── media.ts │ │ │ │ │ ├── cartLine.ts │ │ │ │ │ ├── productVariant.ts │ │ │ │ │ ├── cart.ts │ │ │ │ │ ├── productSummary.ts │ │ │ │ │ ├── order.ts │ │ │ │ │ ├── customer.ts │ │ │ │ │ └── product.ts │ │ │ │ ├── queries │ │ │ │ │ ├── cart.ts │ │ │ │ │ ├── customer.ts │ │ │ │ │ ├── sitemap.ts │ │ │ │ │ ├── localization.ts │ │ │ │ │ ├── product.ts │ │ │ │ │ ├── collection.ts │ │ │ │ │ └── search.ts │ │ │ │ └── mutations │ │ │ │ │ ├── cart.ts │ │ │ │ │ └── customer.ts │ │ │ └── admin │ │ │ │ └── mutations │ │ │ │ └── customer.ts │ │ ├── operations │ │ │ ├── localization.ts │ │ │ ├── collection.ts │ │ │ ├── sitemap.ts │ │ │ ├── search.ts │ │ │ ├── product.ts │ │ │ └── cart.ts │ │ └── utils │ │ │ └── graphql-client.ts │ ├── utils │ │ └── flatten-connection.ts │ ├── composables │ │ └── use-shopify.ts │ └── server │ │ ├── shopify-admin.post.ts │ │ └── shopify-storefront.post.ts │ └── index.ts ├── .editorconfig ├── .gitignore ├── tsconfig.json ├── .github ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── nuxthub.yml ├── public └── logo.svg ├── server └── api │ └── sitemap.ts ├── LICENSE.md ├── codegen.helpers.ts ├── codegen.schema.ts ├── codegen.ts ├── package.json ├── eslint.config.mjs └── nuxt.config.ts /app/assets/svg/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/fonts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/scripts/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/composables/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/pages/index.vue: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /modules/klaviyo/runtime/composables/use-klaviyo.ts: -------------------------------------------------------------------------------- 1 | import subscribe from '../resources/http/subscribe' 2 | 3 | export const useKlaviyo = () => ({ 4 | subscribe, 5 | }) 6 | -------------------------------------------------------------------------------- /app/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 9 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/money.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const MONEY_FRAGMENT = gql` 4 | fragment Money on MoneyV2 { 5 | amount 6 | currencyCode 7 | } 8 | ` 9 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/language.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const LANGUAGE_FRAGMENT = gql` 4 | fragment Language on Language { 5 | endonymName 6 | isoCode 7 | name 8 | } 9 | ` 10 | -------------------------------------------------------------------------------- /app/plugins/shopify-init.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtPlugin(async () => { 2 | const shopStore = useShopStore() 3 | const cartStore = useCartStore() 4 | 5 | await Promise.all([ 6 | shopStore.getLocalization(), 7 | cartStore.getCart(), 8 | ]) 9 | }) 10 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/image.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const IMAGE_FRAGMENT = gql` 4 | fragment Image on Image { 5 | altText 6 | height 7 | id 8 | url 9 | width 10 | } 11 | ` 12 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/pageInfo.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const PAGE_INFO_FRAGMENT = gql` 4 | fragment PageInfo on PageInfo { 5 | endCursor 6 | hasNextPage 7 | hasPreviousPage 8 | startCursor 9 | } 10 | ` 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | .wrangler 8 | 9 | # Generated 10 | dist 11 | 12 | # Node 13 | node_modules 14 | 15 | # Logs 16 | logs 17 | *.log 18 | 19 | # Misc 20 | .DS_Store 21 | .fleet 22 | .idea 23 | .vscode 24 | 25 | # Env 26 | .env 27 | .env.* 28 | !.env.example 29 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./.nuxt/tsconfig.app.json" 6 | }, 7 | { 8 | "path": "./.nuxt/tsconfig.server.json" 9 | }, 10 | { 11 | "path": "./.nuxt/tsconfig.shared.json" 12 | }, 13 | { 14 | "path": "./.nuxt/tsconfig.node.json" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | 4 | 5 | ## Type of change 6 | 7 | - [ ] Feature 8 | - [ ] Fix 9 | - [ ] Refactor 10 | - [ ] Documentation 11 | 12 | ## Summary 13 | 14 | 15 | 16 | ## Related issues 17 | 18 | 19 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/priceRange.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const PRICE_RANGE_FRAGMENT = gql` 4 | fragment PriceRange on ProductPriceRange { 5 | maxVariantPrice { 6 | amount 7 | currencyCode 8 | } 9 | minVariantPrice { 10 | amount 11 | currencyCode 12 | } 13 | } 14 | ` 15 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/country.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const COUNTRY_FRAGMENT = gql` 4 | fragment Country on Country { 5 | availableLanguages { 6 | endonymName 7 | isoCode 8 | name 9 | } 10 | currency { 11 | isoCode 12 | name 13 | symbol 14 | } 15 | isoCode 16 | name 17 | unitSystem 18 | } 19 | ` 20 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/mediaImage.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | 5 | export const MEDIA_IMAGE_FRAGMENT = gql` 6 | fragment MediaImage on MediaImage { 7 | alt 8 | id 9 | image { 10 | ...Image 11 | } 12 | mediaContentType 13 | previewImage { 14 | ...Image 15 | } 16 | } 17 | ${IMAGE_FRAGMENT} 18 | ` 19 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/queries/cart.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { CART_FRAGMENT } from '../fragments/cart' 4 | 5 | export const CART = gql` 6 | query cart ( 7 | $id: ID! 8 | $country: CountryCode 9 | $language: LanguageCode 10 | ) @inContext(country: $country, language: $language) { 11 | cart (id: $id) { 12 | ...Cart 13 | } 14 | } 15 | ${CART_FRAGMENT} 16 | ` 17 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/model3d.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | 5 | export const MODEL_3D_FRAGMENT = gql` 6 | fragment Model3d on Model3d { 7 | alt 8 | id 9 | mediaContentType 10 | previewImage { 11 | ...Image 12 | } 13 | sources { 14 | filesize 15 | format 16 | mimeType 17 | url 18 | } 19 | } 20 | ${IMAGE_FRAGMENT} 21 | ` 22 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/video.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | 5 | export const VIDEO_FRAGMENT = gql` 6 | fragment Video on Video { 7 | alt 8 | id 9 | mediaContentType 10 | previewImage { 11 | ...Image 12 | } 13 | sources { 14 | format 15 | height 16 | mimeType 17 | url 18 | width 19 | } 20 | } 21 | ${IMAGE_FRAGMENT} 22 | ` 23 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/queries/customer.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { CUSTOMER_FRAGMENT } from '../fragments/customer' 4 | 5 | export const CUSTOMER = gql` 6 | query customer ( 7 | $customerAccessToken: String! 8 | $country: CountryCode 9 | $language: LanguageCode 10 | ) @inContext(country: $country, language: $language) { 11 | customer (customerAccessToken: $customerAccessToken) { 12 | ...Customer 13 | } 14 | } 15 | ${CUSTOMER_FRAGMENT} 16 | ` 17 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/buyerIdentity.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { MAILING_ADDRESS_FRAGMENT } from './mailingAddress' 4 | 5 | export const BUYER_IDENTITY_FRAGMENT = gql` 6 | fragment BuyerIdentity on CartBuyerIdentity { 7 | countryCode 8 | customer { 9 | defaultAddress { 10 | ...MailingAddress 11 | } 12 | displayName 13 | email 14 | id 15 | } 16 | email 17 | phone 18 | } 19 | ${MAILING_ADDRESS_FRAGMENT} 20 | ` 21 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/mailingAddress.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const MAILING_ADDRESS_FRAGMENT = gql` 4 | fragment MailingAddress on MailingAddress { 5 | address1 6 | address2 7 | city 8 | company 9 | country 10 | countryCodeV2 11 | firstName 12 | formatted 13 | formattedArea 14 | id 15 | lastName 16 | latitude 17 | longitude 18 | name 19 | phone 20 | province 21 | provinceCode 22 | zip 23 | } 24 | ` 25 | -------------------------------------------------------------------------------- /app/middleware/account.global.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtRouteMiddleware((to) => { 2 | const isAuth = useAuthStore().isAuthenticated 3 | const secureRoutes = ['account', 'account/orders', 'account/addresses'] 4 | 5 | if (secureRoutes.includes(to.name as string) && !isAuth) { 6 | return navigateTo('/account/login') 7 | } 8 | 9 | if (to.name === 'account-login' && isAuth) { 10 | return navigateTo('/account') 11 | } 12 | 13 | if (to.name === 'account-register' && isAuth) { 14 | return navigateTo('/account') 15 | } 16 | }) 17 | -------------------------------------------------------------------------------- /modules/shopify/runtime/utils/flatten-connection.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Flattens a GraphQL connection object by extracting its nodes. 3 | * @param connection - The object containing edges or nodes 4 | * @returns An array of nodes 5 | */ 6 | export const flattenConnection = ( 7 | connection?: { 8 | edges?: { node: T }[] 9 | nodes?: T[] 10 | } | null, 11 | ): T[] => { 12 | if (Array.isArray(connection?.edges)) { 13 | return connection.edges.map(({ node }) => node) 14 | } 15 | 16 | if (Array.isArray(connection?.nodes)) { 17 | return connection.nodes 18 | } 19 | 20 | return [] 21 | } 22 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/filter.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | 5 | export const FILTER_FRAGMENT = gql` 6 | fragment Filter on Filter { 7 | id 8 | label 9 | type 10 | values { 11 | count 12 | id 13 | label 14 | swatch { 15 | color 16 | image { 17 | alt 18 | id 19 | mediaContentType 20 | previewImage { 21 | ...Image 22 | } 23 | } 24 | } 25 | } 26 | } 27 | ${IMAGE_FRAGMENT} 28 | ` 29 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/productOption.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | 5 | export const PRODUCT_OPTION_FRAGMENT = gql` 6 | fragment ProductOption on ProductOption { 7 | id 8 | name 9 | optionValues { 10 | id 11 | name 12 | swatch { 13 | color 14 | image { 15 | alt 16 | id 17 | mediaContentType 18 | previewImage { 19 | ...Image 20 | } 21 | } 22 | } 23 | } 24 | } 25 | ${IMAGE_FRAGMENT} 26 | ` 27 | -------------------------------------------------------------------------------- /modules/shopify/runtime/composables/use-shopify.ts: -------------------------------------------------------------------------------- 1 | import cart from '../resources/operations/cart' 2 | import collection from '../resources/operations/collection' 3 | import customer from '../resources/operations/customer' 4 | import localization from '../resources/operations/localization' 5 | import product from '../resources/operations/product' 6 | import search from '../resources/operations/search' 7 | import sitemap from '../resources/operations/sitemap' 8 | 9 | export const useShopify = () => ({ 10 | cart, 11 | collection, 12 | customer, 13 | localization, 14 | product, 15 | search, 16 | sitemap, 17 | }) 18 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/admin/mutations/customer.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const CUSTOMER_UPDATE_METAFIELDS = gql` 4 | mutation customerUpdate( 5 | $input: CustomerInput! 6 | ) { 7 | customerUpdate(input: $input) { 8 | customer { 9 | id 10 | metafields(first: 10) { 11 | edges { 12 | node { 13 | id 14 | key 15 | namespace 16 | value 17 | } 18 | } 19 | } 20 | } 21 | userErrors { 22 | message 23 | field 24 | } 25 | } 26 | } 27 | ` 28 | -------------------------------------------------------------------------------- /public/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/queries/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const SITEMAP_COLLECTIONS = gql` 4 | query sitemapCollections($first: Int = 250) { 5 | collections(first: $first, sortKey: TITLE) { 6 | edges { 7 | node { 8 | handle 9 | updatedAt 10 | } 11 | } 12 | } 13 | } 14 | ` 15 | 16 | export const SITEMAP_PRODUCTS = gql` 17 | query sitemapProducts($first: Int = 250) { 18 | products(first: $first, sortKey: TITLE) { 19 | edges { 20 | node { 21 | handle 22 | updatedAt 23 | } 24 | } 25 | } 26 | } 27 | ` 28 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/media.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { MEDIA_IMAGE_FRAGMENT } from './mediaImage' 4 | import { MODEL_3D_FRAGMENT } from './model3d' 5 | import { VIDEO_FRAGMENT } from './video' 6 | 7 | export const MEDIA_FRAGMENT = gql` 8 | fragment Media on Media { 9 | alt 10 | id 11 | mediaContentType 12 | previewImage { 13 | url 14 | } 15 | ... on MediaImage { 16 | ...MediaImage 17 | } 18 | ... on Model3d { 19 | ...Model3d 20 | } 21 | ... on Video { 22 | ...Video 23 | } 24 | } 25 | ${MEDIA_IMAGE_FRAGMENT} 26 | ${VIDEO_FRAGMENT} 27 | ${MODEL_3D_FRAGMENT} 28 | ` 29 | -------------------------------------------------------------------------------- /app/components/shopify/product/form/form-header.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 22 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/queries/localization.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { COUNTRY_FRAGMENT } from '../fragments/country' 4 | import { LANGUAGE_FRAGMENT } from '../fragments/language' 5 | 6 | export const LOCALIZATION = gql` 7 | query localization( 8 | $country: CountryCode 9 | $language: LanguageCode 10 | ) @inContext(country: $country, language: $language) { 11 | localization { 12 | availableLanguages { 13 | ...Language 14 | } 15 | availableCountries { 16 | ...Country 17 | } 18 | country { 19 | ...Country 20 | } 21 | language { 22 | ...Language 23 | } 24 | } 25 | } 26 | ${LANGUAGE_FRAGMENT} 27 | ${COUNTRY_FRAGMENT} 28 | ` 29 | -------------------------------------------------------------------------------- /app/components/shopify/product/product-recommendations.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 28 | -------------------------------------------------------------------------------- /.github/workflows/nuxthub.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to NuxtHub 2 | on: push 3 | 4 | jobs: 5 | deploy: 6 | name: "Deploy to NuxtHub" 7 | runs-on: ubuntu-latest 8 | permissions: 9 | contents: read 10 | id-token: write 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | 15 | - name: Install pnpm 16 | uses: pnpm/action-setup@v4 17 | 18 | - name: Install Node.js 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 22 22 | cache: 'pnpm' 23 | 24 | - name: Install dependencies 25 | run: pnpm install 26 | 27 | - name: Ensure NuxtHub module is installed 28 | run: pnpx nuxthub@latest ensure 29 | 30 | - name: Build & Deploy to NuxtHub 31 | uses: nuxt-hub/action@v2 32 | with: 33 | project-key: nitrogen-4phr 34 | -------------------------------------------------------------------------------- /app/app.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 34 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/cartLine.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { MONEY_FRAGMENT } from './money' 4 | import { PRODUCT_VARIANT_FRAGMENT } from './productVariant' 5 | 6 | export const CART_LINE_FRAGMENT = gql` 7 | fragment CartLine on CartLine { 8 | cost { 9 | subtotalAmount { 10 | ...Money 11 | } 12 | totalAmount { 13 | ...Money 14 | } 15 | } 16 | discountAllocations { 17 | discountedAmount { 18 | ...Money 19 | } 20 | } 21 | id 22 | merchandise { 23 | ... on ProductVariant { 24 | ...ProductVariant 25 | } 26 | } 27 | quantity 28 | sellingPlanAllocation { 29 | sellingPlan { 30 | name 31 | } 32 | } 33 | } 34 | ${MONEY_FRAGMENT} 35 | ${PRODUCT_VARIANT_FRAGMENT} 36 | ` 37 | -------------------------------------------------------------------------------- /app/components/shopify/common/shopify-video.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 33 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/operations/localization.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | LocalizationQuery, 3 | LocalizationQueryVariables, 4 | } from '@@/types/shopify-storefront' 5 | 6 | import { LOCALIZATION } from '../graphql/storefront/queries/localization' 7 | import { query } from '../utils/graphql-client' 8 | 9 | /** 10 | * Fetches the localization data. 11 | * @param variables - The variables for the localization query (country, language) 12 | * @returns A Promise resolving to the localization data 13 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/localization 14 | */ 15 | export const get = async ( 16 | variables: LocalizationQueryVariables, 17 | ): Promise => { 18 | const response = await query(LOCALIZATION, variables) 19 | return response.data?.localization 20 | } 21 | 22 | export default { 23 | get, 24 | } 25 | -------------------------------------------------------------------------------- /modules/shopify/runtime/server/shopify-admin.post.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handles server requests to the Shopify GraphQL Admin API. 3 | * @param event - The H3 event containing the request data 4 | * @returns The response from the Shopify API 5 | * @see https://shopify.dev/docs/api/admin-graphql 6 | */ 7 | export default defineEventHandler(async (event) => { 8 | const { shopify: options } = useRuntimeConfig(event) 9 | const { query, variables } = await readBody(event) 10 | 11 | const endpoint = `https://${options.domain}/admin/api/${options.apiVersion}/graphql.json` 12 | 13 | return await $fetch(endpoint, { 14 | method: 'POST', 15 | headers: { 16 | 'Accept': 'application/json', 17 | 'Content-Type': 'application/json', 18 | 'X-Shopify-Access-Token': options.adminAccessToken, 19 | }, 20 | body: JSON.stringify({ query, variables }), 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /modules/shopify/runtime/server/shopify-storefront.post.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handles server requests to the Shopify GraphQL Storefront API. 3 | * @param event - The H3 event containing the request data 4 | * @returns The response from the Shopify API 5 | * @see https://shopify.dev/docs/api/storefront 6 | */ 7 | export default defineEventHandler(async (event) => { 8 | const { shopify: options } = useRuntimeConfig(event) 9 | const { query, variables } = await readBody(event) 10 | 11 | const endpoint = `https://${options.domain}/api/${options.apiVersion}/graphql.json` 12 | 13 | return await $fetch(endpoint, { 14 | method: 'POST', 15 | headers: { 16 | 'Accept': 'application/json', 17 | 'Content-Type': 'application/json', 18 | 'X-Shopify-Storefront-Access-Token': options.storefrontAccessToken, 19 | }, 20 | body: JSON.stringify({ query, variables }), 21 | }) 22 | }) 23 | -------------------------------------------------------------------------------- /app/components/shopify/search/suggestions/suggested-link.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 33 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/productVariant.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | import { MONEY_FRAGMENT } from './money' 5 | 6 | export const PRODUCT_VARIANT_FRAGMENT = gql` 7 | fragment ProductVariant on ProductVariant { 8 | availableForSale 9 | compareAtPrice { 10 | ...Money 11 | } 12 | currentlyNotInStock 13 | id 14 | image { 15 | ...Image 16 | } 17 | price { 18 | ...Money 19 | } 20 | product { 21 | featuredImage { 22 | ...Image 23 | } 24 | handle 25 | title 26 | } 27 | quantityAvailable 28 | requiresShipping 29 | selectedOptions { 30 | name 31 | value 32 | } 33 | sku 34 | title 35 | unitPrice { 36 | ...Money 37 | } 38 | weight 39 | weightUnit 40 | } 41 | ${IMAGE_FRAGMENT} 42 | ${MONEY_FRAGMENT} 43 | ` 44 | -------------------------------------------------------------------------------- /app/components/app/app-header.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 37 | -------------------------------------------------------------------------------- /app/components/shopify/product-card/product-card-media.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 33 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/cart.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { BUYER_IDENTITY_FRAGMENT } from './buyerIdentity' 4 | import { CART_LINE_FRAGMENT } from './cartLine' 5 | import { MONEY_FRAGMENT } from './money' 6 | import { PAGE_INFO_FRAGMENT } from './pageInfo' 7 | 8 | export const CART_FRAGMENT = gql` 9 | fragment Cart on Cart { 10 | buyerIdentity { 11 | ...BuyerIdentity 12 | } 13 | checkoutUrl 14 | cost { 15 | subtotalAmount { 16 | ...Money 17 | } 18 | totalAmount { 19 | ...Money 20 | } 21 | } 22 | createdAt 23 | id 24 | lines(first: 250) { 25 | edges { 26 | node { 27 | ...CartLine 28 | } 29 | } 30 | pageInfo { 31 | ...PageInfo 32 | } 33 | } 34 | totalQuantity 35 | updatedAt 36 | } 37 | ${BUYER_IDENTITY_FRAGMENT} 38 | ${MONEY_FRAGMENT} 39 | ${PAGE_INFO_FRAGMENT} 40 | ${CART_LINE_FRAGMENT} 41 | ` 42 | -------------------------------------------------------------------------------- /server/api/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { SitemapUrlInput } from '#sitemap/types' 2 | 3 | // Composables 4 | const shopify = useShopify() 5 | 6 | /** 7 | * Generates sitemap URLs for the web app. 8 | * @returns An array of sitemap URL objects 9 | * @see https://nuxt.com/modules/sitemap 10 | */ 11 | export default defineSitemapEventHandler(async () => { 12 | const collections = await shopify.sitemap.getCollections({ first: 250 }) 13 | const collectionUrls: SitemapUrlInput[] = collections.edges.map(({ node }) => ({ 14 | loc: `/collections/${node.handle}`, 15 | lastmod: node.updatedAt, 16 | })) 17 | 18 | const products = await shopify.sitemap.getProducts({ first: 250 }) 19 | const productUrls: SitemapUrlInput[] = products.edges.map(({ node }) => ({ 20 | loc: `/products/${node.handle}`, 21 | lastmod: node.updatedAt, 22 | })) 23 | 24 | return [ 25 | ...collectionUrls, 26 | ...productUrls, 27 | { loc: '/', lastmod: new Date(Date.now()) }, 28 | ] satisfies SitemapUrlInput[] 29 | }) 30 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/productSummary.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | import { MEDIA_FRAGMENT } from './media' 5 | import { PRICE_RANGE_FRAGMENT } from './priceRange' 6 | import { PRODUCT_OPTION_FRAGMENT } from './productOption' 7 | 8 | export const PRODUCT_SUMMARY_FRAGMENT = gql` 9 | fragment ProductSummary on Product { 10 | availableForSale 11 | compareAtPriceRange { 12 | ...PriceRange 13 | } 14 | createdAt 15 | handle 16 | id 17 | media(first: 250) { 18 | edges { 19 | node { 20 | ...Media 21 | } 22 | } 23 | } 24 | options(first: 250) { 25 | ...ProductOption 26 | } 27 | priceRange { 28 | ...PriceRange 29 | } 30 | productType 31 | publishedAt 32 | tags 33 | title 34 | updatedAt 35 | } 36 | ${PRICE_RANGE_FRAGMENT} 37 | ${IMAGE_FRAGMENT} 38 | ${MEDIA_FRAGMENT} 39 | ${PRODUCT_OPTION_FRAGMENT} 40 | ` 41 | -------------------------------------------------------------------------------- /app/components/shopify/cart/cart-summary.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 30 | -------------------------------------------------------------------------------- /app/layouts/account.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 38 | -------------------------------------------------------------------------------- /app/utils/validators.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if a given value is a valid email address. 3 | * @param value - The value to check 4 | * @returns A boolean indicating whether the value is a valid email address 5 | */ 6 | export const isEmail = (value: string): boolean => { 7 | const regex = /^(?!.*[._+-]{2})(?!.*[._+-]$)[a-zA-Z0-9._+-]+(? { 17 | if (typeof value !== 'object' || value === null) return false 18 | return value.constructor === Object 19 | } 20 | 21 | /** 22 | * Checks if a given value is an array. 23 | * @param value - The value to check, which should be an array of strings or numbers 24 | * @returns A boolean indicating whether the value is an array 25 | */ 26 | export const isArray = (value: any[]): boolean => { 27 | return Array.isArray(value) 28 | } 29 | -------------------------------------------------------------------------------- /app/stores/app.ts: -------------------------------------------------------------------------------- 1 | import { defineStore } from 'pinia' 2 | 3 | // Types 4 | type ComponentName 5 | = | 'cartDrawer' 6 | | 'mobileMenu' 7 | | 'filterMenu' 8 | | 'searchMenu' 9 | | 'localeModal' 10 | | 'deleteAddressModal' 11 | | 'backInStockModal' 12 | | 'mediaLightbox' 13 | 14 | type AppState = { 15 | [key in ComponentName]: boolean; 16 | } 17 | 18 | // Store 19 | export const useAppStore = defineStore('@nitrogen/app', { 20 | state: (): AppState => ({ 21 | cartDrawer: false, 22 | mobileMenu: false, 23 | filterMenu: false, 24 | searchMenu: false, 25 | localeModal: false, 26 | deleteAddressModal: false, 27 | backInStockModal: false, 28 | mediaLightbox: false, 29 | }), 30 | 31 | actions: { 32 | /** 33 | * Toggles the visibility state of a UI element. 34 | * @param element - The UI element to toggle 35 | * @param state - Optional boolean to force a specific state 36 | */ 37 | toggle(element: keyof AppState, state?: boolean) { 38 | this[element] = state ?? !this[element] 39 | }, 40 | }, 41 | }) 42 | -------------------------------------------------------------------------------- /modules/klaviyo/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineNuxtModule, 3 | addImports, 4 | addServerHandler, 5 | createResolver, 6 | } from '@nuxt/kit' 7 | 8 | // Interface 9 | export interface ModuleOptions { 10 | apiVersion: string 11 | publicApiKey: string 12 | privateApiKey: string 13 | } 14 | 15 | // Module 16 | export default defineNuxtModule({ 17 | meta: { 18 | name: '@nitrogen/klaviyo', 19 | configKey: 'klaviyo', 20 | compatibility: { 21 | nuxt: '>=3.0.0', 22 | }, 23 | }, 24 | 25 | defaults: { 26 | apiVersion: '2025-01-15', 27 | publicApiKey: '', 28 | privateApiKey: '', 29 | }, 30 | 31 | setup(options, nuxt) { 32 | nuxt.options.runtimeConfig.klaviyo = options 33 | 34 | const { resolve } = createResolver(import.meta.url) 35 | 36 | addImports({ 37 | name: 'useKlaviyo', 38 | from: resolve('runtime/composables/use-klaviyo'), 39 | }) 40 | 41 | addServerHandler({ 42 | method: 'post', 43 | route: '/api/klaviyo', 44 | handler: resolve('runtime/server/klaviyo.post'), 45 | }) 46 | }, 47 | }) 48 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Rylan Harper 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /codegen.helpers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Meant to be used with GraphQL CodeGen to type the Admin API's custom scalars correctly. 3 | * Reference for the GraphQL types: https://shopify.dev/docs/api/admin-graphql/2025-01/scalars/arn 4 | * Note: JSON is generated as 'unknown' by default 5 | */ 6 | export const adminApiCustomScalars = { 7 | Color: 'string', 8 | Date: 'string', 9 | DateTime: 'string', 10 | Decimal: 'string', 11 | HTML: 'string', 12 | ID: 'string', 13 | ISO8601DateTime: 'string', 14 | Money: 'string', 15 | StorefrontID: 'string', 16 | UnsignedInt64: 'string', 17 | URL: 'string', 18 | } 19 | 20 | /** 21 | * Meant to be used with GraphQL CodeGen to type the Storefront API's custom scalars correctly. 22 | * Reference for the GraphQL types: https://shopify.dev/docs/api/storefront/2025-01/scalars/HTML 23 | * Note: JSON is generated as 'unknown' by default 24 | */ 25 | export const storefrontApiCustomScalars = { 26 | Color: 'string', 27 | DateTime: 'string', 28 | Decimal: 'string', 29 | HTML: 'string', 30 | ID: 'string', 31 | ISO8601DateTime: 'string', 32 | UnsignedInt64: 'string', 33 | URL: 'string', 34 | } 35 | -------------------------------------------------------------------------------- /app/components/shopify/common/price-display.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 42 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/queries/product.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { PRODUCT_FRAGMENT } from '../fragments/product' 4 | 5 | export const PRODUCT = gql` 6 | query product( 7 | $handle: String 8 | $country: CountryCode 9 | $language: LanguageCode 10 | ) @inContext(country: $country, language: $language) { 11 | product (handle: $handle) { 12 | ...Product 13 | } 14 | } 15 | ${PRODUCT_FRAGMENT} 16 | ` 17 | 18 | export const PRODUCT_IDS = gql` 19 | query productIds( 20 | $ids: [ID!]! 21 | $country: CountryCode 22 | $language: LanguageCode 23 | ) @inContext(country: $country, language: $language) { 24 | nodes(ids: $ids) { 25 | ... on Product { 26 | ...Product 27 | } 28 | } 29 | } 30 | ${PRODUCT_FRAGMENT} 31 | ` 32 | 33 | export const RECOMMENDED_PRODUCTS = gql` 34 | query ProductRecommendations( 35 | $handle: String 36 | $country: CountryCode 37 | $language: LanguageCode 38 | ) @inContext(country: $country, language: $language) { 39 | recommended: productRecommendations(productHandle: $handle) { 40 | ...Product 41 | } 42 | } 43 | ${PRODUCT_FRAGMENT} 44 | ` 45 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/order.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { MAILING_ADDRESS_FRAGMENT } from './mailingAddress' 4 | import { MONEY_FRAGMENT } from './money' 5 | import { PRODUCT_VARIANT_FRAGMENT } from './productVariant' 6 | 7 | export const ORDER_FRAGMENT = gql` 8 | fragment Order on Order { 9 | canceledAt 10 | currencyCode 11 | customerUrl 12 | edited 13 | email 14 | fulfillmentStatus 15 | id 16 | lineItems(first: 10) { 17 | edges { 18 | node { 19 | quantity 20 | title 21 | variant { 22 | ...ProductVariant 23 | } 24 | } 25 | } 26 | } 27 | name 28 | orderNumber 29 | processedAt 30 | shippingAddress { 31 | ...MailingAddress 32 | } 33 | statusUrl 34 | subtotalPrice { 35 | ...Money 36 | } 37 | totalPrice { 38 | ...Money 39 | }, 40 | totalRefunded { 41 | ...Money 42 | } 43 | totalShippingPrice { 44 | ...Money 45 | } 46 | totalTax { 47 | ...Money 48 | } 49 | } 50 | ${MONEY_FRAGMENT} 51 | ${MAILING_ADDRESS_FRAGMENT} 52 | ${PRODUCT_VARIANT_FRAGMENT} 53 | ` 54 | -------------------------------------------------------------------------------- /app/components/shopify/common/shopify-image.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 39 | -------------------------------------------------------------------------------- /codegen.schema.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from '@graphql-codegen/cli' 2 | 3 | import dotenv from 'dotenv' 4 | 5 | dotenv.config() 6 | 7 | if ( 8 | !process.env.NUXT_SHOPIFY_DOMAIN 9 | || !process.env.NUXT_SHOPIFY_API_VERSION 10 | || !process.env.NUXT_SHOPIFY_ADMIN_ACCESS_TOKEN 11 | || !process.env.NUXT_SHOPIFY_STOREFRONT_ACCESS_TOKEN 12 | ) { 13 | throw new Error( 14 | 'Missing required Shopify environment variables for Codegen support.', 15 | ) 16 | } 17 | 18 | export const adminApiSchema: CodegenConfig['schema'] = { 19 | [`https://${process.env.NUXT_SHOPIFY_DOMAIN}/admin/api/${process.env.NUXT_SHOPIFY_API_VERSION}/graphql.json`]: 20 | { 21 | headers: { 22 | 'content-type': 'application/json', 23 | 'X-Shopify-Access-Token': process.env.NUXT_SHOPIFY_ADMIN_ACCESS_TOKEN, 24 | }, 25 | }, 26 | } 27 | 28 | export const storefrontApiSchema: CodegenConfig['schema'] = { 29 | [`https://${process.env.NUXT_SHOPIFY_DOMAIN}/api/${process.env.NUXT_SHOPIFY_API_VERSION}/graphql.json`]: 30 | { 31 | headers: { 32 | 'content-type': 'application/json', 33 | 'X-Shopify-Storefront-Access-Token': process.env.NUXT_SHOPIFY_STOREFRONT_ACCESS_TOKEN, 34 | }, 35 | }, 36 | } 37 | -------------------------------------------------------------------------------- /app/components/shopify/product-card/product-card.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 38 | -------------------------------------------------------------------------------- /app/components/shopify/search/suggestions/suggested-product-card.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 39 | -------------------------------------------------------------------------------- /modules/klaviyo/runtime/server/klaviyo.post.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Handles server requests to the Klaviyo API. 3 | * @param event - The H3 event containing the request data 4 | * @returns The response from the Klaviyo API 5 | * @see https://developers.klaviyo.com/en/reference/api_overview 6 | */ 7 | export default defineEventHandler(async (event) => { 8 | const { klaviyo: options } = useRuntimeConfig(event) 9 | const body = await readBody(event) 10 | const type = body.data.type 11 | 12 | let endpoint = '' 13 | 14 | switch (type) { 15 | case 'subscription': 16 | endpoint = `https://a.klaviyo.com/client/subscriptions/?company_id=${options.publicApiKey}` 17 | break 18 | case 'back-in-stock-subscription': 19 | endpoint = `https://a.klaviyo.com/client/back-in-stock-subscriptions/?company_id=${options.publicApiKey}` 20 | break 21 | default: 22 | throw createError({ 23 | statusCode: 400, 24 | statusMessage: 'Invalid data type specified.', 25 | }) 26 | } 27 | 28 | return await $fetch(endpoint, { 29 | method: 'POST', 30 | headers: { 31 | 'accept': 'application/json', 32 | 'revision': options.apiVersion, 33 | 'content-type': 'application/json', 34 | 'Authorization': `Klaviyo-API-Key ${options.privateApiKey}`, 35 | }, 36 | body: JSON.stringify(body), 37 | }) 38 | }) 39 | -------------------------------------------------------------------------------- /codegen.ts: -------------------------------------------------------------------------------- 1 | import type { CodegenConfig } from '@graphql-codegen/cli' 2 | 3 | import { adminApiCustomScalars, storefrontApiCustomScalars } from './codegen.helpers' 4 | import { adminApiSchema, storefrontApiSchema } from './codegen.schema' 5 | 6 | const config: CodegenConfig = { 7 | overwrite: true, 8 | generates: { 9 | // Admin 10 | './types/shopify-admin.d.ts': { 11 | schema: adminApiSchema, 12 | documents: './modules/shopify/runtime/resources/graphql/admin/**/*.ts', 13 | plugins: ['typescript', 'typescript-operations'], 14 | config: { 15 | skipTypename: true, 16 | useTypeImports: true, 17 | defaultScalarType: 'unknown', 18 | useImplementingTypes: true, 19 | enumsAsTypes: true, 20 | scalars: adminApiCustomScalars, 21 | }, 22 | }, 23 | // Storefront 24 | './types/shopify-storefront.d.ts': { 25 | schema: storefrontApiSchema, 26 | documents: './modules/shopify/runtime/resources/graphql/storefront/**/*.ts', 27 | plugins: ['typescript', 'typescript-operations'], 28 | config: { 29 | skipTypename: true, 30 | useTypeImports: true, 31 | defaultScalarType: 'unknown', 32 | useImplementingTypes: true, 33 | enumsAsTypes: true, 34 | scalars: storefrontApiCustomScalars, 35 | }, 36 | }, 37 | }, 38 | } 39 | 40 | export default config 41 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/customer.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { MAILING_ADDRESS_FRAGMENT } from './mailingAddress' 4 | import { ORDER_FRAGMENT } from './order' 5 | import { PRODUCT_FRAGMENT } from './product' 6 | 7 | export const CUSTOMER_FRAGMENT = gql` 8 | fragment Customer on Customer { 9 | acceptsMarketing 10 | addresses(first: 250) { 11 | edges { 12 | node { 13 | ...MailingAddress 14 | } 15 | } 16 | } 17 | createdAt 18 | defaultAddress { 19 | ...MailingAddress 20 | } 21 | displayName 22 | email 23 | firstName 24 | id 25 | lastName 26 | numberOfOrders 27 | orders(first: 250, sortKey: PROCESSED_AT, reverse: true) { 28 | edges { 29 | node { 30 | ...Order 31 | } 32 | } 33 | } 34 | phone 35 | tags 36 | updatedAt 37 | 38 | # Custom customer metafields 39 | wishlist: metafield(namespace: "custom", key: "wishlist") { 40 | namespace 41 | key 42 | type 43 | updatedAt 44 | references(first: 100) { 45 | edges { 46 | node { 47 | ... on Product { 48 | ...Product 49 | } 50 | } 51 | } 52 | } 53 | } 54 | } 55 | ${MAILING_ADDRESS_FRAGMENT} 56 | ${ORDER_FRAGMENT} 57 | ${PRODUCT_FRAGMENT} 58 | ` 59 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nitrogen", 3 | "private": true, 4 | "scripts": { 5 | "build": "nuxt build", 6 | "dev": "nuxt dev", 7 | "postinstall": "nuxt prepare", 8 | "codegen": "graphql-codegen --config codegen.ts", 9 | "dev:codegen": "graphql-codegen --config codegen.ts --watch", 10 | "lint": "eslint .", 11 | "lint:fix": "eslint . --fix" 12 | }, 13 | "dependencies": { 14 | "@nuxt/fonts": "^0.12.1", 15 | "@nuxt/icon": "^2.1.0", 16 | "@nuxtjs/robots": "^5.5.6", 17 | "@nuxtjs/sitemap": "^7.4.7", 18 | "@pinia/nuxt": "0.11.3", 19 | "@vueuse/core": "^14.0.0", 20 | "@vueuse/nuxt": "^14.0.0", 21 | "dotenv": "^17.2.3", 22 | "embla-carousel-vue": "^8.6.0", 23 | "graphql": "^16.12.0", 24 | "graphql-tag": "^2.12.6", 25 | "pinia": "^3.0.4", 26 | "pinia-plugin-persistedstate": "^4.7.1" 27 | }, 28 | "devDependencies": { 29 | "@graphql-codegen/cli": "^6.0.2", 30 | "@graphql-codegen/typescript": "^5.0.4", 31 | "@graphql-codegen/typescript-operations": "^5.0.4", 32 | "@iconify-json/ph": "^1.2.2", 33 | "@nuxt/eslint": "^1.10.0", 34 | "@nuxthub/core": "0.9.0", 35 | "@tailwindcss/vite": "^4.1.17", 36 | "eslint": "^9.39.1", 37 | "nuxt": "^4.2.1", 38 | "tailwindcss": "^4.1.17", 39 | "typescript": "^5.9.3", 40 | "vue": "^3.5.24", 41 | "vue-router": "^4.6.3", 42 | "wrangler": "^4.48.0" 43 | }, 44 | "packageManager": "pnpm@10.20.0" 45 | } 46 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/operations/collection.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CollectionQuery, 3 | CollectionQueryVariables, 4 | CollectionFiltersQuery, 5 | CollectionFiltersQueryVariables, 6 | } from '@@/types/shopify-storefront' 7 | 8 | import { 9 | COLLECTION, 10 | COLLECTION_FILTERS, 11 | } from '../graphql/storefront/queries/collection' 12 | import { query } from '../utils/graphql-client' 13 | 14 | /** 15 | * Fetches the collection data. 16 | * @param variables - The variables for the collection query (handle, filters, etc.) 17 | * @returns A Promise resolving to the collection data 18 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/collection 19 | */ 20 | const get = async ( 21 | variables: CollectionQueryVariables, 22 | ): Promise => { 23 | const response = await query(COLLECTION, variables) 24 | return response.data?.collection 25 | } 26 | 27 | /** 28 | * Fetches the collection filter data. 29 | * @param variables - The variables for the collection query (handle) 30 | * @returns A Promise resolving to the collection filters data 31 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/collection 32 | */ 33 | const getFilters = async ( 34 | variables: CollectionFiltersQueryVariables, 35 | ): Promise => { 36 | const response = await query(COLLECTION_FILTERS, variables) 37 | return response.data?.collection 38 | } 39 | 40 | export default { 41 | get, 42 | getFilters, 43 | } 44 | -------------------------------------------------------------------------------- /app/components/shopify/product/product-media-gallery.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 59 | -------------------------------------------------------------------------------- /app/components/shopify/product-card/product-card-tags.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 48 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/operations/sitemap.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SitemapCollectionsQuery, 3 | SitemapCollectionsQueryVariables, 4 | SitemapProductsQuery, 5 | SitemapProductsQueryVariables, 6 | } from '@@/types/shopify-storefront' 7 | 8 | import { 9 | SITEMAP_COLLECTIONS, 10 | SITEMAP_PRODUCTS, 11 | } from '../graphql/storefront/queries/sitemap' 12 | import { query } from '../utils/graphql-client' 13 | 14 | /** 15 | * Fetches all collections for the sitemap. 16 | * @param variables - The variables for the collections query 17 | * @returns A Promise resolving to the collections data 18 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/collections 19 | */ 20 | export const getCollections = async ( 21 | variables: SitemapCollectionsQueryVariables, 22 | ): Promise => { 23 | const response = await query(SITEMAP_COLLECTIONS, variables) 24 | return response.data?.collections 25 | } 26 | 27 | /** 28 | * Fetches all products for the sitemap. 29 | * @param variables - The variables for the products query 30 | * @returns A Promise resolving to the products data 31 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/products 32 | */ 33 | export const getProducts = async ( 34 | variables: SitemapProductsQueryVariables, 35 | ): Promise => { 36 | const response = await query(SITEMAP_PRODUCTS, variables) 37 | return response.data?.products 38 | } 39 | 40 | export default { 41 | getCollections, 42 | getProducts, 43 | } 44 | -------------------------------------------------------------------------------- /modules/shopify/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineNuxtModule, 3 | addImports, 4 | addServerImports, 5 | addServerHandler, 6 | createResolver, 7 | } from '@nuxt/kit' 8 | 9 | // Interface 10 | export interface ModuleOptions { 11 | domain: string 12 | apiVersion: string 13 | adminAccessToken: string 14 | storefrontAccessToken: string 15 | } 16 | 17 | // Module 18 | export default defineNuxtModule({ 19 | meta: { 20 | name: '@nitrogen/shopify', 21 | configKey: 'shopify', 22 | compatibility: { 23 | nuxt: '>=3.0.0', 24 | }, 25 | }, 26 | 27 | defaults: { 28 | domain: '', 29 | apiVersion: '2025-01', 30 | adminAccessToken: '', 31 | storefrontAccessToken: '', 32 | }, 33 | 34 | setup(options, nuxt) { 35 | nuxt.options.runtimeConfig.shopify = options 36 | 37 | const { resolve } = createResolver(import.meta.url) 38 | 39 | const imports = [ 40 | { 41 | name: 'useShopify', 42 | from: resolve('runtime/composables/use-shopify'), 43 | }, 44 | { 45 | name: 'flattenConnection', 46 | from: resolve('runtime/utils/flatten-connection'), 47 | }, 48 | ] 49 | 50 | addImports(imports) 51 | 52 | addServerImports(imports) 53 | 54 | addServerHandler({ 55 | method: 'post', 56 | route: '/api/shopify-admin', 57 | handler: resolve('runtime/server/shopify-admin.post'), 58 | }) 59 | 60 | addServerHandler({ 61 | method: 'post', 62 | route: '/api/shopify-storefront', 63 | handler: resolve('runtime/server/shopify-storefront.post'), 64 | }) 65 | }, 66 | }) 67 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import withNuxt from './.nuxt/eslint.config.mjs' 2 | 3 | export default withNuxt( 4 | { 5 | ignores: [ 6 | 'nuxt.config.ts', 7 | '**/types/**', 8 | ], 9 | }, 10 | { 11 | rules: { 12 | // Vue 13 | 'vue/no-v-html': 'off', 14 | 'vue/html-self-closing': 'off', 15 | 'vue/require-typed-ref': ['error'], 16 | 'vue/prefer-use-template-ref': ['error'], 17 | 'vue/define-props-declaration': ['error', 'type-based'], 18 | 'vue/define-emits-declaration': ['error', 'type-based'], 19 | 'vue/define-macros-order': [ 20 | 'error', 21 | { 22 | order: ['defineProps', 'defineEmits'], 23 | }, 24 | ], 25 | // TypeScript 26 | '@typescript-eslint/no-explicit-any': 'off', 27 | '@typescript-eslint/no-dynamic-delete': 'off', 28 | '@typescript-eslint/no-unused-vars': [ 29 | 'warn', 30 | { 31 | argsIgnorePattern: '^_', 32 | varsIgnorePattern: '^_', 33 | caughtErrorsIgnorePattern: '^_', 34 | }, 35 | ], 36 | // Imports 37 | 'import/order': [ 38 | 'error', 39 | { 40 | groups: [ 41 | 'type', 42 | ['builtin', 'external'], 43 | ['internal', 'parent', 'sibling', 'index', 'object'], 44 | ], 45 | alphabetize: { 46 | order: 'asc', 47 | }, 48 | }, 49 | ], 50 | // Styles 51 | '@stylistic/semi': ['error', 'never'], 52 | '@stylistic/brace-style': ['error', '1tbs'], 53 | '@stylistic/arrow-parens': ['error', 'always'], 54 | }, 55 | }, 56 | ) 57 | -------------------------------------------------------------------------------- /app/assets/styles/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | :root { 4 | --body-text-color: #000; 5 | --body-background-color: #fff; 6 | --body-selection-color: #eee; 7 | --backgound-line-color: #f4f4f4; 8 | --header-height: 36px; 9 | } 10 | 11 | @theme { 12 | --font-sans: 'IBM Plex Sans', sans-serif; 13 | --font-mono: 'IBM Plex Mono', monospace; 14 | } 15 | 16 | @utility wrapper { 17 | display: flex; 18 | flex-direction: column; 19 | flex: 1 1 auto; 20 | width: 100%; 21 | margin-inline: auto; 22 | } 23 | 24 | @utility no-scrollbar { 25 | scrollbar-width: none; 26 | &::-webkit-scrollbar { 27 | display: none; 28 | } 29 | } 30 | 31 | @utility bg-line-pattern { 32 | background-image: linear-gradient(135deg, var(--backgound-line-color) 10%, #0000 0, #0000 50%, var(--backgound-line-color) 0, var(--backgound-line-color) 60%, #0000 0, #0000); 33 | background-size: 7px 7px; 34 | } 35 | 36 | @utility text-normalize { 37 | font: inherit; 38 | text-transform: uppercase; 39 | white-space: nowrap; 40 | } 41 | 42 | html { 43 | font-size: 13px; 44 | font-weight: 400; 45 | font-style: normal; 46 | font-family: 'IBM Plex Mono', monospace; 47 | -webkit-font-smoothing: antialiased; 48 | -moz-osx-font-smoothing: grayscale; 49 | -webkit-text-size-adjust: 100%; 50 | } 51 | 52 | body { 53 | color: var(--body-text-color); 54 | background-color: var(--body-background-color); 55 | overflow-x: hidden; 56 | } 57 | 58 | ::selection { 59 | color: var(--body-text-color); 60 | background: var(--body-selection-color); 61 | } 62 | 63 | a, 64 | button { 65 | cursor: pointer; 66 | touch-action: manipulation; 67 | } 68 | 69 | span { 70 | white-space: nowrap; 71 | text-overflow: ellipsis; 72 | overflow: hidden; 73 | } 74 | 75 | svg, 76 | img { 77 | flex-shrink: 0; 78 | } 79 | -------------------------------------------------------------------------------- /app/pages/account/orders/index.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 66 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import tailwindcss from '@tailwindcss/vite' 2 | 3 | // https://nuxt.com/docs/api/configuration/nuxt-config 4 | export default defineNuxtConfig({ 5 | modules: [ 6 | '@pinia/nuxt', 7 | 'pinia-plugin-persistedstate/nuxt', 8 | '@vueuse/nuxt', 9 | '@nuxtjs/sitemap', 10 | '@nuxtjs/robots', 11 | '@nuxt/icon', 12 | '@nuxt/fonts', 13 | '@nuxt/eslint', 14 | '@nuxthub/core', 15 | ], 16 | 17 | shopify: { 18 | domain: process.env.NUXT_SHOPIFY_DOMAIN, 19 | apiVersion: process.env.NUXT_SHOPIFY_API_VERSION, 20 | adminAccessToken: process.env.NUXT_SHOPIFY_ADMIN_ACCESS_TOKEN, 21 | storefrontAccessToken: process.env.NUXT_SHOPIFY_STOREFRONT_ACCESS_TOKEN, 22 | }, 23 | 24 | klaviyo: { 25 | apiVersion: process.env.NUXT_KLAVIYO_API_VERSION, 26 | publicApiKey: process.env.NUXT_KLAVIYO_PUBLIC_API_KEY, 27 | privateApiKey: process.env.NUXT_KLAVIYO_PRIVATE_API_KEY, 28 | }, 29 | 30 | site: { 31 | url: 'https://nitrogen.nuxt.dev', 32 | name: 'Nitrogen', 33 | }, 34 | 35 | sitemap: { 36 | sources: [ 37 | '/api/sitemap', 38 | ], 39 | }, 40 | 41 | robots: { 42 | disallow: ['/account', '/account/*'], 43 | sitemap: 'https://nitrogen.nuxt.dev/sitemap.xml', 44 | }, 45 | 46 | icon: { 47 | clientBundle: { 48 | scan: true, 49 | sizeLimitKb: 256, 50 | }, 51 | }, 52 | 53 | fonts: { 54 | defaults: { 55 | weights: [400, 500], 56 | styles: ['normal', 'italic'], 57 | subsets: ['latin'], 58 | }, 59 | }, 60 | 61 | eslint: { 62 | config: { 63 | stylistic: true, 64 | }, 65 | }, 66 | 67 | css: ['@/assets/styles/app.css'], 68 | 69 | vite: { 70 | plugins: [tailwindcss()], 71 | }, 72 | 73 | components: [ 74 | { 75 | path: '@/components', 76 | pathPrefix: false, 77 | }, 78 | ], 79 | }) 80 | -------------------------------------------------------------------------------- /app/components/app/nav/nav-mobile.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 69 | -------------------------------------------------------------------------------- /app/components/shopify/search/search-menu.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 79 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/queries/collection.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { FILTER_FRAGMENT } from '../fragments/filter' 4 | import { IMAGE_FRAGMENT } from '../fragments/image' 5 | import { PAGE_INFO_FRAGMENT } from '../fragments/pageInfo' 6 | import { PRODUCT_SUMMARY_FRAGMENT } from '../fragments/productSummary' 7 | 8 | export const COLLECTION = gql` 9 | query collection( 10 | $handle: String 11 | $first: Int 12 | $reverse: Boolean 13 | $sortKey: ProductCollectionSortKeys 14 | $filters: [ProductFilter!] 15 | $language: LanguageCode 16 | $country: CountryCode 17 | ) @inContext(country: $country, language: $language) { 18 | collection(handle: $handle) { 19 | description 20 | descriptionHtml 21 | handle 22 | id 23 | image { 24 | ...Image 25 | } 26 | title 27 | trackingParameters 28 | updatedAt 29 | products( 30 | first: $first 31 | reverse: $reverse 32 | sortKey: $sortKey 33 | filters: $filters 34 | ) { 35 | filters { 36 | ...Filter 37 | } 38 | edges { 39 | node { 40 | ...ProductSummary 41 | } 42 | } 43 | pageInfo { 44 | ...PageInfo 45 | } 46 | } 47 | } 48 | } 49 | ${IMAGE_FRAGMENT} 50 | ${FILTER_FRAGMENT} 51 | ${PRODUCT_SUMMARY_FRAGMENT} 52 | ${PAGE_INFO_FRAGMENT} 53 | ` 54 | 55 | export const COLLECTION_FILTERS = gql` 56 | query collectionFilters( 57 | $handle: String 58 | $country: CountryCode 59 | $language: LanguageCode 60 | ) @inContext(country: $country, language: $language) { 61 | collection(handle: $handle) { 62 | products(first: 250) { 63 | filters { 64 | ...Filter 65 | } 66 | edges { 67 | node { 68 | id 69 | } 70 | } 71 | } 72 | } 73 | } 74 | ${FILTER_FRAGMENT} 75 | ` 76 | -------------------------------------------------------------------------------- /app/components/shopify/product/product-media-lightbox.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 76 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/operations/search.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | SearchQuery, 3 | SearchQueryVariables, 4 | SearchFiltersQuery, 5 | SearchFiltersQueryVariables, 6 | PredictiveSearchQuery, 7 | PredictiveSearchQueryVariables, 8 | } from '@@/types/shopify-storefront' 9 | 10 | import { 11 | SEARCH, 12 | SEARCH_FILTERS, 13 | PREDICTIVE_SEARCH, 14 | } from '../graphql/storefront/queries/search' 15 | import { query } from '../utils/graphql-client' 16 | 17 | /** 18 | * Fetches the search data. 19 | * @param options - The variables for the search query (query, filters, etc.) 20 | * @returns A Promise resolving to the search results 21 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/search 22 | */ 23 | const get = async ( 24 | options: SearchQueryVariables, 25 | ): Promise => { 26 | const response = await query(SEARCH, options) 27 | return response.data?.search 28 | } 29 | 30 | /** 31 | * Fetches the search filter data. 32 | * @param options - The variables for the search query (query) 33 | * @returns A Promise resolving to the search results 34 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/search 35 | */ 36 | const getFilters = async ( 37 | options: SearchFiltersQueryVariables, 38 | ): Promise => { 39 | const response = await query(SEARCH_FILTERS, options) 40 | return response.data?.search 41 | } 42 | 43 | /** 44 | * Fetches the predictive search data. 45 | * @param options - The variables for the predictive search query (query) 46 | * @returns A Promise resolving to the predictive search results 47 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/predictiveSearch 48 | */ 49 | const getPredictive = async ( 50 | options: PredictiveSearchQueryVariables, 51 | ): Promise => { 52 | const response = await query(PREDICTIVE_SEARCH, options) 53 | return response.data?.predictiveSearch 54 | } 55 | 56 | export default { 57 | get, 58 | getFilters, 59 | getPredictive, 60 | } 61 | -------------------------------------------------------------------------------- /app/utils/formatters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Formats a date according to the specified locale. 3 | * @param locale - The locale code (e.g., 'en-US') 4 | * @param d - The date to format, either as a Date object or a string 5 | * @returns The formatted date string 6 | */ 7 | export const formatDateByLocale = (locale: string, d: Date | string): string => { 8 | const options: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'numeric', day: 'numeric' } 9 | return new Date(d).toLocaleDateString(locale, options) 10 | } 11 | 12 | /** 13 | * Formats an image URL to include a specified width. 14 | * @param src - The original image URL 15 | * @param width - The desired width to include in the URL 16 | * @returns The formatted image URL or an empty string if undefined 17 | */ 18 | export const formatImageUrl = (src: string | undefined, width: number): string => { 19 | if (!src) return '' 20 | 21 | const url = new URL(src) 22 | url.searchParams.set('width', String(width)) 23 | 24 | return url.href 25 | } 26 | 27 | /** 28 | * Formats a number as a currency string. 29 | * @param amount - The number (or string) to format 30 | * @param currencyCode - The currency code (e.g., 'USD') 31 | * @param locale - The locale code (e.g., 'en-US') 32 | * @returns The formatted currency string 33 | */ 34 | export const formatCurrency = (amount: string | number, currencyCode: string = 'USD', locale: string = 'en-US'): string => { 35 | const numericAmount = typeof amount === 'string' ? parseFloat(amount) : amount 36 | 37 | return new Intl.NumberFormat(locale, { 38 | style: 'currency', 39 | currency: currencyCode, 40 | minimumFractionDigits: 2, 41 | }) 42 | .format(numericAmount) 43 | .replace(/\.00/g, '') 44 | } 45 | 46 | /** 47 | * Formats a product variant ID to its numeric form. 48 | * @param gid - The variant ID (e.g., 'gid://shopify/ProductVariant/44284874064058') 49 | * @returns The numeric portion of the ID (e.g., '44284874064058') 50 | */ 51 | export const formatVariantId = (gid: string): string => { 52 | return gid.split('/').pop() ?? '' 53 | } 54 | -------------------------------------------------------------------------------- /app/components/shopify/product/form/form-add-to-cart.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 67 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/operations/product.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ProductQuery, 3 | ProductQueryVariables, 4 | ProductIdsQuery, 5 | ProductIdsQueryVariables, 6 | ProductRecommendationsQuery, 7 | ProductRecommendationsQueryVariables, 8 | } from '@@/types/shopify-storefront' 9 | 10 | import { 11 | PRODUCT, 12 | PRODUCT_IDS, 13 | RECOMMENDED_PRODUCTS, 14 | } from '../graphql/storefront/queries/product' 15 | import { query } from '../utils/graphql-client' 16 | 17 | /** 18 | * Fetches the product data. 19 | * @param variables - The variables for the product query (handle) 20 | * @returns A Promise resolving to the product data 21 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/product 22 | */ 23 | const get = async ( 24 | variables: ProductQueryVariables, 25 | ): Promise => { 26 | const response = await query(PRODUCT, variables) 27 | return response.data?.product 28 | } 29 | 30 | /** 31 | * Fetches multiple products based on IDs. 32 | * @param variables - The variables for the products query (IDs) 33 | * @returns A Promise resolving to an array of products 34 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/nodes 35 | */ 36 | const getIds = async ( 37 | variables: ProductIdsQueryVariables, 38 | ): Promise => { 39 | const response = await query(PRODUCT_IDS, variables) 40 | return response.data?.nodes 41 | } 42 | 43 | /** 44 | * Fetches the recommended product data. 45 | * @param variables - The variables for the recommendation query (handle) 46 | * @returns A Promise resolving to an array of recommended products 47 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/productRecommendations 48 | */ 49 | async function getRecommended( 50 | variables: ProductRecommendationsQueryVariables, 51 | ): Promise { 52 | const response = await query(RECOMMENDED_PRODUCTS, variables) 53 | return response.data?.recommended 54 | } 55 | 56 | export default { 57 | get, 58 | getIds, 59 | getRecommended, 60 | } 61 | -------------------------------------------------------------------------------- /app/pages/account/addresses/index.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 74 | -------------------------------------------------------------------------------- /app/stores/shop.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CountryCode, 3 | LanguageCode, 4 | LocalizationQuery, 5 | } from '@@/types/shopify-storefront' 6 | 7 | import { defineStore } from 'pinia' 8 | 9 | // Interface 10 | interface ShopState { 11 | locale: LocalizationQuery['localization'] 12 | } 13 | 14 | // Composables 15 | const shopify = useShopify() 16 | 17 | // Store 18 | export const useShopStore = defineStore('@nikkoel/shop', { 19 | state: (): ShopState => ({ 20 | locale: { 21 | availableCountries: [], 22 | availableLanguages: [], 23 | country: { 24 | isoCode: 'US', 25 | }, 26 | language: { 27 | isoCode: 'EN', 28 | }, 29 | }, 30 | }), 31 | 32 | actions: { 33 | /** 34 | * Fetches localization data from Shopify and updates the store. 35 | * @param newCountryCode - Optional country code input 36 | * @param newLanguageCode - Optional language code input 37 | */ 38 | async getLocalization(newCountryCode?: CountryCode, newLanguageCode?: LanguageCode) { 39 | try { 40 | const response = await shopify.localization.get({ 41 | country: newCountryCode ?? this.locale.country.isoCode, 42 | language: newLanguageCode ?? this.locale.language.isoCode, 43 | }) 44 | 45 | if (!response.country && !response.language) { 46 | throw new Error('No localization data found.') 47 | } 48 | 49 | this.locale.availableCountries = response.availableCountries 50 | this.locale.availableLanguages = response.availableLanguages 51 | this.locale.country = response.country 52 | this.locale.language = response.language 53 | } catch (error) { 54 | console.error('Connot get localization data:', error) 55 | throw error 56 | } 57 | }, 58 | }, 59 | 60 | getters: { 61 | buyerCountryCode: (state) => state.locale?.country?.isoCode, 62 | buyerCurrencyCode: (state) => state.locale?.country?.currency?.isoCode, 63 | buyerCurrencySymbol: (state) => state.locale?.country?.currency?.symbol, 64 | buyerLanguageCode: (state) => state.locale?.language?.isoCode, 65 | }, 66 | 67 | persist: { 68 | pick: ['locale.country', 'locale.language'], 69 | }, 70 | }) 71 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/utils/graphql-client.ts: -------------------------------------------------------------------------------- 1 | import type { DocumentNode } from 'graphql' 2 | 3 | import { print } from 'graphql' 4 | 5 | type QueryOptions = { 6 | api?: string 7 | maxRetries?: number 8 | cacheable?: boolean 9 | } 10 | 11 | const cache = new Map() 12 | 13 | /** 14 | * A minimal GraphQL client that sends a query to the Shopify API. 15 | * @param query - The GraphQL query as a DocumentNode 16 | * @param variables - Optional variables for the GraphQL query 17 | * @returns The response from the Shopify API 18 | */ 19 | export const query = async ( 20 | query: DocumentNode, 21 | variables: Record = {}, 22 | options: QueryOptions = {}, 23 | ) => { 24 | const { 25 | api = 'storefront', 26 | maxRetries = 3, 27 | cacheable = true, 28 | } = options 29 | 30 | // Serialize query and create cache key 31 | const serializedQuery = print(query) 32 | const cacheKey = JSON.stringify({ query: serializedQuery, variables }) 33 | 34 | // Cache only collection, product, and search queries 35 | const shouldCache = cacheable && /query Collection|query Product|query Search/i.test(serializedQuery) 36 | 37 | // Return cached response if applicable 38 | if (shouldCache && cache.has(cacheKey)) { 39 | return cache.get(cacheKey) 40 | } 41 | 42 | // Sends a fetch request to the Shopify API 43 | const fetchRequest = async (retryCount = 0) => { 44 | try { 45 | const response = await $fetch(`/api/shopify-${api}`, { 46 | method: 'POST', 47 | body: { query: serializedQuery, variables }, 48 | }) 49 | 50 | // Cache response only if applicable 51 | if (shouldCache) { 52 | cache.set(cacheKey, response) 53 | setTimeout(() => cache.delete(cacheKey), 5 * 60 * 1000) // 5 minutes 54 | } 55 | 56 | return response 57 | } catch (error: any) { 58 | const count = retryCount + 1 59 | 60 | if (retryCount < maxRetries) { 61 | console.warn(`Retrying ${api} API fetch request (${count}/${maxRetries})`) 62 | await new Promise((resolve) => setTimeout(resolve, 1000)) 63 | return fetchRequest(count) 64 | } 65 | 66 | throw createError({ 67 | statusCode: 500, 68 | statusMessage: `Shopify API error: GraphQL request failed after ${maxRetries} attempts.`, 69 | data: { error: error.message }, 70 | }) 71 | } 72 | } 73 | 74 | return fetchRequest() 75 | } 76 | -------------------------------------------------------------------------------- /app/components/shopify/product/form/form-size-options.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 75 | -------------------------------------------------------------------------------- /modules/klaviyo/runtime/resources/http/subscribe.ts: -------------------------------------------------------------------------------- 1 | export interface KlaviyoError { 2 | id: string 3 | code: string 4 | title: string 5 | detail: string 6 | source?: { 7 | pointer?: string 8 | parameter?: string 9 | } 10 | } 11 | 12 | export interface KlaviyoApiResponse { 13 | data?: T 14 | errors?: KlaviyoError[] 15 | } 16 | 17 | /** 18 | * Subscribes a user to a specified email list. 19 | * @param email - The user's email address 20 | * @param listId - The email list ID (newsletter) 21 | * @returns A Promise resolving to the subscription data 22 | * @see https://developers.klaviyo.com/en/reference/create_client_subscription 23 | */ 24 | const newsletter = async ( 25 | email: string, 26 | listId: string, 27 | ): Promise => { 28 | return await $fetch('/api/klaviyo', { 29 | method: 'POST', 30 | body: { 31 | data: { 32 | type: 'subscription', 33 | attributes: { 34 | profile: { 35 | data: { 36 | type: 'profile', 37 | attributes: { email }, 38 | }, 39 | }, 40 | }, 41 | relationships: { 42 | list: { 43 | data: { 44 | type: 'list', 45 | id: listId, 46 | }, 47 | }, 48 | }, 49 | }, 50 | }, 51 | }) 52 | } 53 | 54 | /** 55 | * Subscribes a user to back-in-stock notifications. 56 | * @param email - The user's email address 57 | * @param variantId - The selected product variant ID 58 | * @returns A Promise resolving to the subscription data 59 | * @see https://developers.klaviyo.com/en/reference/create_client_back_in_stock_subscription 60 | */ 61 | const backInStock = async ( 62 | email: string, 63 | variantId: string, 64 | ): Promise => { 65 | return await $fetch('/api/klaviyo', { 66 | method: 'POST', 67 | body: { 68 | data: { 69 | type: 'back-in-stock-subscription', 70 | attributes: { 71 | channels: ['EMAIL'], 72 | profile: { 73 | data: { 74 | type: 'profile', 75 | attributes: { email }, 76 | }, 77 | }, 78 | }, 79 | relationships: { 80 | variant: { 81 | data: { 82 | type: 'catalog-variant', 83 | id: `$shopify:::$default:::${variantId}`, 84 | }, 85 | }, 86 | }, 87 | }, 88 | }, 89 | }) 90 | } 91 | 92 | export default { 93 | newsletter, 94 | backInStock, 95 | } 96 | -------------------------------------------------------------------------------- /app/utils/modifiers.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Converts a string to a URL-friendly slug. 3 | * @param text - The input string to slugify 4 | * @returns The slugified string 5 | */ 6 | export const slugify = (text: string): string => { 7 | return text 8 | .toLowerCase() 9 | .replace(/[^\w ]+/g, '') 10 | .replace(/ +/g, '-') 11 | } 12 | 13 | /** 14 | * Converts a slug back to a normal string with spaces. 15 | * @param text - The slugified string 16 | * @returns The deslugified string 17 | */ 18 | export const deslugify = (text: string): string => { 19 | return text.toLowerCase().replace(/-/g, ' ') 20 | } 21 | 22 | /** 23 | * Converts a string to camelCase. 24 | * @param text - The input string 25 | * @returns The camelCased string 26 | */ 27 | export const camelCase = (text: string): string => { 28 | if (!text) return '' 29 | return text 30 | .trim() 31 | .replace(/[^\w\s-]/g, '') 32 | .split(/[-\s]/) 33 | .map((word, index) => { 34 | if (index === 0) return word.toLowerCase() 35 | return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase() 36 | }) 37 | .join('') 38 | } 39 | 40 | /** 41 | * Converts a string to PascalCase. 42 | * @param text - The input string 43 | * @returns The PascalCased string 44 | */ 45 | export const pascalCase = (text: string): string => { 46 | if (!text) return '' 47 | return text 48 | .trim() 49 | .replace(/[^\w\s-]/g, '') 50 | .split(/[-\s]/) 51 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 52 | .join('') 53 | } 54 | 55 | /** 56 | * Converts a string to snake_case. 57 | * @param text - The input string 58 | * @returns The snake_cased string 59 | */ 60 | export const snakeCase = (text: string): string => { 61 | if (!text) return '' 62 | return text 63 | .trim() 64 | .replace(/[^\w\s]/g, '') 65 | .replace(/\s+/g, '_') 66 | .toLowerCase() 67 | } 68 | 69 | /** 70 | * Converts a string to kebab-case. 71 | * @param text - The input string 72 | * @returns The kebab-cased string 73 | */ 74 | export const kebabCase = (text: string): string => { 75 | if (!text) return '' 76 | return text 77 | .trim() 78 | .toLowerCase() 79 | .replace(/[^\w\s-]/g, '') 80 | .replace(/\s+/g, '-') 81 | } 82 | 83 | /** 84 | * Converts a string to Title Case. 85 | * @param text - The input string 86 | * @returns The title-cased string 87 | */ 88 | export const titleCase = (text: string): string => { 89 | return text 90 | .replace(/(?:^\w|[A-Z]|\b\w)/g, (word) => { 91 | return word.toUpperCase() 92 | }) 93 | .replace(/\s+/g, ' ') 94 | } 95 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/fragments/product.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { IMAGE_FRAGMENT } from './image' 4 | import { MEDIA_FRAGMENT } from './media' 5 | import { PRICE_RANGE_FRAGMENT } from './priceRange' 6 | import { PRODUCT_OPTION_FRAGMENT } from './productOption' 7 | import { PRODUCT_VARIANT_FRAGMENT } from './productVariant' 8 | 9 | export const PRODUCT_FRAGMENT = gql` 10 | fragment Product on Product { 11 | availableForSale 12 | compareAtPriceRange { 13 | ...PriceRange 14 | } 15 | createdAt 16 | description 17 | descriptionHtml 18 | featuredImage { 19 | ...Image 20 | } 21 | handle 22 | id 23 | isGiftCard 24 | media(first: 250) { 25 | edges { 26 | node { 27 | ...Media 28 | } 29 | } 30 | } 31 | onlineStoreUrl 32 | options(first: 250) { 33 | ...ProductOption 34 | } 35 | priceRange { 36 | ...PriceRange 37 | } 38 | productType 39 | publishedAt 40 | tags 41 | title 42 | totalInventory 43 | trackingParameters 44 | updatedAt 45 | variants(first: 250) { 46 | edges { 47 | node { 48 | ...ProductVariant 49 | } 50 | } 51 | } 52 | 53 | # Custom product metafields 54 | filter_color: metafield(namespace: "custom", key: "filter_color") { 55 | key 56 | value 57 | references(first: 10) { 58 | edges { 59 | node { 60 | ... on Metaobject { 61 | fields { 62 | key 63 | value 64 | } 65 | handle 66 | id 67 | } 68 | } 69 | } 70 | } 71 | } 72 | matching_colors: metafield(namespace: "custom", key: "matching_colors") { 73 | key 74 | value 75 | references(first: 10) { 76 | edges { 77 | node { 78 | ...on Product { 79 | availableForSale 80 | handle 81 | id 82 | options(first: 250) { 83 | ...ProductOption 84 | } 85 | } 86 | } 87 | } 88 | } 89 | } 90 | details: metafield(namespace: "custom", key: "details") { 91 | key 92 | value 93 | } 94 | shipping: metafield(namespace: "custom", key: "shipping") { 95 | key 96 | value 97 | } 98 | } 99 | ${PRICE_RANGE_FRAGMENT} 100 | ${IMAGE_FRAGMENT} 101 | ${MEDIA_FRAGMENT} 102 | ${PRODUCT_OPTION_FRAGMENT} 103 | ${PRODUCT_VARIANT_FRAGMENT} 104 | ` 105 | -------------------------------------------------------------------------------- /app/components/klaviyo/klaviyo-newsletter.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 97 | -------------------------------------------------------------------------------- /app/components/app/menus/mobile-menu.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 66 | 67 | 100 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/mutations/cart.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { CART_FRAGMENT } from '../fragments/cart' 4 | 5 | export const CART_CREATE = gql` 6 | mutation cartCreate( 7 | $input: CartInput 8 | $country: CountryCode 9 | $language: LanguageCode 10 | ) @inContext(country: $country, language: $language) { 11 | cartCreate(input: $input) { 12 | cart { 13 | ...Cart 14 | } 15 | userErrors { 16 | code 17 | field 18 | message 19 | } 20 | } 21 | } 22 | ${CART_FRAGMENT} 23 | ` 24 | 25 | export const CART_LINES_ADD = gql` 26 | mutation cartLinesAdd( 27 | $cartId: ID! 28 | $lines: [CartLineInput!]! 29 | $country: CountryCode 30 | $language: LanguageCode 31 | ) @inContext(country: $country, language: $language) { 32 | cartLinesAdd(cartId: $cartId, lines: $lines) { 33 | cart { 34 | ...Cart 35 | } 36 | userErrors { 37 | code 38 | field 39 | message 40 | } 41 | } 42 | } 43 | ${CART_FRAGMENT} 44 | ` 45 | 46 | export const CART_LINES_REMOVE = gql` 47 | mutation cartLinesRemove( 48 | $cartId: ID! 49 | $lineIds: [ID!]! 50 | $country: CountryCode 51 | $language: LanguageCode 52 | ) @inContext(country: $country, language: $language) { 53 | cartLinesRemove(cartId: $cartId, lineIds: $lineIds) { 54 | cart { 55 | ...Cart 56 | } 57 | userErrors { 58 | code 59 | field 60 | message 61 | } 62 | } 63 | } 64 | ${CART_FRAGMENT} 65 | ` 66 | 67 | export const CART_LINES_UPDATE = gql` 68 | mutation cartLinesUpdate( 69 | $cartId: ID! 70 | $lines: [CartLineUpdateInput!]! 71 | $country: CountryCode 72 | $language: LanguageCode 73 | ) @inContext(country: $country, language: $language) { 74 | cartLinesUpdate(cartId: $cartId, lines: $lines) { 75 | cart { 76 | ...Cart 77 | } 78 | userErrors { 79 | code 80 | field 81 | message 82 | } 83 | } 84 | } 85 | ${CART_FRAGMENT} 86 | ` 87 | 88 | export const CART_BUYER_IDENTITY_UPDATE = gql` 89 | mutation cartBuyerIdentityUpdate( 90 | $buyerIdentity: CartBuyerIdentityInput! 91 | $cartId: ID! 92 | $country: CountryCode 93 | $language: LanguageCode 94 | ) @inContext(country: $country, language: $language) { 95 | cartBuyerIdentityUpdate(buyerIdentity: $buyerIdentity, cartId: $cartId) { 96 | cart { 97 | ...Cart 98 | } 99 | userErrors { 100 | code 101 | field 102 | message 103 | } 104 | } 105 | } 106 | ${CART_FRAGMENT} 107 | ` 108 | -------------------------------------------------------------------------------- /app/components/shopify/account/account-orders.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 88 | -------------------------------------------------------------------------------- /app/components/shopify/account/account-address.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 81 | -------------------------------------------------------------------------------- /app/components/app/nav/nav-desktop.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 94 | -------------------------------------------------------------------------------- /app/pages/products/[handle].vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 97 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/queries/search.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | import { FILTER_FRAGMENT } from '../fragments/filter' 4 | import { IMAGE_FRAGMENT } from '../fragments/image' 5 | import { PAGE_INFO_FRAGMENT } from '../fragments/pageInfo' 6 | import { PRICE_RANGE_FRAGMENT } from '../fragments/priceRange' 7 | import { PRODUCT_FRAGMENT } from '../fragments/product' 8 | import { PRODUCT_OPTION_FRAGMENT } from '../fragments/productOption' 9 | 10 | export const SEARCH = gql` 11 | query search( 12 | $searchTerm: String! 13 | $first: Int 14 | $reverse: Boolean 15 | $sortKey: SearchSortKeys 16 | $filters: [ProductFilter!] 17 | $country: CountryCode 18 | $language: LanguageCode 19 | ) @inContext(country: $country, language: $language) { 20 | search( 21 | query: $searchTerm 22 | first: $first 23 | reverse: $reverse 24 | sortKey: $sortKey 25 | productFilters: $filters 26 | types: PRODUCT 27 | ) { 28 | filters: productFilters { 29 | ...Filter 30 | } 31 | edges { 32 | node { 33 | ... on Product { 34 | ...Product 35 | } 36 | } 37 | } 38 | pageInfo { 39 | ...PageInfo 40 | } 41 | totalCount 42 | } 43 | } 44 | ${FILTER_FRAGMENT} 45 | ${PRODUCT_FRAGMENT} 46 | ${PAGE_INFO_FRAGMENT} 47 | ` 48 | 49 | export const SEARCH_FILTERS = gql` 50 | query searchFilters( 51 | $searchTerm: String! 52 | $country: CountryCode 53 | $language: LanguageCode 54 | ) @inContext(country: $country, language: $language) { 55 | search( 56 | query: $searchTerm 57 | first: 250 58 | types: PRODUCT 59 | ) { 60 | filters: productFilters { 61 | ...Filter 62 | } 63 | edges { 64 | node { 65 | ... on Product { 66 | id 67 | } 68 | } 69 | } 70 | } 71 | } 72 | ${FILTER_FRAGMENT} 73 | ` 74 | 75 | export const PREDICTIVE_SEARCH = gql` 76 | query predictiveSearch( 77 | $query: String! 78 | $country: CountryCode 79 | $language: LanguageCode 80 | ) @inContext(country: $country, language: $language) { 81 | predictiveSearch( 82 | query: $query 83 | limit: 6 84 | types: [PRODUCT, COLLECTION, QUERY] 85 | ) { 86 | products { 87 | compareAtPriceRange { 88 | ...PriceRange 89 | } 90 | description 91 | featuredImage { 92 | ...Image 93 | } 94 | handle 95 | options(first: 250) { 96 | ...ProductOption 97 | } 98 | id 99 | priceRange { 100 | ...PriceRange 101 | } 102 | title 103 | } 104 | collections { 105 | handle 106 | id 107 | title 108 | } 109 | queries { 110 | text 111 | } 112 | } 113 | } 114 | ${IMAGE_FRAGMENT} 115 | ${PRICE_RANGE_FRAGMENT} 116 | ${PRODUCT_OPTION_FRAGMENT} 117 | ` 118 | -------------------------------------------------------------------------------- /app/components/shopify/product/product-form.vue: -------------------------------------------------------------------------------- 1 | 70 | 71 | 101 | -------------------------------------------------------------------------------- /app/components/shopify/product/form/form-color-options.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 95 | -------------------------------------------------------------------------------- /app/pages/account/recover.vue: -------------------------------------------------------------------------------- 1 | 50 | 51 | 110 | -------------------------------------------------------------------------------- /app/components/shopify/modals/delete-address-modal.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 88 | 89 | 122 | -------------------------------------------------------------------------------- /app/pages/account/index.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 110 | -------------------------------------------------------------------------------- /app/components/shopify/product/product-media-carousel.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 124 | -------------------------------------------------------------------------------- /app/components/shopify/cart/cart-drawer.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 91 | 92 | 125 | -------------------------------------------------------------------------------- /app/components/shopify/cart/cart-line.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | 109 | -------------------------------------------------------------------------------- /app/components/shopify/search/search-menu-mobile.vue: -------------------------------------------------------------------------------- 1 | 49 | 50 | 108 | 109 | 142 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/operations/cart.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CartQuery, 3 | CartQueryVariables, 4 | CartCreateMutation, 5 | CartCreateMutationVariables, 6 | CartLinesAddMutation, 7 | CartLinesAddMutationVariables, 8 | CartLinesRemoveMutation, 9 | CartLinesRemoveMutationVariables, 10 | CartLinesUpdateMutation, 11 | CartLinesUpdateMutationVariables, 12 | CartBuyerIdentityUpdateMutation, 13 | CartBuyerIdentityUpdateMutationVariables, 14 | } from '@@/types/shopify-storefront' 15 | 16 | import { 17 | CART_CREATE, 18 | CART_LINES_ADD, 19 | CART_LINES_REMOVE, 20 | CART_LINES_UPDATE, 21 | CART_BUYER_IDENTITY_UPDATE, 22 | } from '../graphql/storefront/mutations/cart' 23 | import { CART } from '../graphql/storefront/queries/cart' 24 | import { query } from '../utils/graphql-client' 25 | 26 | /** 27 | * Fetches the cart data. 28 | * @param variables - The variables for the cart query (cart ID) 29 | * @returns A Promise resolving to the cart data 30 | * @see https://shopify.dev/docs/api/storefront/2025-01/queries/cart 31 | */ 32 | const get = async ( 33 | variables: CartQueryVariables, 34 | ): Promise => { 35 | const response = await query(CART, variables) 36 | return response.data?.cart 37 | } 38 | 39 | /** 40 | * Creates a new cart. 41 | * @param variables - The variables for the cart creation mutation (input details) 42 | * @returns A Promise resolving to the created cart 43 | * @see https://shopify.dev/docs/api/storefront/2025-01/mutations/cartCreate 44 | */ 45 | const create = async ( 46 | variables: CartCreateMutationVariables, 47 | ): Promise => { 48 | const response = await query(CART_CREATE, variables) 49 | return response.data?.cartCreate 50 | } 51 | 52 | /** 53 | * Adds line items to the cart. 54 | * @param variables - The variables for the cart lines add mutation (cart ID, lines) 55 | * @returns A Promise resolving to the updated cart after adding lines 56 | * @see https://shopify.dev/docs/api/storefront/2025-01/mutations/cartLinesAdd 57 | */ 58 | const addLines = async ( 59 | variables: CartLinesAddMutationVariables, 60 | ): Promise => { 61 | const response = await query(CART_LINES_ADD, variables) 62 | return response.data?.cartLinesAdd 63 | } 64 | 65 | /** 66 | * Removes line items from the cart. 67 | * @param variables - The variables for the cart lines remove mutation (cart ID, line IDs) 68 | * @returns A Promise resolving to the updated cart after removing lines 69 | * @see https://shopify.dev/docs/api/storefront/2025-01/mutations/cartLinesRemove 70 | */ 71 | const removeLines = async ( 72 | variables: CartLinesRemoveMutationVariables, 73 | ): Promise => { 74 | const response = await query(CART_LINES_REMOVE, variables) 75 | return response.data?.cartLinesRemove 76 | } 77 | 78 | /** 79 | * Updates line items in the cart. 80 | * @param variables - The variables for the cart lines update mutation (cart ID, lines) 81 | * @returns A Promise resolving to the updated cart after updating lines 82 | * @see https://shopify.dev/docs/api/storefront/2025-01/mutations/cartLinesUpdate 83 | */ 84 | const updateLines = async ( 85 | variables: CartLinesUpdateMutationVariables, 86 | ): Promise => { 87 | const response = await query(CART_LINES_UPDATE, variables) 88 | return response.data?.cartLinesUpdate 89 | } 90 | 91 | /** 92 | * Updates the buyer's identity in the cart. 93 | * @param variables - The variables for the cart buyer identity update mutation (cart ID, buyer identity) 94 | * @returns A Promise resolving to the updated cart with the new buyer identity 95 | * @see https://shopify.dev/docs/api/storefront/2025-01/mutations/cartBuyerIdentityUpdate 96 | */ 97 | const updateBuyerIdentity = async ( 98 | variables: CartBuyerIdentityUpdateMutationVariables, 99 | ): Promise => { 100 | const response = await query(CART_BUYER_IDENTITY_UPDATE, variables) 101 | return response.data?.cartBuyerIdentityUpdate 102 | } 103 | 104 | export default { 105 | get, 106 | create, 107 | addLines, 108 | removeLines, 109 | updateLines, 110 | updateBuyerIdentity, 111 | } 112 | -------------------------------------------------------------------------------- /app/components/shopify/modals/locale-modal.vue: -------------------------------------------------------------------------------- 1 | 47 | 48 | 115 | 116 | 149 | -------------------------------------------------------------------------------- /app/components/shopify/account/account-menu.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 136 | -------------------------------------------------------------------------------- /app/pages/account/login.vue: -------------------------------------------------------------------------------- 1 | 67 | 68 | 151 | -------------------------------------------------------------------------------- /app/components/shopify/product/form/form-details.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 147 | 148 | 155 | -------------------------------------------------------------------------------- /app/components/shopify/filter/filter-menu.vue: -------------------------------------------------------------------------------- 1 | 158 | 159 | 183 | -------------------------------------------------------------------------------- /app/components/app/app-footer.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 142 | -------------------------------------------------------------------------------- /app/components/shopify/search/search-menu-desktop.vue: -------------------------------------------------------------------------------- 1 | 56 | 57 | 149 | 150 | 183 | -------------------------------------------------------------------------------- /app/components/klaviyo/klaviyo-back-in-stock-modal.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 150 | 151 | 184 | -------------------------------------------------------------------------------- /app/pages/account/reset.vue: -------------------------------------------------------------------------------- 1 | 81 | 82 | 168 | -------------------------------------------------------------------------------- /modules/shopify/runtime/resources/graphql/storefront/mutations/customer.ts: -------------------------------------------------------------------------------- 1 | import { gql } from 'graphql-tag' 2 | 3 | export const CUSTOMER_ACCESS_TOKEN_CREATE = gql` 4 | mutation customerAccessTokenCreate( 5 | $input: CustomerAccessTokenCreateInput! 6 | $country: CountryCode 7 | $language: LanguageCode 8 | ) @inContext(country: $country, language: $language) { 9 | customerAccessTokenCreate(input: $input) { 10 | customerAccessToken { 11 | accessToken 12 | expiresAt 13 | } 14 | customerUserErrors { 15 | code 16 | field 17 | message 18 | } 19 | } 20 | } 21 | ` 22 | 23 | export const CUSTOMER_ACCESS_TOKEN_DELETE = gql` 24 | mutation CustomerAccessTokenDelete( 25 | $customerAccessToken: String! 26 | $country: CountryCode 27 | $language: LanguageCode 28 | ) @inContext(country: $country, language: $language) { 29 | customerAccessTokenDelete(customerAccessToken: $customerAccessToken) { 30 | deletedAccessToken 31 | deletedCustomerAccessTokenId 32 | userErrors { 33 | field 34 | message 35 | } 36 | } 37 | } 38 | ` 39 | 40 | export const CUSTOMER_CREATE = gql` 41 | mutation customerCreate( 42 | $input: CustomerCreateInput! 43 | $country: CountryCode 44 | $language: LanguageCode 45 | ) @inContext(country: $country, language: $language) { 46 | customerCreate(input: $input) { 47 | customer { 48 | id 49 | } 50 | customerUserErrors { 51 | code 52 | field 53 | message 54 | } 55 | } 56 | } 57 | ` 58 | 59 | export const CUSTOMER_RECOVER = gql` 60 | mutation customerRecover( 61 | $email: String! 62 | $country: CountryCode 63 | $language: LanguageCode 64 | ) @inContext(country: $country, language: $language) { 65 | customerRecover(email: $email) { 66 | customerUserErrors { 67 | code 68 | field 69 | message 70 | } 71 | } 72 | } 73 | ` 74 | 75 | export const CUSTOMER_RESET = gql` 76 | mutation customerReset( 77 | $id: ID! 78 | $input: CustomerResetInput! 79 | $country: CountryCode 80 | $language: LanguageCode 81 | ) @inContext(country: $country, language: $language) { 82 | customerReset(id: $id, input: $input) { 83 | customerAccessToken { 84 | accessToken 85 | expiresAt 86 | } 87 | customerUserErrors { 88 | code 89 | field 90 | message 91 | } 92 | } 93 | } 94 | ` 95 | 96 | export const CUSTOMER_RESET_BY_URL = gql` 97 | mutation customerResetByUrl( 98 | $password: String! 99 | $resetUrl: URL! 100 | $country: CountryCode 101 | $language: LanguageCode 102 | ) @inContext(country: $country, language: $language) { 103 | customerResetByUrl(password: $password, resetUrl: $resetUrl) { 104 | customerAccessToken { 105 | accessToken 106 | expiresAt 107 | } 108 | customerUserErrors { 109 | code 110 | field 111 | message 112 | } 113 | } 114 | } 115 | ` 116 | 117 | export const CUSTOMER_ADDRESS_CREATE = gql` 118 | mutation customerAddressCreate( 119 | $address: MailingAddressInput! 120 | $customerAccessToken: String! 121 | $country: CountryCode 122 | $language: LanguageCode 123 | ) @inContext(country: $country, language: $language) { 124 | customerAddressCreate( 125 | address: $address 126 | customerAccessToken: $customerAccessToken 127 | ) { 128 | customerAddress { 129 | id 130 | } 131 | customerUserErrors { 132 | code 133 | field 134 | message 135 | } 136 | } 137 | } 138 | ` 139 | 140 | export const CUSTOMER_ADDRESS_DELETE = gql` 141 | mutation customerAddressDelete( 142 | $customerAccessToken: String! 143 | $id: ID! 144 | $country: CountryCode 145 | $language: LanguageCode 146 | ) @inContext(country: $country, language: $language) { 147 | customerAddressDelete(customerAccessToken: $customerAccessToken, id: $id) { 148 | customerUserErrors { 149 | code 150 | field 151 | message 152 | } 153 | deletedCustomerAddressId 154 | } 155 | } 156 | ` 157 | 158 | export const CUSTOMER_ADDRESS_UPDATE = gql` 159 | mutation customerAddressUpdate( 160 | $address: MailingAddressInput! 161 | $customerAccessToken: String! 162 | $id: ID! 163 | $country: CountryCode 164 | $language: LanguageCode 165 | ) @inContext(country: $country, language: $language) { 166 | customerAddressUpdate( 167 | address: $address 168 | customerAccessToken: $customerAccessToken 169 | id: $id 170 | ) { 171 | customerAddress { 172 | id 173 | } 174 | customerUserErrors { 175 | code 176 | field 177 | message 178 | } 179 | } 180 | } 181 | ` 182 | 183 | export const CUSTOMER_DEFAULT_ADDRESS_UPDATE = gql` 184 | mutation customerDefaultAddressUpdate( 185 | $addressId: ID! 186 | $customerAccessToken: String! 187 | $country: CountryCode 188 | $language: LanguageCode 189 | ) @inContext(country: $country, language: $language) { 190 | customerDefaultAddressUpdate( 191 | addressId: $addressId 192 | customerAccessToken: $customerAccessToken 193 | ) { 194 | customerUserErrors { 195 | code 196 | field 197 | message 198 | } 199 | } 200 | } 201 | ` 202 | -------------------------------------------------------------------------------- /app/stores/auth.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CustomerQuery, 3 | CustomerQueryVariables, 4 | CustomerAccessTokenCreateInput, 5 | CustomerCreateInput, 6 | } from '@@/types/shopify-storefront' 7 | 8 | import { defineStore } from 'pinia' 9 | 10 | // Interface 11 | interface AuthState { 12 | accessToken: CustomerQueryVariables['customerAccessToken'] 13 | customer: CustomerQuery['customer'] | null 14 | } 15 | 16 | // Composables 17 | const shopify = useShopify() 18 | 19 | // Store 20 | export const useAuthStore = defineStore('@nikkoel/auth', { 21 | state: (): AuthState => ({ 22 | accessToken: '', 23 | customer: null, 24 | }), 25 | 26 | actions: { 27 | /** 28 | * Creates a customer access token. 29 | * @param input - The input data for creating the token 30 | */ 31 | async createToken(input: CustomerAccessTokenCreateInput) { 32 | try { 33 | const response = await shopify.customer.createAccessToken({ 34 | input: input, 35 | }) 36 | 37 | if (response?.customerUserErrors?.length) { 38 | throw new Error(response?.customerUserErrors[0]?.message) 39 | } 40 | 41 | if (response?.customerAccessToken) { 42 | this.accessToken = response.customerAccessToken.accessToken 43 | await this.getCustomer() 44 | } 45 | } catch (error) { 46 | console.error('Cannot create customer access token:', error) 47 | throw error 48 | } 49 | }, 50 | /** 51 | * Fetches the customer data using the stored access token. 52 | */ 53 | async getCustomer() { 54 | try { 55 | const response = await shopify.customer.get({ 56 | customerAccessToken: this.accessToken, 57 | }) 58 | 59 | if (!response) { 60 | throw new Error('No customer data found.') 61 | } 62 | 63 | const customerInfo = { 64 | id: response.id, 65 | email: response.email, 66 | addresses: response.addresses, 67 | firstName: response.firstName, 68 | lastName: response.lastName, 69 | // ... 70 | } 71 | 72 | this.customer = customerInfo 73 | } catch (error) { 74 | console.error('Cannot get customer data:', error) 75 | throw error 76 | } 77 | }, 78 | /** 79 | * Creates a new customer. 80 | * @param input - The input data for creating the customer 81 | */ 82 | async createCustomer(input: CustomerCreateInput) { 83 | try { 84 | const response = await shopify.customer.create({ 85 | input: input, 86 | }) 87 | 88 | if (!response?.customer?.id) { 89 | throw new Error(response?.customerUserErrors[0]?.message) 90 | } 91 | 92 | await this.createToken({ 93 | email: input.email, 94 | password: input.password, 95 | }) 96 | } catch (error) { 97 | console.error('Cannot create new customer:', error) 98 | throw error 99 | } 100 | }, 101 | /** 102 | * Logs in the customer, creates a new customer access token. 103 | * @param email - The customer's email 104 | * @param password - The customer's password 105 | */ 106 | async login(email: string, password: string) { 107 | await this.createToken({ 108 | email: email, 109 | password: password, 110 | }) 111 | }, 112 | /** 113 | * Logs out the customer, deletes the customer access token. 114 | */ 115 | async logout() { 116 | try { 117 | const response = await shopify.customer.deleteAccessToken({ 118 | customerAccessToken: this.accessToken, 119 | }) 120 | 121 | if (response?.userErrors?.length) { 122 | throw new Error(response?.userErrors[0]?.message) 123 | } 124 | 125 | if (response?.deletedAccessToken) { 126 | this.accessToken = '' 127 | this.customer = null 128 | } 129 | } catch (error) { 130 | console.error('Cannot delete access token:', error) 131 | throw error 132 | } 133 | }, 134 | /** 135 | * Sends a reset password email to the customer. 136 | * @param email - The customer's email 137 | */ 138 | async recover(email: string) { 139 | try { 140 | const response = await shopify.customer.recover({ 141 | email: email, 142 | }) 143 | 144 | if (response?.customerUserErrors?.length) { 145 | throw new Error(response?.customerUserErrors[0]?.message) 146 | } 147 | 148 | return response 149 | } catch (error) { 150 | console.error('Cannot recover customer password:', error) 151 | throw error 152 | } 153 | }, 154 | /** 155 | * Resets a customer's password using their ID, new password, and reset token. 156 | * @param id - The customer's ID 157 | * @param password - The new password 158 | * @param resetToken - The reset token from the URL 159 | */ 160 | async reset(id: string, password: string, resetToken: string) { 161 | try { 162 | const response = await shopify.customer.reset({ 163 | id: `gid://shopify/Customer/${id}`, 164 | input: { 165 | password, 166 | resetToken, 167 | }, 168 | }) 169 | 170 | if (response?.customerUserErrors?.length) { 171 | throw new Error(response?.customerUserErrors[0]?.message) 172 | } 173 | 174 | if (response?.customerAccessToken) { 175 | this.accessToken = response.customerAccessToken.accessToken 176 | await this.getCustomer() 177 | } 178 | } catch (error) { 179 | console.error('Cannot reset customer password:', error) 180 | throw error 181 | } 182 | }, 183 | }, 184 | 185 | getters: { 186 | isAuthenticated: (state) => !!state.accessToken, 187 | currentToken: (state) => state.accessToken, 188 | }, 189 | 190 | persist: { 191 | pick: ['accessToken', 'customer'], 192 | }, 193 | }) 194 | --------------------------------------------------------------------------------