@open-mercato/core 0.6.6-develop.5716.1.b108502d0d → 0.6.6-develop.5738.1.4182c2b2c7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js +13 -7
- package/dist/modules/catalog/backend/catalog/products/[id]/page.js.map +2 -2
- package/dist/modules/catalog/backend/catalog/products/create/page.js +16 -9
- package/dist/modules/catalog/backend/catalog/products/create/page.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/validation.js +39 -0
- package/dist/modules/catalog/widgets/injection/product-seo/validation.js.map +7 -0
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js +8 -28
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/backend/catalog/products/[id]/page.tsx +16 -6
- package/src/modules/catalog/backend/catalog/products/create/page.tsx +15 -5
- package/src/modules/catalog/widgets/injection/product-seo/validation.ts +47 -0
- package/src/modules/catalog/widgets/injection/product-seo/widget.ts +7 -29
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const MESSAGE_PREFIX = "SEO helper: ";
|
|
2
|
+
function buildSeoBlockMessage(issues) {
|
|
3
|
+
if (issues.length === 1) return `${MESSAGE_PREFIX}${issues[0]}`;
|
|
4
|
+
if (issues.length <= 3) return `${MESSAGE_PREFIX}${issues.join(" ")}`;
|
|
5
|
+
return `${MESSAGE_PREFIX}${issues.length} issues found. ${issues.slice(0, 2).join(" ")}`;
|
|
6
|
+
}
|
|
7
|
+
function evaluateProductSeo(data) {
|
|
8
|
+
const issues = [];
|
|
9
|
+
const fieldErrors = {};
|
|
10
|
+
const title = data?.title || data?.name;
|
|
11
|
+
if (typeof title === "string" && title.length > 0) {
|
|
12
|
+
if (title.length < 10) {
|
|
13
|
+
issues.push("Title is too short (min 10 characters).");
|
|
14
|
+
fieldErrors.title = "Title is too short for good SEO (min 10 characters).";
|
|
15
|
+
} else if (title.length > 60) {
|
|
16
|
+
issues.push("Title is too long (max 60 characters recommended).");
|
|
17
|
+
fieldErrors.title = "Title is too long for optimal SEO (max 60 characters).";
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const description = data?.description;
|
|
21
|
+
if (typeof description === "string") {
|
|
22
|
+
if (description.trim().length === 0) {
|
|
23
|
+
issues.push("Add a product description for better SEO.");
|
|
24
|
+
fieldErrors.description = "Provide a description to help search engines understand this product.";
|
|
25
|
+
} else if (description.length < 50) {
|
|
26
|
+
issues.push("Description is too short (min 50 characters).");
|
|
27
|
+
fieldErrors.description = "Description is too short for good SEO (min 50 characters).";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (issues.length) {
|
|
31
|
+
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues) };
|
|
32
|
+
}
|
|
33
|
+
return { ok: true, issues: [], fieldErrors: {} };
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
buildSeoBlockMessage,
|
|
37
|
+
evaluateProductSeo
|
|
38
|
+
};
|
|
39
|
+
//# sourceMappingURL=validation.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../../../../src/modules/catalog/widgets/injection/product-seo/validation.ts"],
|
|
4
|
+
"sourcesContent": ["export type ProductSeoEvaluation = {\n ok: boolean\n issues: string[]\n fieldErrors: Record<string, string>\n message?: string\n}\n\nconst MESSAGE_PREFIX = 'SEO helper: '\n\nexport function buildSeoBlockMessage(issues: string[]): string {\n if (issues.length === 1) return `${MESSAGE_PREFIX}${issues[0]}`\n if (issues.length <= 3) return `${MESSAGE_PREFIX}${issues.join(' ')}`\n return `${MESSAGE_PREFIX}${issues.length} issues found. ${issues.slice(0, 2).join(' ')}`\n}\n\nexport function evaluateProductSeo(data: Record<string, unknown> | null | undefined): ProductSeoEvaluation {\n const issues: string[] = []\n const fieldErrors: Record<string, string> = {}\n\n const title = (data?.title as unknown) || (data?.name as unknown)\n if (typeof title === 'string' && title.length > 0) {\n if (title.length < 10) {\n issues.push('Title is too short (min 10 characters).')\n fieldErrors.title = 'Title is too short for good SEO (min 10 characters).'\n } else if (title.length > 60) {\n issues.push('Title is too long (max 60 characters recommended).')\n fieldErrors.title = 'Title is too long for optimal SEO (max 60 characters).'\n }\n }\n\n const description = data?.description\n if (typeof description === 'string') {\n if (description.trim().length === 0) {\n issues.push('Add a product description for better SEO.')\n fieldErrors.description = 'Provide a description to help search engines understand this product.'\n } else if (description.length < 50) {\n issues.push('Description is too short (min 50 characters).')\n fieldErrors.description = 'Description is too short for good SEO (min 50 characters).'\n }\n }\n\n if (issues.length) {\n return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues) }\n }\n\n return { ok: true, issues: [], fieldErrors: {} }\n}\n"],
|
|
5
|
+
"mappings": "AAOA,MAAM,iBAAiB;AAEhB,SAAS,qBAAqB,QAA0B;AAC7D,MAAI,OAAO,WAAW,EAAG,QAAO,GAAG,cAAc,GAAG,OAAO,CAAC,CAAC;AAC7D,MAAI,OAAO,UAAU,EAAG,QAAO,GAAG,cAAc,GAAG,OAAO,KAAK,GAAG,CAAC;AACnE,SAAO,GAAG,cAAc,GAAG,OAAO,MAAM,kBAAkB,OAAO,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG,CAAC;AACxF;AAEO,SAAS,mBAAmB,MAAwE;AACzG,QAAM,SAAmB,CAAC;AAC1B,QAAM,cAAsC,CAAC;AAE7C,QAAM,QAAS,MAAM,SAAsB,MAAM;AACjD,MAAI,OAAO,UAAU,YAAY,MAAM,SAAS,GAAG;AACjD,QAAI,MAAM,SAAS,IAAI;AACrB,aAAO,KAAK,yCAAyC;AACrD,kBAAY,QAAQ;AAAA,IACtB,WAAW,MAAM,SAAS,IAAI;AAC5B,aAAO,KAAK,oDAAoD;AAChE,kBAAY,QAAQ;AAAA,IACtB;AAAA,EACF;AAEA,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,UAAU;AACnC,QAAI,YAAY,KAAK,EAAE,WAAW,GAAG;AACnC,aAAO,KAAK,2CAA2C;AACvD,kBAAY,cAAc;AAAA,IAC5B,WAAW,YAAY,SAAS,IAAI;AAClC,aAAO,KAAK,+CAA+C;AAC3D,kBAAY,cAAc;AAAA,IAC5B;AAAA,EACF;AAEA,MAAI,OAAO,QAAQ;AACjB,WAAO,EAAE,IAAI,OAAO,QAAQ,aAAa,SAAS,qBAAqB,MAAM,EAAE;AAAA,EACjF;AAEA,SAAO,EAAE,IAAI,MAAM,QAAQ,CAAC,GAAG,aAAa,CAAC,EAAE;AACjD;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import ProductSeoWidget from "./widget.client.js";
|
|
2
2
|
import { publishProductSeoValidation } from "./state.js";
|
|
3
|
+
import { evaluateProductSeo } from "./validation.js";
|
|
3
4
|
const widget = {
|
|
4
5
|
metadata: {
|
|
5
6
|
id: "catalog.injection.product-seo",
|
|
@@ -7,37 +8,16 @@ const widget = {
|
|
|
7
8
|
description: "Helps optimize product metadata for search engines",
|
|
8
9
|
features: ["catalog.products.edit"],
|
|
9
10
|
priority: 90,
|
|
10
|
-
enabled: true
|
|
11
|
+
enabled: true,
|
|
12
|
+
requiredFields: ["description"]
|
|
11
13
|
},
|
|
12
14
|
Widget: ProductSeoWidget,
|
|
13
15
|
eventHandlers: {
|
|
14
|
-
onBeforeSave: async (data
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (title.length < 10) {
|
|
20
|
-
issues.push("Title is too short (min 10 characters).");
|
|
21
|
-
fieldErrors.title = "Title is too short for good SEO (min 10 characters).";
|
|
22
|
-
} else if (title.length > 60) {
|
|
23
|
-
issues.push("Title is too long (max 60 characters recommended).");
|
|
24
|
-
fieldErrors.title = "Title is too long for optimal SEO (max 60 characters).";
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
const description = data?.description;
|
|
28
|
-
if (typeof description === "string") {
|
|
29
|
-
if (description.trim().length === 0) {
|
|
30
|
-
issues.push("Add a product description for better SEO.");
|
|
31
|
-
fieldErrors.description = "Provide a description to help search engines understand this product.";
|
|
32
|
-
} else if (description.length < 50) {
|
|
33
|
-
issues.push("Description is too short (min 50 characters).");
|
|
34
|
-
fieldErrors.description = "Description is too short for good SEO (min 50 characters).";
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
if (issues.length) {
|
|
38
|
-
const message = "SEO helper blocked save. Improve the highlighted fields.";
|
|
39
|
-
publishProductSeoValidation({ ok: false, issues, message });
|
|
40
|
-
return { ok: false, message, fieldErrors };
|
|
16
|
+
onBeforeSave: async (data) => {
|
|
17
|
+
const evaluation = evaluateProductSeo(data);
|
|
18
|
+
if (!evaluation.ok) {
|
|
19
|
+
publishProductSeoValidation({ ok: false, issues: evaluation.issues, message: evaluation.message });
|
|
20
|
+
return { ok: false, message: evaluation.message, fieldErrors: evaluation.fieldErrors };
|
|
41
21
|
}
|
|
42
22
|
publishProductSeoValidation({ ok: true, issues: [] });
|
|
43
23
|
return { ok: true };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/catalog/widgets/injection/product-seo/widget.ts"],
|
|
4
|
-
"sourcesContent": ["import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport ProductSeoWidget from './widget.client'\nimport { publishProductSeoValidation } from './state'\n\nconst widget: InjectionWidgetModule<any, any> = {\n metadata: {\n id: 'catalog.injection.product-seo',\n title: 'Product SEO Helper',\n description: 'Helps optimize product metadata for search engines',\n features: ['catalog.products.edit'],\n priority: 90,\n enabled: true,\n },\n Widget: ProductSeoWidget,\n eventHandlers: {\n onBeforeSave: async (data
|
|
5
|
-
"mappings": "AACA,OAAO,sBAAsB;AAC7B,SAAS,mCAAmC;
|
|
4
|
+
"sourcesContent": ["import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport ProductSeoWidget from './widget.client'\nimport { publishProductSeoValidation } from './state'\nimport { evaluateProductSeo } from './validation'\n\nconst widget: InjectionWidgetModule<any, any> = {\n metadata: {\n id: 'catalog.injection.product-seo',\n title: 'Product SEO Helper',\n description: 'Helps optimize product metadata for search engines',\n features: ['catalog.products.edit'],\n priority: 90,\n enabled: true,\n requiredFields: ['description'],\n },\n Widget: ProductSeoWidget,\n eventHandlers: {\n onBeforeSave: async (data) => {\n const evaluation = evaluateProductSeo(data as Record<string, unknown>)\n\n if (!evaluation.ok) {\n publishProductSeoValidation({ ok: false, issues: evaluation.issues, message: evaluation.message })\n return { ok: false, message: evaluation.message, fieldErrors: evaluation.fieldErrors }\n }\n\n publishProductSeoValidation({ ok: true, issues: [] })\n return { ok: true }\n },\n },\n}\n\nexport default widget\n"],
|
|
5
|
+
"mappings": "AACA,OAAO,sBAAsB;AAC7B,SAAS,mCAAmC;AAC5C,SAAS,0BAA0B;AAEnC,MAAM,SAA0C;AAAA,EAC9C,UAAU;AAAA,IACR,IAAI;AAAA,IACJ,OAAO;AAAA,IACP,aAAa;AAAA,IACb,UAAU,CAAC,uBAAuB;AAAA,IAClC,UAAU;AAAA,IACV,SAAS;AAAA,IACT,gBAAgB,CAAC,aAAa;AAAA,EAChC;AAAA,EACA,QAAQ;AAAA,EACR,eAAe;AAAA,IACb,cAAc,OAAO,SAAS;AAC5B,YAAM,aAAa,mBAAmB,IAA+B;AAErE,UAAI,CAAC,WAAW,IAAI;AAClB,oCAA4B,EAAE,IAAI,OAAO,QAAQ,WAAW,QAAQ,SAAS,WAAW,QAAQ,CAAC;AACjG,eAAO,EAAE,IAAI,OAAO,SAAS,WAAW,SAAS,aAAa,WAAW,YAAY;AAAA,MACvF;AAEA,kCAA4B,EAAE,IAAI,MAAM,QAAQ,CAAC,EAAE,CAAC;AACpD,aAAO,EAAE,IAAI,KAAK;AAAA,IACpB;AAAA,EACF;AACF;AAEA,IAAO,iBAAQ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/core",
|
|
3
|
-
"version": "0.6.6-develop.
|
|
3
|
+
"version": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -245,16 +245,16 @@
|
|
|
245
245
|
"zod": "^4.4.3"
|
|
246
246
|
},
|
|
247
247
|
"peerDependencies": {
|
|
248
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
249
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
250
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
248
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
249
|
+
"@open-mercato/shared": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
250
|
+
"@open-mercato/ui": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
251
251
|
"react": "^19.0.0",
|
|
252
252
|
"react-dom": "^19.0.0"
|
|
253
253
|
},
|
|
254
254
|
"devDependencies": {
|
|
255
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
256
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
257
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
255
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
256
|
+
"@open-mercato/shared": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
257
|
+
"@open-mercato/ui": "0.6.6-develop.5738.1.4182c2b2c7",
|
|
258
258
|
"@testing-library/dom": "^10.4.1",
|
|
259
259
|
"@testing-library/jest-dom": "^6.9.1",
|
|
260
260
|
"@testing-library/react": "^16.3.1",
|
|
@@ -830,11 +830,12 @@ export default function EditCatalogProductPage({
|
|
|
830
830
|
{
|
|
831
831
|
id: "details",
|
|
832
832
|
column: 1,
|
|
833
|
-
component: ({ values, setValue, errors }) => (
|
|
833
|
+
component: ({ values, setValue, errors, requiredFieldIds }) => (
|
|
834
834
|
<ProductDetailsSection
|
|
835
835
|
values={values as ProductFormValues}
|
|
836
836
|
setValue={setValue}
|
|
837
837
|
errors={errors}
|
|
838
|
+
requiredFieldIds={requiredFieldIds}
|
|
838
839
|
productId={productId ?? ""}
|
|
839
840
|
hasVariants={Boolean(
|
|
840
841
|
(values as ProductFormValues).hasVariants,
|
|
@@ -1498,6 +1499,7 @@ function ProductDetailsSection({
|
|
|
1498
1499
|
values,
|
|
1499
1500
|
setValue,
|
|
1500
1501
|
errors,
|
|
1502
|
+
requiredFieldIds,
|
|
1501
1503
|
productId,
|
|
1502
1504
|
hasVariants,
|
|
1503
1505
|
variantMediaGroups,
|
|
@@ -1554,10 +1556,10 @@ function ProductDetailsSection({
|
|
|
1554
1556
|
|
|
1555
1557
|
return (
|
|
1556
1558
|
<div className="space-y-6">
|
|
1557
|
-
<div className="space-y-2">
|
|
1559
|
+
<div className="space-y-2" data-crud-field-id="title">
|
|
1558
1560
|
<Label className="flex items-center gap-1">
|
|
1559
1561
|
{t("catalog.products.form.title", "Title")}
|
|
1560
|
-
<span className="text-
|
|
1562
|
+
<span className="text-status-error-text">*</span>
|
|
1561
1563
|
</Label>
|
|
1562
1564
|
<Input
|
|
1563
1565
|
value={values.title}
|
|
@@ -1568,13 +1570,18 @@ function ProductDetailsSection({
|
|
|
1568
1570
|
)}
|
|
1569
1571
|
/>
|
|
1570
1572
|
{errors.title ? (
|
|
1571
|
-
<p className="text-xs text-
|
|
1573
|
+
<p className="text-xs text-status-error-text">{errors.title}</p>
|
|
1572
1574
|
) : null}
|
|
1573
1575
|
</div>
|
|
1574
1576
|
|
|
1575
|
-
<div className="space-y-2">
|
|
1577
|
+
<div className="space-y-2" data-crud-field-id="description">
|
|
1576
1578
|
<div className="flex items-center justify-between">
|
|
1577
|
-
<Label
|
|
1579
|
+
<Label className="flex items-center gap-1">
|
|
1580
|
+
{t("catalog.products.form.description", "Description")}
|
|
1581
|
+
{requiredFieldIds?.has("description") ? (
|
|
1582
|
+
<span className="text-status-error-text">*</span>
|
|
1583
|
+
) : null}
|
|
1584
|
+
</Label>
|
|
1578
1585
|
<Button
|
|
1579
1586
|
type="button"
|
|
1580
1587
|
variant="ghost"
|
|
@@ -1611,6 +1618,9 @@ function ProductDetailsSection({
|
|
|
1611
1618
|
)}
|
|
1612
1619
|
/>
|
|
1613
1620
|
)}
|
|
1621
|
+
{errors.description ? (
|
|
1622
|
+
<p className="text-xs text-status-error-text">{errors.description}</p>
|
|
1623
|
+
) : null}
|
|
1614
1624
|
</div>
|
|
1615
1625
|
|
|
1616
1626
|
<ProductMediaManager
|
|
@@ -304,6 +304,7 @@ export default function CreateCatalogProductPage() {
|
|
|
304
304
|
values,
|
|
305
305
|
setValue,
|
|
306
306
|
errors,
|
|
307
|
+
requiredFieldIds,
|
|
307
308
|
}: CrudFormGroupComponentProps) => (
|
|
308
309
|
<ProductBuilder
|
|
309
310
|
values={values as ProductFormValues}
|
|
@@ -311,6 +312,7 @@ export default function CreateCatalogProductPage() {
|
|
|
311
312
|
errors={errors}
|
|
312
313
|
priceKinds={priceKinds}
|
|
313
314
|
taxRates={taxRates}
|
|
315
|
+
requiredFieldIds={requiredFieldIds}
|
|
314
316
|
/>
|
|
315
317
|
),
|
|
316
318
|
},
|
|
@@ -827,6 +829,7 @@ type ProductBuilderProps = {
|
|
|
827
829
|
errors: Record<string, string>;
|
|
828
830
|
priceKinds: PriceKindSummary[];
|
|
829
831
|
taxRates: TaxRateSummary[];
|
|
832
|
+
requiredFieldIds?: ReadonlySet<string>;
|
|
830
833
|
};
|
|
831
834
|
|
|
832
835
|
type ProductMetaSectionProps = {
|
|
@@ -984,6 +987,7 @@ function ProductBuilder({
|
|
|
984
987
|
errors,
|
|
985
988
|
priceKinds,
|
|
986
989
|
taxRates,
|
|
990
|
+
requiredFieldIds,
|
|
987
991
|
}: ProductBuilderProps) {
|
|
988
992
|
const t = useT();
|
|
989
993
|
const steps = PRODUCT_FORM_STEPS;
|
|
@@ -1325,10 +1329,10 @@ function ProductBuilder({
|
|
|
1325
1329
|
|
|
1326
1330
|
{currentStepKey === "general" ? (
|
|
1327
1331
|
<div className="space-y-6">
|
|
1328
|
-
<div className="space-y-2">
|
|
1332
|
+
<div className="space-y-2" data-crud-field-id="title">
|
|
1329
1333
|
<Label className="flex items-center gap-1">
|
|
1330
1334
|
{t("catalog.products.form.title", "Title")}
|
|
1331
|
-
<span className="text-
|
|
1335
|
+
<span className="text-status-error-text">*</span>
|
|
1332
1336
|
</Label>
|
|
1333
1337
|
<Input
|
|
1334
1338
|
value={values.title}
|
|
@@ -1339,14 +1343,17 @@ function ProductBuilder({
|
|
|
1339
1343
|
)}
|
|
1340
1344
|
/>
|
|
1341
1345
|
{errors.title ? (
|
|
1342
|
-
<p className="text-xs text-
|
|
1346
|
+
<p className="text-xs text-status-error-text">{errors.title}</p>
|
|
1343
1347
|
) : null}
|
|
1344
1348
|
</div>
|
|
1345
1349
|
|
|
1346
|
-
<div className="space-y-2">
|
|
1350
|
+
<div className="space-y-2" data-crud-field-id="description">
|
|
1347
1351
|
<div className="flex items-center justify-between">
|
|
1348
|
-
<Label>
|
|
1352
|
+
<Label className="flex items-center gap-1">
|
|
1349
1353
|
{t("catalog.products.form.description", "Description")}
|
|
1354
|
+
{requiredFieldIds?.has("description") ? (
|
|
1355
|
+
<span className="text-status-error-text">*</span>
|
|
1356
|
+
) : null}
|
|
1350
1357
|
</Label>
|
|
1351
1358
|
<Button
|
|
1352
1359
|
type="button"
|
|
@@ -1389,6 +1396,9 @@ function ProductBuilder({
|
|
|
1389
1396
|
)}
|
|
1390
1397
|
/>
|
|
1391
1398
|
)}
|
|
1399
|
+
{errors.description ? (
|
|
1400
|
+
<p className="text-xs text-status-error-text">{errors.description}</p>
|
|
1401
|
+
) : null}
|
|
1392
1402
|
</div>
|
|
1393
1403
|
|
|
1394
1404
|
<ProductMediaManager
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export type ProductSeoEvaluation = {
|
|
2
|
+
ok: boolean
|
|
3
|
+
issues: string[]
|
|
4
|
+
fieldErrors: Record<string, string>
|
|
5
|
+
message?: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const MESSAGE_PREFIX = 'SEO helper: '
|
|
9
|
+
|
|
10
|
+
export function buildSeoBlockMessage(issues: string[]): string {
|
|
11
|
+
if (issues.length === 1) return `${MESSAGE_PREFIX}${issues[0]}`
|
|
12
|
+
if (issues.length <= 3) return `${MESSAGE_PREFIX}${issues.join(' ')}`
|
|
13
|
+
return `${MESSAGE_PREFIX}${issues.length} issues found. ${issues.slice(0, 2).join(' ')}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function evaluateProductSeo(data: Record<string, unknown> | null | undefined): ProductSeoEvaluation {
|
|
17
|
+
const issues: string[] = []
|
|
18
|
+
const fieldErrors: Record<string, string> = {}
|
|
19
|
+
|
|
20
|
+
const title = (data?.title as unknown) || (data?.name as unknown)
|
|
21
|
+
if (typeof title === 'string' && title.length > 0) {
|
|
22
|
+
if (title.length < 10) {
|
|
23
|
+
issues.push('Title is too short (min 10 characters).')
|
|
24
|
+
fieldErrors.title = 'Title is too short for good SEO (min 10 characters).'
|
|
25
|
+
} else if (title.length > 60) {
|
|
26
|
+
issues.push('Title is too long (max 60 characters recommended).')
|
|
27
|
+
fieldErrors.title = 'Title is too long for optimal SEO (max 60 characters).'
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const description = data?.description
|
|
32
|
+
if (typeof description === 'string') {
|
|
33
|
+
if (description.trim().length === 0) {
|
|
34
|
+
issues.push('Add a product description for better SEO.')
|
|
35
|
+
fieldErrors.description = 'Provide a description to help search engines understand this product.'
|
|
36
|
+
} else if (description.length < 50) {
|
|
37
|
+
issues.push('Description is too short (min 50 characters).')
|
|
38
|
+
fieldErrors.description = 'Description is too short for good SEO (min 50 characters).'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (issues.length) {
|
|
43
|
+
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues) }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { ok: true, issues: [], fieldErrors: {} }
|
|
47
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
2
|
import ProductSeoWidget from './widget.client'
|
|
3
3
|
import { publishProductSeoValidation } from './state'
|
|
4
|
+
import { evaluateProductSeo } from './validation'
|
|
4
5
|
|
|
5
6
|
const widget: InjectionWidgetModule<any, any> = {
|
|
6
7
|
metadata: {
|
|
@@ -10,39 +11,16 @@ const widget: InjectionWidgetModule<any, any> = {
|
|
|
10
11
|
features: ['catalog.products.edit'],
|
|
11
12
|
priority: 90,
|
|
12
13
|
enabled: true,
|
|
14
|
+
requiredFields: ['description'],
|
|
13
15
|
},
|
|
14
16
|
Widget: ProductSeoWidget,
|
|
15
17
|
eventHandlers: {
|
|
16
|
-
onBeforeSave: async (data
|
|
17
|
-
const
|
|
18
|
-
const fieldErrors: Record<string, string> = {}
|
|
18
|
+
onBeforeSave: async (data) => {
|
|
19
|
+
const evaluation = evaluateProductSeo(data as Record<string, unknown>)
|
|
19
20
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
issues.push('Title is too short (min 10 characters).')
|
|
24
|
-
fieldErrors.title = 'Title is too short for good SEO (min 10 characters).'
|
|
25
|
-
} else if (title.length > 60) {
|
|
26
|
-
issues.push('Title is too long (max 60 characters recommended).')
|
|
27
|
-
fieldErrors.title = 'Title is too long for optimal SEO (max 60 characters).'
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const description = data?.description
|
|
32
|
-
if (typeof description === 'string') {
|
|
33
|
-
if (description.trim().length === 0) {
|
|
34
|
-
issues.push('Add a product description for better SEO.')
|
|
35
|
-
fieldErrors.description = 'Provide a description to help search engines understand this product.'
|
|
36
|
-
} else if (description.length < 50) {
|
|
37
|
-
issues.push('Description is too short (min 50 characters).')
|
|
38
|
-
fieldErrors.description = 'Description is too short for good SEO (min 50 characters).'
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (issues.length) {
|
|
43
|
-
const message = 'SEO helper blocked save. Improve the highlighted fields.'
|
|
44
|
-
publishProductSeoValidation({ ok: false, issues, message })
|
|
45
|
-
return { ok: false, message, fieldErrors }
|
|
21
|
+
if (!evaluation.ok) {
|
|
22
|
+
publishProductSeoValidation({ ok: false, issues: evaluation.issues, message: evaluation.message })
|
|
23
|
+
return { ok: false, message: evaluation.message, fieldErrors: evaluation.fieldErrors }
|
|
46
24
|
}
|
|
47
25
|
|
|
48
26
|
publishProductSeoValidation({ ok: true, issues: [] })
|