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:
Unchained
2026-03-21 12:36:21 +02:00
parent db1914d69b
commit 7b94537670
27 changed files with 7879 additions and 3 deletions

49
src/lib/saleor/client.ts Normal file
View 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;

View 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}
`;

View 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
}
}
`;

View 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
View 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";

View 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
View 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,
};
}

View 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}
`;
`;

View 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}
`;

191
src/types/saleor.ts Normal file
View File

@@ -0,0 +1,191 @@
// Saleor GraphQL Types
export interface Money {
amount: number;
currency: string;
}
export interface Price {
gross: Money;
net?: Money;
}
export interface DiscountedPrice {
gross: Money;
}
export interface ProductMedia {
id: string;
url: string;
alt: string;
type: string;
}
export interface ProductAttributeValue {
name: string;
slug: string;
}
export interface ProductAttribute {
attribute: {
name: string;
slug: string;
};
values: ProductAttributeValue[];
}
export interface ProductVariantPricing {
price: Price;
onSale: boolean;
discount?: DiscountedPrice;
}
export interface ProductVariant {
id: string;
name: string;
sku: string;
quantityAvailable: number;
weight?: {
value: number;
unit: string;
};
media?: ProductMedia[];
pricing?: ProductVariantPricing;
attributes?: ProductAttribute[];
}
export interface ProductTranslation {
id: string;
name: string;
slug: string;
description: string;
seoTitle?: string;
seoDescription?: string;
}
export interface Product {
id: string;
name: string;
slug: string;
description: string;
seoTitle?: string;
seoDescription?: string;
translation?: ProductTranslation;
variants: ProductVariant[];
media: ProductMedia[];
category?: {
id: string;
name: string;
slug: string;
};
metadata?: {
key: string;
value: string;
}[];
}
export interface ProductEdge {
node: Product;
}
export interface ProductList {
edges: ProductEdge[];
pageInfo: {
hasNextPage: boolean;
endCursor?: string;
};
}
// Checkout Types
export interface Address {
id?: string;
firstName: string;
lastName: string;
companyName?: string;
streetAddress1: string;
streetAddress2?: string;
city: string;
postalCode: string;
country: {
code: string;
country: string;
};
countryArea?: string;
phone?: string;
}
export interface CheckoutLine {
id: string;
quantity: number;
totalPrice: Price;
variant: ProductVariant & {
product: {
id: string;
name: string;
slug: string;
media: ProductMedia[];
};
};
}
export interface ShippingMethod {
id: string;
name: string;
price: Money;
}
export interface PaymentGateway {
id: string;
name: string;
}
export interface Checkout {
id: string;
token: string;
email?: string;
isShippingRequired: boolean;
lines: CheckoutLine[];
shippingPrice: Price;
subtotalPrice: Price;
totalPrice: Price;
shippingAddress?: Address;
billingAddress?: Address;
shippingMethods: ShippingMethod[];
availablePaymentGateways: PaymentGateway[];
note?: string;
}
// Order Types
export interface Order {
id: string;
number: string;
status: string;
created: string;
total: Price;
}
// API Response Types
export interface CheckoutCreateResponse {
checkoutCreate: {
checkout?: Checkout;
errors: Array<{
field: string;
message: string;
code: string;
}>;
};
}
export interface CheckoutCompleteResponse {
checkoutComplete: {
order?: Order;
errors: Array<{
field: string;
message: string;
code: string;
}>;
};
}