feat: add bundle feature with 2x/3x set options
Some checks failed
Build and Deploy / build (push) Has been cancelled

- Created BundleSelector component for selecting bundle options
- Updated ProductDetail to show bundle options
- Added bundle translations for all 4 locales
- Added GraphQL query for bundle products
- Updated TypeScript types for attributes
- Saleor backend: created bundle products for all base products
This commit is contained in:
Unchained
2026-03-24 16:00:07 +02:00
parent 28a6e58dba
commit 9a61564e3c
12 changed files with 370 additions and 34 deletions

View File

@@ -35,6 +35,18 @@ export const PRODUCT_FRAGMENT = gql`
key
value
}
attributes {
attribute {
id
name
slug
}
values {
id
name
slug
}
}
}
${PRODUCT_VARIANT_FRAGMENT}
`;

View File

@@ -7,7 +7,7 @@ export { PRODUCT_VARIANT_FRAGMENT, CHECKOUT_LINE_FRAGMENT } from "./fragments/Va
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_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_PRODUCTS_BY_CATEGORY, GET_BUNDLE_PRODUCTS } from "./queries/Products";
export { GET_CHECKOUT, GET_CHECKOUT_BY_ID } from "./queries/Checkout";
// Mutations
@@ -34,4 +34,5 @@ export {
formatPrice,
getLocalizedProduct,
parseDescription,
getBundleProducts,
} from "./products";

View File

@@ -1,5 +1,5 @@
import { saleorClient } from "./client";
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG } from "./queries/Products";
import { GET_PRODUCTS, GET_PRODUCT_BY_SLUG, GET_BUNDLE_PRODUCTS } from "./queries/Products";
import type { Product } from "@/types/saleor";
const CHANNEL = process.env.NEXT_PUBLIC_SALEOR_CHANNEL || "default-channel";
@@ -155,3 +155,65 @@ export function getLocalizedProduct(
seoDescription: translation?.seoDescription || product.seoDescription,
};
}
interface ProductsResponse {
products?: {
edges: Array<{ node: Product }>;
};
}
export async function getBundleProducts(
locale: string = "SR",
first: number = 50
): Promise<Product[]> {
try {
const { data } = await saleorClient.query<ProductsResponse>({
query: GET_BUNDLE_PRODUCTS,
variables: {
channel: CHANNEL,
locale: locale.toUpperCase(),
first,
},
});
return data?.products?.edges.map((edge) => edge.node) || [];
} catch (error) {
console.error("Error fetching bundle products from Saleor:", error);
return [];
}
}
export function getBundleProductsForProduct(
allProducts: Product[],
baseProductId: string
): Product[] {
return allProducts.filter((product) => {
const bundleItemsAttr = product.attributes?.find(
(attr) => attr.attribute.slug === "bundle-items"
);
if (!bundleItemsAttr) return false;
return bundleItemsAttr.values.some((val) => {
const referencedId = Buffer.from(val.slug.split(":")[1] || val.id).toString("base64");
const expectedId = `UHJvZHVjdDo${baseProductId.split("UHJvZHVjdDo")[1]}`;
return referencedId.includes(baseProductId.split("UHJvZHVjdDo")[1] || "") ||
val.slug.includes(baseProductId.split("UHJvZHVjdDo")[1] || "");
});
});
}
export function getProductBundleComponents(product: Product): number | null {
const bundleAttr = product.attributes?.find(
(attr) => attr.attribute.slug === "bundle-items"
);
if (!bundleAttr) return null;
const bundleAttrMatch = product.name.match(/(\d+)x/i);
if (bundleAttrMatch) {
return parseInt(bundleAttrMatch[1], 10);
}
return null;
}
export function isBundleProduct(product: Product): boolean {
return getProductBundleComponents(product) !== null;
}

View File

@@ -49,3 +49,16 @@ export const GET_PRODUCTS_BY_CATEGORY = gql`
}
${PRODUCT_LIST_ITEM_FRAGMENT}
`;
export const GET_BUNDLE_PRODUCTS = gql`
query GetBundleProducts($channel: String!, $locale: LanguageCodeEnum!, $first: Int!) {
products(channel: $channel, first: $first) {
edges {
node {
...ProductFragment
}
}
}
}
${PRODUCT_FRAGMENT}
`;