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
|
name
|
||||||
sku
|
sku
|
||||||
pricing {
|
pricing {
|
||||||
|
priceUndiscounted{
|
||||||
|
gross {
|
||||||
|
currency
|
||||||
|
amount
|
||||||
|
}
|
||||||
|
}
|
||||||
price {
|
price {
|
||||||
gross {
|
gross {
|
||||||
currency
|
currency
|
||||||
|
|
|
@ -29,6 +29,14 @@ const priceBase: GoogleFeedProductVariantFragment["pricing"] = {
|
||||||
currency: "USD",
|
currency: "USD",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
priceUndiscounted: {
|
||||||
|
__typename: "TaxedMoney",
|
||||||
|
gross: {
|
||||||
|
__typename: "Money",
|
||||||
|
amount: 2,
|
||||||
|
currency: "USD",
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("generateGoogleXmlFeed", () => {
|
describe("generateGoogleXmlFeed", () => {
|
||||||
|
@ -78,7 +86,8 @@ describe("generateGoogleXmlFeed", () => {
|
||||||
<g:product_type>Category Name</g:product_type>
|
<g:product_type>Category Name</g:product_type>
|
||||||
<g:google_product_category>1</g:google_product_category>
|
<g:google_product_category>1</g:google_product_category>
|
||||||
<link>https://example.com/p/product-slug</link>
|
<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>
|
||||||
<item>
|
<item>
|
||||||
<g:id>sku2</g:id>
|
<g:id>sku2</g:id>
|
||||||
|
@ -89,7 +98,8 @@ describe("generateGoogleXmlFeed", () => {
|
||||||
<g:product_type>Category Name</g:product_type>
|
<g:product_type>Category Name</g:product_type>
|
||||||
<g:google_product_category>1</g:google_product_category>
|
<g:google_product_category>1</g:google_product_category>
|
||||||
<link>https://example.com/p/product-slug</link>
|
<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>
|
||||||
</channel>
|
</channel>
|
||||||
</rss>"
|
</rss>"
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { shopDetailsToProxy } from "./shop-details-to-proxy";
|
||||||
import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
|
import { EditorJsPlaintextRenderer } from "../editor-js/editor-js-plaintext-renderer";
|
||||||
import { RootConfig } from "../app-configuration/app-config";
|
import { RootConfig } from "../app-configuration/app-config";
|
||||||
import { getMappedAttributes } from "./attribute-mapping";
|
import { getMappedAttributes } from "./attribute-mapping";
|
||||||
|
import { priceMapping } from "./price-mapping";
|
||||||
|
|
||||||
interface GenerateGoogleXmlFeedArgs {
|
interface GenerateGoogleXmlFeedArgs {
|
||||||
productVariants: GoogleFeedProductVariantFragment[];
|
productVariants: GoogleFeedProductVariantFragment[];
|
||||||
|
@ -15,22 +16,6 @@ interface GenerateGoogleXmlFeedArgs {
|
||||||
shopDescription?: string;
|
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 = ({
|
export const generateGoogleXmlFeed = ({
|
||||||
attributeMapping,
|
attributeMapping,
|
||||||
productVariants,
|
productVariants,
|
||||||
|
@ -45,10 +30,7 @@ export const generateGoogleXmlFeed = ({
|
||||||
variant,
|
variant,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currency = variant.pricing?.price?.gross.currency;
|
const pricing = priceMapping({ pricing: variant.pricing });
|
||||||
const amount = variant.pricing?.price?.gross.amount;
|
|
||||||
|
|
||||||
const price = currency ? formatCurrency(currency, amount!) : undefined;
|
|
||||||
|
|
||||||
return productToProxy({
|
return productToProxy({
|
||||||
storefrontUrlTemplate: productStorefrontUrl,
|
storefrontUrlTemplate: productStorefrontUrl,
|
||||||
|
@ -62,13 +44,13 @@ export const generateGoogleXmlFeed = ({
|
||||||
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
|
variant.quantityAvailable && variant.quantityAvailable > 0 ? "in_stock" : "out_of_stock",
|
||||||
category: variant.product.category?.name || "unknown",
|
category: variant.product.category?.name || "unknown",
|
||||||
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
googleProductCategory: variant.product.category?.googleCategoryId || "",
|
||||||
price: price,
|
|
||||||
imageUrl: variant.product.thumbnail?.url || "",
|
imageUrl: variant.product.thumbnail?.url || "",
|
||||||
material: attributes?.material,
|
material: attributes?.material,
|
||||||
color: attributes?.color,
|
color: attributes?.color,
|
||||||
brand: attributes?.brand,
|
brand: attributes?.brand,
|
||||||
pattern: attributes?.pattern,
|
pattern: attributes?.pattern,
|
||||||
size: attributes?.size,
|
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) {
|
if (p.material) {
|
||||||
item.push({
|
item.push({
|
||||||
"g:material": [
|
"g:material": [
|
||||||
|
|
|
@ -9,6 +9,7 @@ export type ProductEntry = {
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
condition?: "new" | "refurbished" | "used";
|
condition?: "new" | "refurbished" | "used";
|
||||||
price?: string;
|
price?: string;
|
||||||
|
salePrice?: string;
|
||||||
googleProductCategory?: string;
|
googleProductCategory?: string;
|
||||||
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
|
availability: "in_stock" | "out_of_stock" | "preorder" | "backorder";
|
||||||
category: string;
|
category: string;
|
||||||
|
|
Loading…
Reference in a new issue