Product feed: Product Pricing update (#840)
* Add attribute mapping * Improve release note * Log the error * Add pattern attribute * Add group ID * Update the item pricing
This commit is contained in:
parent
261957fda4
commit
0b0297eeb8
8 changed files with 161 additions and 23 deletions
13
.changeset/happy-poets-wait.md
Normal file
13
.changeset/happy-poets-wait.md
Normal file
|
@ -0,0 +1,13 @@
|
|||
---
|
||||
"saleor-app-products-feed": minor
|
||||
---
|
||||
|
||||
Updated pricing attributes according to the Google guidelines.
|
||||
|
||||
Was:
|
||||
- Price: base or discounted price
|
||||
|
||||
Now:
|
||||
|
||||
- Price: always the base price. Attribute skipped if amount is equal to 0.
|
||||
- Sale price: discounted price. Attribute skipped if value is the same as base price
|
|
@ -3,6 +3,12 @@ fragment GoogleFeedProductVariant on ProductVariant {
|
|||
name
|
||||
sku
|
||||
pricing {
|
||||
priceUndiscounted{
|
||||
gross {
|
||||
currency
|
||||
amount
|
||||
}
|
||||
}
|
||||
price {
|
||||
gross {
|
||||
currency
|
||||
|
|
|
@ -29,6 +29,14 @@ const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
|
|||
currency: "USD",
|
||||
},
|
||||
},
|
||||
priceUndiscounted: {
|
||||
__typename: "TaxedMoney",
|
||||
gross: {
|
||||
__typename: "Money",
|
||||
amount: 2,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("generateGoogleXmlFeed", () => {
|
||||
|
@ -78,7 +86,8 @@ describe("generateGoogleXmlFeed", () => {
|
|||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>1.00 USD</g:price>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
<item>
|
||||
<g:id>sku2</g:id>
|
||||
|
@ -89,7 +98,8 @@ describe("generateGoogleXmlFeed", () => {
|
|||
<g:product_type>Category Name</g:product_type>
|
||||
<g:google_product_category>1</g:google_product_category>
|
||||
<link>https://example.com/p/product-slug</link>
|
||||
<g:price>1.00 USD</g:price>
|
||||
<g:price>2.00 USD</g:price>
|
||||
<g:sale_price>1.00 USD</g:sale_price>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>"
|
||||
|
|
|
@ -5,6 +5,7 @@ import { shopDetailsToProxy } from "./shop-details-to-proxy";
|
|||
import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
|
||||
import { RootConfig } from "../app-configuration/app-config";
|
||||
import { getMappedAttributes } from "./attribute-mapping";
|
||||
import { priceMapping } from "./price-mapping";
|
||||
|
||||
interface GenerateGoogleXmlFeedArgs {
|
||||
productVariants: GoogleFeedProductVariantFragment[];
|
||||
|
@ -15,22 +16,6 @@ interface GenerateGoogleXmlFeedArgs {
|
|||
shopDescription?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Price format has to be altered from the en format to the one expected by Google
|
||||
* eg. 1.00 USD, 5.00 PLN
|
||||
*/
|
||||
const formatCurrency = (currency: string, amount: number) => {
|
||||
return (
|
||||
new Intl.NumberFormat("en-EN", {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 2,
|
||||
style: "decimal",
|
||||
currencyDisplay: "code",
|
||||
currency: currency,
|
||||
}).format(amount) + ` ${currency}`
|
||||
);
|
||||
};
|
||||
|
||||
export const generateGoogleXmlFeed = ({
|
||||
attributeMapping,
|
||||
productVariants,
|
||||
|
@ -45,10 +30,7 @@ export const generateGoogleXmlFeed = ({
|
|||
variant,
|
||||
});
|
||||
|
||||
const currency = variant.pricing?.price?.gross.currency;
|
||||
const amount = variant.pricing?.price?.gross.amount;
|
||||
|
||||
const price = currency ? formatCurrency(currency, amount!) : undefined;
|
||||
const pricing = priceMapping({ pricing: variant.pricing });
|
||||
|
||||
return productToProxy({
|
||||
storefrontUrlTemplate: productStorefrontUrl,
|
||||
|
@ -62,13 +44,13 @@ export const generateGoogleXmlFeed = ({
|
|||
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
|
||||
category: variant.product.category?.name || "unknown",
|
||||
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
||||
price: price,
|
||||
imageUrl: variant.product.thumbnail?.url || "",
|
||||
material: attributes?.material,
|
||||
color: attributes?.color,
|
||||
brand: attributes?.brand,
|
||||
pattern: attributes?.pattern,
|
||||
size: attributes?.size,
|
||||
...pricing,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { priceMapping } from "./price-mapping";
|
||||
|
||||
describe("priceMapping", () => {
|
||||
it("Return undefined, when no pricing available", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: undefined,
|
||||
})
|
||||
).toStrictEqual(undefined);
|
||||
});
|
||||
it("Return undefined, when amount is equal to 0", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: {
|
||||
priceUndiscounted: {
|
||||
gross: {
|
||||
amount: 0,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toStrictEqual(undefined);
|
||||
});
|
||||
it("Return formatted base price, when there is no sale", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: {
|
||||
priceUndiscounted: {
|
||||
gross: {
|
||||
amount: 10.5,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toStrictEqual({ price: "10.50 USD" });
|
||||
});
|
||||
it("Return formatted base and sale prices, when there is a sale", () => {
|
||||
expect(
|
||||
priceMapping({
|
||||
pricing: {
|
||||
priceUndiscounted: {
|
||||
gross: {
|
||||
amount: 10.5,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
price: {
|
||||
gross: {
|
||||
amount: 5.25,
|
||||
currency: "USD",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
).toStrictEqual({ price: "10.50 USD", salePrice: "5.25 USD" });
|
||||
});
|
||||
});
|
56
apps/products-feed/src/modules/google-feed/price-mapping.ts
Normal file
56
apps/products-feed/src/modules/google-feed/price-mapping.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { GoogleFeedProductVariantFragment } from "../../../generated/graphql";
|
||||
|
||||
/**
|
||||
* Price format has to be altered from the en format to the one expected by Google
|
||||
* eg. 1.00 USD, 5.00 PLN
|
||||
*/
|
||||
const formatCurrency = (currency: string, amount: number) => {
|
||||
return (
|
||||
new Intl.NumberFormat("en-EN", {
|
||||
useGrouping: false,
|
||||
minimumFractionDigits: 2,
|
||||
style: "decimal",
|
||||
currencyDisplay: "code",
|
||||
currency: currency,
|
||||
}).format(amount) + ` ${currency}`
|
||||
);
|
||||
};
|
||||
|
||||
interface priceMappingArgs {
|
||||
pricing: GoogleFeedProductVariantFragment["pricing"];
|
||||
}
|
||||
|
||||
/*
|
||||
* Maps variant pricing to Google Feed format.
|
||||
* https://support.google.com/merchants/answer/6324371
|
||||
*/
|
||||
export const priceMapping = ({ pricing }: priceMappingArgs) => {
|
||||
const priceUndiscounted = pricing?.priceUndiscounted?.gross;
|
||||
|
||||
// Pricing should not be submitted empty or with 0 value
|
||||
if (!priceUndiscounted?.amount) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Price attribute is expected to be a base price
|
||||
const formattedUndiscountedPrice = formatCurrency(
|
||||
priceUndiscounted.currency,
|
||||
priceUndiscounted.amount
|
||||
);
|
||||
|
||||
const discountedPrice = pricing?.price?.gross;
|
||||
|
||||
// Return early if there is no sale
|
||||
if (!discountedPrice || discountedPrice?.amount === priceUndiscounted.amount) {
|
||||
return {
|
||||
price: formattedUndiscountedPrice,
|
||||
};
|
||||
}
|
||||
|
||||
const formattedDiscountedPrice = formatCurrency(discountedPrice.currency, discountedPrice.amount);
|
||||
|
||||
return {
|
||||
price: formattedUndiscountedPrice,
|
||||
salePrice: formattedDiscountedPrice,
|
||||
};
|
||||
};
|
|
@ -109,6 +109,16 @@ export const productToProxy = (p: ProductEntry) => {
|
|||
});
|
||||
}
|
||||
|
||||
if (p.salePrice?.length) {
|
||||
item.push({
|
||||
"g:sale_price": [
|
||||
{
|
||||
"#text": p.salePrice,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (p.material) {
|
||||
item.push({
|
||||
"g:material": [
|
||||
|
|
|
@ -9,6 +9,7 @@ export type ProductEntry = {
|
|||
imageUrl?: string;
|
||||
condition?: "new" | "refurbished" | "used";
|
||||
price?: string;
|
||||
salePrice?: string;
|
||||
googleProductCategory?: string;
|
||||
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
|
||||
category: string;
|
||||
|
|
Loading…
Reference in a new issue