feat(saleor): Phase 1 - GraphQL Client Setup
- Add Apollo Client for Saleor GraphQL API - Create GraphQL fragments (Product, Variant, Checkout) - Create GraphQL queries (Products, Checkout) - Create GraphQL mutations (Checkout operations) - Add TypeScript types for Saleor entities - Add product helper functions - Install @apollo/client and graphql dependencies Part of WordPress/WooCommerce → Saleor migration
This commit is contained in:
49
src/lib/saleor/client.ts
Normal file
49
src/lib/saleor/client.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client";
|
||||
import { setContext } from "@apollo/client/link/context";
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: process.env.NEXT_PUBLIC_SALEOR_API_URL || "http://localhost:8000/graphql/",
|
||||
});
|
||||
|
||||
const authLink = setContext((_, { headers }) => {
|
||||
// Saleor doesn't require auth for public queries
|
||||
// Add auth token here if needed for admin operations
|
||||
return {
|
||||
headers: {
|
||||
...headers,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const saleorClient = new ApolloClient({
|
||||
link: authLink.concat(httpLink),
|
||||
cache: new InMemoryCache({
|
||||
typePolicies: {
|
||||
Query: {
|
||||
fields: {
|
||||
products: {
|
||||
keyArgs: ["channel", "filter"],
|
||||
merge(existing, incoming) {
|
||||
if (!existing) return incoming;
|
||||
return {
|
||||
...incoming,
|
||||
edges: [...existing.edges, ...incoming.edges],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
watchQuery: {
|
||||
fetchPolicy: "cache-first",
|
||||
},
|
||||
query: {
|
||||
fetchPolicy: "cache-first",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export default saleorClient;
|
||||
74
src/lib/saleor/fragments/Checkout.ts
Normal file
74
src/lib/saleor/fragments/Checkout.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { gql } from "@apollo/client";
|
||||
import { CHECKOUT_LINE_FRAGMENT } from "./Variant";
|
||||
|
||||
export const ADDRESS_FRAGMENT = gql`
|
||||
fragment AddressFragment on Address {
|
||||
id
|
||||
firstName
|
||||
lastName
|
||||
companyName
|
||||
streetAddress1
|
||||
streetAddress2
|
||||
city
|
||||
postalCode
|
||||
country {
|
||||
code
|
||||
country
|
||||
}
|
||||
countryArea
|
||||
phone
|
||||
isDefaultBillingAddress
|
||||
isDefaultShippingAddress
|
||||
}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_FRAGMENT = gql`
|
||||
fragment CheckoutFragment on Checkout {
|
||||
id
|
||||
token
|
||||
email
|
||||
isShippingRequired
|
||||
lines {
|
||||
...CheckoutLineFragment
|
||||
}
|
||||
shippingPrice {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
subtotalPrice {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
totalPrice {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
shippingAddress {
|
||||
...AddressFragment
|
||||
}
|
||||
billingAddress {
|
||||
...AddressFragment
|
||||
}
|
||||
shippingMethods {
|
||||
id
|
||||
name
|
||||
price {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
availablePaymentGateways {
|
||||
id
|
||||
name
|
||||
}
|
||||
note
|
||||
}
|
||||
${CHECKOUT_LINE_FRAGMENT}
|
||||
${ADDRESS_FRAGMENT}
|
||||
`;
|
||||
81
src/lib/saleor/fragments/Product.ts
Normal file
81
src/lib/saleor/fragments/Product.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { gql } from "@apollo/client";
|
||||
import { PRODUCT_VARIANT_FRAGMENT } from "./Variant";
|
||||
|
||||
export const PRODUCT_FRAGMENT = gql`
|
||||
fragment ProductFragment on Product {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
seoTitle
|
||||
seoDescription
|
||||
translation(languageCode: $locale) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
seoTitle
|
||||
seoDescription
|
||||
}
|
||||
variants {
|
||||
...ProductVariantFragment
|
||||
}
|
||||
media {
|
||||
id
|
||||
url
|
||||
alt
|
||||
type
|
||||
}
|
||||
category {
|
||||
id
|
||||
name
|
||||
slug
|
||||
}
|
||||
metadata {
|
||||
key
|
||||
value
|
||||
}
|
||||
}
|
||||
${PRODUCT_VARIANT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const PRODUCT_LIST_ITEM_FRAGMENT = gql`
|
||||
fragment ProductListItemFragment on Product {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
translation(languageCode: $locale) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
description
|
||||
}
|
||||
variants {
|
||||
id
|
||||
name
|
||||
sku
|
||||
quantityAvailable
|
||||
pricing {
|
||||
price {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
onSale
|
||||
discount {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
media {
|
||||
id
|
||||
url
|
||||
alt
|
||||
}
|
||||
}
|
||||
`;
|
||||
84
src/lib/saleor/fragments/Variant.ts
Normal file
84
src/lib/saleor/fragments/Variant.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { gql } from "@apollo/client";
|
||||
|
||||
export const PRODUCT_VARIANT_FRAGMENT = gql`
|
||||
fragment ProductVariantFragment on ProductVariant {
|
||||
id
|
||||
name
|
||||
sku
|
||||
quantityAvailable
|
||||
weight {
|
||||
value
|
||||
unit
|
||||
}
|
||||
media {
|
||||
id
|
||||
url
|
||||
alt
|
||||
}
|
||||
pricing {
|
||||
price {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
net {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
onSale
|
||||
discount {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
attributes {
|
||||
attribute {
|
||||
name
|
||||
slug
|
||||
}
|
||||
values {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_LINE_FRAGMENT = gql`
|
||||
fragment CheckoutLineFragment on CheckoutLine {
|
||||
id
|
||||
quantity
|
||||
totalPrice {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
variant {
|
||||
id
|
||||
name
|
||||
sku
|
||||
product {
|
||||
id
|
||||
name
|
||||
slug
|
||||
media {
|
||||
id
|
||||
url
|
||||
alt
|
||||
}
|
||||
}
|
||||
pricing {
|
||||
price {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
35
src/lib/saleor/index.ts
Normal file
35
src/lib/saleor/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Saleor GraphQL Client and Utilities
|
||||
export { saleorClient } from "./client";
|
||||
|
||||
// Fragments
|
||||
export { PRODUCT_FRAGMENT, PRODUCT_LIST_ITEM_FRAGMENT } from "./fragments/Product";
|
||||
export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Variant";
|
||||
export { CHECKOUT_FRAGMENT, ADDRESS_FRAGMENT } from "./fragments/Checkout";
|
||||
|
||||
// Queries
|
||||
export { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY } from "./queries/Products";
|
||||
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
|
||||
|
||||
// Mutations
|
||||
export {
|
||||
CHECKOUT_CREATE,
|
||||
CHECKOUT_LINES_ADD,
|
||||
CHECKOUT_LINES_UPDATE,
|
||||
CHECKOUT_LINES_DELETE,
|
||||
CHECKOUT_SHIPPING_ADDRESS_UPDATE,
|
||||
CHECKOUT_BILLING_ADDRESS_UPDATE,
|
||||
CHECKOUT_SHIPPING_METHOD_UPDATE,
|
||||
CHECKOUT_COMPLETE,
|
||||
CHECKOUT_EMAIL_UPDATE,
|
||||
} from "./mutations/Checkout";
|
||||
|
||||
// Helper functions
|
||||
export {
|
||||
getProducts,
|
||||
getProductBySlug,
|
||||
getProductPrice,
|
||||
getProductImage,
|
||||
isProductAvailable,
|
||||
formatPrice,
|
||||
getLocalizedProduct,
|
||||
} from "./products";
|
||||
154
src/lib/saleor/mutations/Checkout.ts
Normal file
154
src/lib/saleor/mutations/Checkout.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import { gql } from "@apollo/client";
|
||||
import { CHECKOUT_FRAGMENT } from "../fragments/Checkout";
|
||||
|
||||
export const CHECKOUT_CREATE = gql`
|
||||
mutation CheckoutCreate($input: CheckoutCreateInput!) {
|
||||
checkoutCreate(input: $input) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_LINES_ADD = gql`
|
||||
mutation CheckoutLinesAdd($checkoutId: ID!, $lines: [CheckoutLineInput!]!) {
|
||||
checkoutLinesAdd(checkoutId: $checkoutId, lines: $lines) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_LINES_UPDATE = gql`
|
||||
mutation CheckoutLinesUpdate($checkoutId: ID!, $lines: [CheckoutLineUpdateInput!]!) {
|
||||
checkoutLinesUpdate(checkoutId: $checkoutId, lines: $lines) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_LINES_DELETE = gql`
|
||||
mutation CheckoutLinesDelete($checkoutId: ID!, $lineIds: [ID!]!) {
|
||||
checkoutLinesDelete(checkoutId: $checkoutId, lines: $lineIds) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_SHIPPING_ADDRESS_UPDATE = gql`
|
||||
mutation CheckoutShippingAddressUpdate($checkoutId: ID!, $shippingAddress: AddressInput!) {
|
||||
checkoutShippingAddressUpdate(checkoutId: $checkoutId, shippingAddress: $shippingAddress) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_BILLING_ADDRESS_UPDATE = gql`
|
||||
mutation CheckoutBillingAddressUpdate($checkoutId: ID!, $billingAddress: AddressInput!) {
|
||||
checkoutBillingAddressUpdate(checkoutId: $checkoutId, billingAddress: $billingAddress) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_SHIPPING_METHOD_UPDATE = gql`
|
||||
mutation CheckoutShippingMethodUpdate($checkoutId: ID!, $shippingMethodId: ID!) {
|
||||
checkoutShippingMethodUpdate(checkoutId: $checkoutId, shippingMethodId: $shippingMethodId) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_COMPLETE = gql`
|
||||
mutation CheckoutComplete($checkoutId: ID!) {
|
||||
checkoutComplete(checkoutId: $checkoutId) {
|
||||
order {
|
||||
id
|
||||
number
|
||||
status
|
||||
created
|
||||
total {
|
||||
gross {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const CHECKOUT_EMAIL_UPDATE = gql`
|
||||
mutation CheckoutEmailUpdate($checkoutId: ID!, $email: String!) {
|
||||
checkoutEmailUpdate(checkoutId: $checkoutId, email: $email) {
|
||||
checkout {
|
||||
...CheckoutFragment
|
||||
}
|
||||
errors {
|
||||
field
|
||||
message
|
||||
code
|
||||
}
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
105
src/lib/saleor/products.ts
Normal file
105
src/lib/saleor/products.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { saleorClient } from "./client";
|
||||
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products";
|
||||
import type { Product, ProductList } from "@/types/saleor";
|
||||
|
||||
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
|
||||
|
||||
export async function getProducts(
|
||||
locale: string = "SR",
|
||||
first: number = 100
|
||||
): Promise<Product[]> {
|
||||
try {
|
||||
const { data } = await saleorClient.query({
|
||||
query: GET_PRODUCTS,
|
||||
variables: {
|
||||
channel: CHANNEL,
|
||||
locale: locale.toUpperCase(),
|
||||
first,
|
||||
},
|
||||
});
|
||||
|
||||
return data?.products?.edges.map((edge: { node: Product }) => edge.node) || [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching products from Saleor:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getProductBySlug(
|
||||
slug: string,
|
||||
locale: string = "SR"
|
||||
): Promise<Product | null> {
|
||||
try {
|
||||
const { data } = await saleorClient.query({
|
||||
query: GET_PRODUCT_BY_SLUG,
|
||||
variables: {
|
||||
slug,
|
||||
channel: CHANNEL,
|
||||
locale: locale.toUpperCase(),
|
||||
},
|
||||
});
|
||||
|
||||
return data?.product || null;
|
||||
} catch (error) {
|
||||
console.error(`Error fetching product ${slug} from Saleor:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getProductPrice(product: Product): string {
|
||||
const variant = product.variants?.[0];
|
||||
if (!variant?.pricing?.price?.gross?.amount) {
|
||||
return "";
|
||||
}
|
||||
return formatPrice(
|
||||
variant.pricing.price.gross.amount,
|
||||
variant.pricing.price.gross.currency
|
||||
);
|
||||
}
|
||||
|
||||
export function getProductImage(product: Product): string {
|
||||
if (product.media && product.media.length > 0) {
|
||||
return product.media[0].url;
|
||||
}
|
||||
if (product.variants?.[0]?.media && product.variants[0].media.length > 0) {
|
||||
return product.variants[0].media[0].url;
|
||||
}
|
||||
return "/placeholder-product.jpg";
|
||||
}
|
||||
|
||||
export function isProductAvailable(product: Product): boolean {
|
||||
const variant = product.variants?.[0];
|
||||
if (!variant) return false;
|
||||
return (variant.quantityAvailable || 0) > 0;
|
||||
}
|
||||
|
||||
export function formatPrice(amount: number, currency: string = "RSD"): string {
|
||||
return new Intl.NumberFormat("sr-RS", {
|
||||
style: "currency",
|
||||
currency: currency,
|
||||
minimumFractionDigits: 0,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
// Get localized product data
|
||||
export function getLocalizedProduct(
|
||||
product: Product,
|
||||
locale: string = "SR"
|
||||
): {
|
||||
name: string;
|
||||
slug: string;
|
||||
description: string;
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
} {
|
||||
const isEnglish = locale.toLowerCase() === "en";
|
||||
const translation = isEnglish ? product.translation : null;
|
||||
|
||||
return {
|
||||
name: translation?.name || product.name,
|
||||
slug: translation?.slug || product.slug,
|
||||
description: translation?.description || product.description,
|
||||
seoTitle: translation?.seoTitle || product.seoTitle,
|
||||
seoDescription: translation?.seoDescription || product.seoDescription,
|
||||
};
|
||||
}
|
||||
21
src/lib/saleor/queries/Checkout.ts
Normal file
21
src/lib/saleor/queries/Checkout.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { gql } from "@apollo/client";
|
||||
import { CHECKOUT_FRAGMENT } from "../fragments/Checkout";
|
||||
|
||||
export const GET_CHECKOUT = gql`
|
||||
query GetCheckout($token: UUID!) {
|
||||
checkout(token: $token) {
|
||||
...CheckoutFragment
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const GET_CHECKOUT_BY_ID = gql`
|
||||
query GetCheckoutById($id: ID!) {
|
||||
checkout(id: $id) {
|
||||
...CheckoutFragment
|
||||
}
|
||||
}
|
||||
${CHECKOUT_FRAGMENT}
|
||||
`;
|
||||
`;
|
||||
51
src/lib/saleor/queries/Products.ts
Normal file
51
src/lib/saleor/queries/Products.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { gql } from "@apollo/client";
|
||||
import { PRODUCT_FRAGMENT, PRODUCT_LIST_ITEM_FRAGMENT } from "../fragments/Product";
|
||||
|
||||
export const GET_PRODUCTS = gql`
|
||||
query GetProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
|
||||
products(channel: $channel, first: $first) {
|
||||
edges {
|
||||
node {
|
||||
...ProductListItemFragment
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const GET_PRODUCT_BY_SLUG = gql`
|
||||
query GetProduct($slug: String!, $channel: String!, $locale: LanguageCodeEnum!) {
|
||||
product(slug: $slug, channel: $channel) {
|
||||
...ProductFragment
|
||||
}
|
||||
}
|
||||
${PRODUCT_FRAGMENT}
|
||||
`;
|
||||
|
||||
export const GET_PRODUCTS_BY_CATEGORY = gql`
|
||||
query GetProductsByCategory(
|
||||
$categorySlug: String!
|
||||
$channel: String!
|
||||
$locale: LanguageCodeEnum!
|
||||
$first: Int!
|
||||
) {
|
||||
category(slug: $categorySlug) {
|
||||
id
|
||||
name
|
||||
slug
|
||||
products(channel: $channel, first: $first) {
|
||||
edges {
|
||||
node {
|
||||
...ProductListItemFragment
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
${PRODUCT_LIST_ITEM_FRAGMENT}
|
||||
`;
|
||||
Reference in New Issue
Block a user