@open-mercato/core 0.6.6-develop.6403.1.b57089b6fe → 0.6.6-develop.6404.1.e2f8d5025a
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/widgets/injection/product-seo/state.js +9 -0
- package/dist/modules/catalog/widgets/injection/product-seo/state.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/validation.js +29 -15
- package/dist/modules/catalog/widgets/injection/product-seo/validation.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +5 -1
- package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js +2 -2
- package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/catalog/i18n/de.json +10 -0
- package/src/modules/catalog/i18n/en.json +10 -0
- package/src/modules/catalog/i18n/es.json +10 -0
- package/src/modules/catalog/i18n/pl.json +10 -0
- package/src/modules/catalog/widgets/injection/product-seo/state.ts +17 -0
- package/src/modules/catalog/widgets/injection/product-seo/validation.ts +38 -15
- package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +7 -1
- package/src/modules/catalog/widgets/injection/product-seo/widget.ts +2 -2
package/.turbo/turbo-build.log
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
let productSeoTranslator = null;
|
|
2
|
+
function setProductSeoTranslator(t) {
|
|
3
|
+
productSeoTranslator = t;
|
|
4
|
+
}
|
|
5
|
+
function getProductSeoTranslator() {
|
|
6
|
+
return productSeoTranslator ?? void 0;
|
|
7
|
+
}
|
|
1
8
|
const listeners = /* @__PURE__ */ new Set();
|
|
2
9
|
function publishProductSeoValidation(payload) {
|
|
3
10
|
listeners.forEach((listener) => {
|
|
@@ -13,7 +20,9 @@ function subscribeProductSeoValidation(listener) {
|
|
|
13
20
|
return () => listeners.delete(listener);
|
|
14
21
|
}
|
|
15
22
|
export {
|
|
23
|
+
getProductSeoTranslator,
|
|
16
24
|
publishProductSeoValidation,
|
|
25
|
+
setProductSeoTranslator,
|
|
17
26
|
subscribeProductSeoValidation
|
|
18
27
|
};
|
|
19
28
|
//# sourceMappingURL=state.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/catalog/widgets/injection/product-seo/state.ts"],
|
|
4
|
-
"sourcesContent": ["
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\n\nexport type ProductSeoValidationPayload = {\n ok: boolean\n issues: string[]\n message?: string\n}\n\n// The `onBeforeSave` validation hook (widget.ts) is a plain side-effect handler\n// with no React context, so it cannot call `useT`. The widget component (which\n// does have `useT`) publishes its translator here on mount; the hook reads it to\n// localize the save-block message (#3299). Undefined until the widget mounts \u2014\n// callers fall back to English via `evaluateProductSeo`'s default translator.\nlet productSeoTranslator: TranslateFn | null = null\n\nexport function setProductSeoTranslator(t: TranslateFn | null) {\n productSeoTranslator = t\n}\n\nexport function getProductSeoTranslator(): TranslateFn | undefined {\n return productSeoTranslator ?? undefined\n}\n\ntype Listener = (payload: ProductSeoValidationPayload) => void\n\nconst listeners = new Set<Listener>()\n\nexport function publishProductSeoValidation(payload: ProductSeoValidationPayload) {\n listeners.forEach((listener) => {\n try {\n listener(payload)\n } catch (err) {\n console.error('[product-seo] Failed to notify listener', err)\n }\n })\n}\n\nexport function subscribeProductSeoValidation(listener: Listener): () => void {\n listeners.add(listener)\n return () => listeners.delete(listener)\n}\n"],
|
|
5
|
+
"mappings": "AAaA,IAAI,uBAA2C;AAExC,SAAS,wBAAwB,GAAuB;AAC7D,yBAAuB;AACzB;AAEO,SAAS,0BAAmD;AACjE,SAAO,wBAAwB;AACjC;AAIA,MAAM,YAAY,oBAAI,IAAc;AAE7B,SAAS,4BAA4B,SAAsC;AAChF,YAAU,QAAQ,CAAC,aAAa;AAC9B,QAAI;AACF,eAAS,OAAO;AAAA,IAClB,SAAS,KAAK;AACZ,cAAQ,MAAM,2CAA2C,GAAG;AAAA,IAC9D;AAAA,EACF,CAAC;AACH;AAEO,SAAS,8BAA8B,UAAgC;AAC5E,YAAU,IAAI,QAAQ;AACtB,SAAO,MAAM,UAAU,OAAO,QAAQ;AACxC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,34 +1,48 @@
|
|
|
1
|
-
const
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
if (
|
|
5
|
-
return
|
|
1
|
+
const englishFallback = (_key, fallbackOrParams, params) => {
|
|
2
|
+
const template = typeof fallbackOrParams === "string" ? fallbackOrParams : "";
|
|
3
|
+
const resolvedParams = typeof fallbackOrParams === "string" ? params : fallbackOrParams;
|
|
4
|
+
if (!resolvedParams) return template;
|
|
5
|
+
return template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleKey, singleKey) => {
|
|
6
|
+
const key = doubleKey ?? singleKey;
|
|
7
|
+
const value = resolvedParams[key];
|
|
8
|
+
return value === void 0 ? match : String(value);
|
|
9
|
+
});
|
|
10
|
+
};
|
|
11
|
+
const K = "catalog.products.create.seoWidget.validation";
|
|
12
|
+
function buildSeoBlockMessage(issues, t = englishFallback) {
|
|
13
|
+
if (issues.length <= 3) {
|
|
14
|
+
return t(`${K}.block.list`, "SEO helper: {{issues}}", { issues: issues.join(" ") });
|
|
15
|
+
}
|
|
16
|
+
return t(`${K}.block.summary`, "SEO helper: {{count}} issues found. {{issues}}", {
|
|
17
|
+
count: issues.length,
|
|
18
|
+
issues: issues.slice(0, 2).join(" ")
|
|
19
|
+
});
|
|
6
20
|
}
|
|
7
|
-
function evaluateProductSeo(data) {
|
|
21
|
+
function evaluateProductSeo(data, t = englishFallback) {
|
|
8
22
|
const issues = [];
|
|
9
23
|
const fieldErrors = {};
|
|
10
24
|
const title = data?.title || data?.name;
|
|
11
25
|
if (typeof title === "string" && title.length > 0) {
|
|
12
26
|
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).";
|
|
27
|
+
issues.push(t(`${K}.issue.titleTooShort`, "Title is too short (min 10 characters)."));
|
|
28
|
+
fieldErrors.title = t(`${K}.fieldError.titleTooShort`, "Title is too short for good SEO (min 10 characters).");
|
|
15
29
|
} 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).";
|
|
30
|
+
issues.push(t(`${K}.issue.titleTooLong`, "Title is too long (max 60 characters recommended)."));
|
|
31
|
+
fieldErrors.title = t(`${K}.fieldError.titleTooLong`, "Title is too long for optimal SEO (max 60 characters).");
|
|
18
32
|
}
|
|
19
33
|
}
|
|
20
34
|
const description = data?.description;
|
|
21
35
|
if (typeof description === "string") {
|
|
22
36
|
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.";
|
|
37
|
+
issues.push(t(`${K}.issue.descriptionMissing`, "Add a product description for better SEO."));
|
|
38
|
+
fieldErrors.description = t(`${K}.fieldError.descriptionMissing`, "Provide a description to help search engines understand this product.");
|
|
25
39
|
} 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).";
|
|
40
|
+
issues.push(t(`${K}.issue.descriptionTooShort`, "Description is too short (min 50 characters)."));
|
|
41
|
+
fieldErrors.description = t(`${K}.fieldError.descriptionTooShort`, "Description is too short for good SEO (min 50 characters).");
|
|
28
42
|
}
|
|
29
43
|
}
|
|
30
44
|
if (issues.length) {
|
|
31
|
-
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues) };
|
|
45
|
+
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues, t) };
|
|
32
46
|
}
|
|
33
47
|
return { ok: true, issues: [], fieldErrors: {} };
|
|
34
48
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/catalog/widgets/injection/product-seo/validation.ts"],
|
|
4
|
-
"sourcesContent": ["
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'\n\nexport type ProductSeoEvaluation = {\n ok: boolean\n issues: string[]\n fieldErrors: Record<string, string>\n message?: string\n}\n\n// English-fallback translator: keeps this pure module usable (and unit-testable)\n// when no real `t` is threaded in \u2014 every string still ships its English default,\n// interpolating `{{param}}` placeholders the same way the real translator does.\nconst englishFallback: TranslateFn = (_key, fallbackOrParams, params) => {\n const template = typeof fallbackOrParams === 'string' ? fallbackOrParams : ''\n const resolvedParams = typeof fallbackOrParams === 'string' ? params : fallbackOrParams\n if (!resolvedParams) return template\n return template.replace(/\\{\\{(\\w+)\\}\\}|\\{(\\w+)\\}/g, (match, doubleKey, singleKey) => {\n const key = doubleKey ?? singleKey\n const value = resolvedParams[key]\n return value === undefined ? match : String(value)\n })\n}\n\nconst K = 'catalog.products.create.seoWidget.validation'\n\nexport function buildSeoBlockMessage(issues: string[], t: TranslateFn = englishFallback): string {\n if (issues.length <= 3) {\n return t(`${K}.block.list`, 'SEO helper: {{issues}}', { issues: issues.join(' ') })\n }\n return t(`${K}.block.summary`, 'SEO helper: {{count}} issues found. {{issues}}', {\n count: issues.length,\n issues: issues.slice(0, 2).join(' '),\n })\n}\n\nexport function evaluateProductSeo(\n data: Record<string, unknown> | null | undefined,\n t: TranslateFn = englishFallback,\n): 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(t(`${K}.issue.titleTooShort`, 'Title is too short (min 10 characters).'))\n fieldErrors.title = t(`${K}.fieldError.titleTooShort`, 'Title is too short for good SEO (min 10 characters).')\n } else if (title.length > 60) {\n issues.push(t(`${K}.issue.titleTooLong`, 'Title is too long (max 60 characters recommended).'))\n fieldErrors.title = t(`${K}.fieldError.titleTooLong`, '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(t(`${K}.issue.descriptionMissing`, 'Add a product description for better SEO.'))\n fieldErrors.description = t(`${K}.fieldError.descriptionMissing`, 'Provide a description to help search engines understand this product.')\n } else if (description.length < 50) {\n issues.push(t(`${K}.issue.descriptionTooShort`, 'Description is too short (min 50 characters).'))\n fieldErrors.description = t(`${K}.fieldError.descriptionTooShort`, '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, t) }\n }\n\n return { ok: true, issues: [], fieldErrors: {} }\n}\n"],
|
|
5
|
+
"mappings": "AAYA,MAAM,kBAA+B,CAAC,MAAM,kBAAkB,WAAW;AACvE,QAAM,WAAW,OAAO,qBAAqB,WAAW,mBAAmB;AAC3E,QAAM,iBAAiB,OAAO,qBAAqB,WAAW,SAAS;AACvE,MAAI,CAAC,eAAgB,QAAO;AAC5B,SAAO,SAAS,QAAQ,4BAA4B,CAAC,OAAO,WAAW,cAAc;AACnF,UAAM,MAAM,aAAa;AACzB,UAAM,QAAQ,eAAe,GAAG;AAChC,WAAO,UAAU,SAAY,QAAQ,OAAO,KAAK;AAAA,EACnD,CAAC;AACH;AAEA,MAAM,IAAI;AAEH,SAAS,qBAAqB,QAAkB,IAAiB,iBAAyB;AAC/F,MAAI,OAAO,UAAU,GAAG;AACtB,WAAO,EAAE,GAAG,CAAC,eAAe,0BAA0B,EAAE,QAAQ,OAAO,KAAK,GAAG,EAAE,CAAC;AAAA,EACpF;AACA,SAAO,EAAE,GAAG,CAAC,kBAAkB,kDAAkD;AAAA,IAC/E,OAAO,OAAO;AAAA,IACd,QAAQ,OAAO,MAAM,GAAG,CAAC,EAAE,KAAK,GAAG;AAAA,EACrC,CAAC;AACH;AAEO,SAAS,mBACd,MACA,IAAiB,iBACK;AACtB,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,EAAE,GAAG,CAAC,wBAAwB,yCAAyC,CAAC;AACpF,kBAAY,QAAQ,EAAE,GAAG,CAAC,6BAA6B,sDAAsD;AAAA,IAC/G,WAAW,MAAM,SAAS,IAAI;AAC5B,aAAO,KAAK,EAAE,GAAG,CAAC,uBAAuB,oDAAoD,CAAC;AAC9F,kBAAY,QAAQ,EAAE,GAAG,CAAC,4BAA4B,wDAAwD;AAAA,IAChH;AAAA,EACF;AAEA,QAAM,cAAc,MAAM;AAC1B,MAAI,OAAO,gBAAgB,UAAU;AACnC,QAAI,YAAY,KAAK,EAAE,WAAW,GAAG;AACnC,aAAO,KAAK,EAAE,GAAG,CAAC,6BAA6B,2CAA2C,CAAC;AAC3F,kBAAY,cAAc,EAAE,GAAG,CAAC,kCAAkC,uEAAuE;AAAA,IAC3I,WAAW,YAAY,SAAS,IAAI;AAClC,aAAO,KAAK,EAAE,GAAG,CAAC,8BAA8B,+CAA+C,CAAC;AAChG,kBAAY,cAAc,EAAE,GAAG,CAAC,mCAAmC,4DAA4D;AAAA,IACjI;AAAA,EACF;AAEA,MAAI,OAAO,QAAQ;AACjB,WAAO,EAAE,IAAI,OAAO,QAAQ,aAAa,SAAS,qBAAqB,QAAQ,CAAC,EAAE;AAAA,EACpF;AAEA,SAAO,EAAE,IAAI,MAAM,QAAQ,CAAC,GAAG,aAAa,CAAC,EAAE;AACjD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
3
|
import * as React from "react";
|
|
4
|
-
import { subscribeProductSeoValidation } from "./state.js";
|
|
4
|
+
import { subscribeProductSeoValidation, setProductSeoTranslator } from "./state.js";
|
|
5
5
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
6
6
|
import { StatusBadge } from "@open-mercato/ui/primitives/status-badge";
|
|
7
7
|
import { Alert } from "@open-mercato/ui/primitives/alert";
|
|
@@ -22,6 +22,10 @@ function computeIssueKeys(title, description) {
|
|
|
22
22
|
}
|
|
23
23
|
function ProductSeoWidget({ data }) {
|
|
24
24
|
const t = useT();
|
|
25
|
+
React.useEffect(() => {
|
|
26
|
+
setProductSeoTranslator(t);
|
|
27
|
+
return () => setProductSeoTranslator(null);
|
|
28
|
+
}, [t]);
|
|
25
29
|
const title = (data?.title || data?.name || "") ?? "";
|
|
26
30
|
const description = data?.description ?? "";
|
|
27
31
|
const baselineIssueKeys = React.useMemo(() => computeIssueKeys(title, description), [title, description]);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../../../src/modules/catalog/widgets/injection/product-seo/widget.client.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { subscribeProductSeoValidation } from './state'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'\nimport { Alert } from '@open-mercato/ui/primitives/alert'\n\ntype SeoData = {\n title?: string | null\n name?: string | null\n description?: string | null\n}\n\ntype ValidationState = { ok: boolean; issues: string[]; message?: string }\n\ntype IssueKey = 'addTitle' | 'titleTooShort' | 'titleTooLong' | 'addDescription' | 'descriptionTooShort'\n\ntype SeoScore = { text: string; variant: StatusBadgeVariant }\n\nfunction computeIssueKeys(title: string, description: string): IssueKey[] {\n const issues: IssueKey[] = []\n if (!title) {\n issues.push('addTitle')\n } else {\n if (title.length < 10) issues.push('titleTooShort')\n if (title.length > 60) issues.push('titleTooLong')\n }\n if (!description) {\n issues.push('addDescription')\n } else if (description.length < 50) {\n issues.push('descriptionTooShort')\n }\n return issues\n}\n\nexport default function ProductSeoWidget({ data }: InjectionWidgetComponentProps<unknown, SeoData>) {\n const t = useT()\n const title = (data?.title || data?.name || '') ?? ''\n const description = data?.description ?? ''\n const baselineIssueKeys = React.useMemo(() => computeIssueKeys(title, description), [title, description])\n const [validation, setValidation] = React.useState<ValidationState>({ ok: baselineIssueKeys.length === 0, issues: baselineIssueKeys })\n\n React.useEffect(() => {\n setValidation({ ok: baselineIssueKeys.length === 0, issues: baselineIssueKeys })\n }, [baselineIssueKeys])\n\n React.useEffect(() => {\n return subscribeProductSeoValidation((payload) => {\n setValidation({\n ok: payload.ok,\n issues: payload.issues,\n message: payload.message,\n })\n })\n }, [])\n\n const titleScore = React.useMemo<SeoScore>(() => {\n if (!title) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), variant: 'error' }\n if (title.length < 10) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), variant: 'warning' }\n if (title.length > 60) return { text: t('catalog.products.create.seoWidget.tooLong', 'Too long'), variant: 'warning' }\n return { text: t('catalog.products.create.seoWidget.good', 'Good'), variant: 'success' }\n }, [title, t])\n\n const descScore = React.useMemo<SeoScore>(() => {\n if (!description) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), variant: 'error' }\n if (description.length < 50) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), variant: 'warning' }\n return { text: t('catalog.products.create.seoWidget.good', 'Good'), variant: 'success' }\n }, [description, t])\n\n const statusBadge = validation.ok ? (\n <StatusBadge variant=\"success\">\n {t('catalog.products.create.seoWidget.ready', 'Ready')}\n </StatusBadge>\n ) : (\n <StatusBadge variant=\"warning\">\n {t('catalog.products.create.seoWidget.needsAttention', 'Needs attention')}\n </StatusBadge>\n )\n\n const translateIssue = (issueKey: string): string => {\n return t(`catalog.products.create.seoWidget.issues.${issueKey}`, issueKey)\n }\n\n return (\n <div className=\"mt-4 w-full space-y-3 rounded-lg border bg-card p-4 shadow-sm\">\n <div className=\"flex items-start justify-between gap-3\">\n <div>\n <div className=\"text-sm font-semibold text-foreground\">{t('catalog.products.create.seoWidget.title', 'SEO Optimization')}</div>\n <p className=\"text-xs text-muted-foreground\">{t('catalog.products.create.seoWidget.hint', 'Keep titles 10\u201360 chars and descriptions 50+ chars.')}</p>\n </div>\n {statusBadge}\n </div>\n\n {validation.message || validation.issues.length ? (\n <Alert\n status={validation.ok ? 'success' : 'warning'}\n style=\"lighter\"\n size=\"sm\"\n showIcon={false}\n className=\"text-xs\"\n >\n {validation.message ? <div className=\"font-medium\">{validation.message}</div> : null}\n {validation.issues.length ? (\n <ul className=\"ml-4 list-disc space-y-1 pt-1\">\n {validation.issues.map((issue) => (\n <li key={issue}>{translateIssue(issue)}</li>\n ))}\n </ul>\n ) : null}\n </Alert>\n ) : null}\n\n <div className=\"rounded border bg-muted/30 p-3 text-sm\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-muted-foreground\">{t('catalog.products.create.seoWidget.titleLabel', 'Title ({{count}} chars)', { count: title.length })}</span>\n <StatusBadge variant={titleScore.variant}>{titleScore.text}</StatusBadge>\n </div>\n <div className=\"mt-2 flex items-center justify-between\">\n <span className=\"text-muted-foreground\">{t('catalog.products.create.seoWidget.descriptionLabel', 'Description ({{count}} chars)', { count: description.length })}</span>\n <StatusBadge variant={descScore.variant}>{descScore.text}</StatusBadge>\n </div>\n </div>\n\n <p className=\"text-overline text-muted-foreground\">\n {t('catalog.products.create.seoWidget.footer', 'Example widget powered by the injection system.')}{' '}\n <a className=\"text-primary underline\" href=\"/docs/framework/admin-ui/widget-injection\" target=\"_blank\" rel=\"noreferrer\">\n {t('catalog.products.create.seoWidget.learnMore', 'Learn how to build your own')}\n </a>\n .\n </p>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'\nimport { subscribeProductSeoValidation, setProductSeoTranslator } from './state'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'\nimport { Alert } from '@open-mercato/ui/primitives/alert'\n\ntype SeoData = {\n title?: string | null\n name?: string | null\n description?: string | null\n}\n\ntype ValidationState = { ok: boolean; issues: string[]; message?: string }\n\ntype IssueKey = 'addTitle' | 'titleTooShort' | 'titleTooLong' | 'addDescription' | 'descriptionTooShort'\n\ntype SeoScore = { text: string; variant: StatusBadgeVariant }\n\nfunction computeIssueKeys(title: string, description: string): IssueKey[] {\n const issues: IssueKey[] = []\n if (!title) {\n issues.push('addTitle')\n } else {\n if (title.length < 10) issues.push('titleTooShort')\n if (title.length > 60) issues.push('titleTooLong')\n }\n if (!description) {\n issues.push('addDescription')\n } else if (description.length < 50) {\n issues.push('descriptionTooShort')\n }\n return issues\n}\n\nexport default function ProductSeoWidget({ data }: InjectionWidgetComponentProps<unknown, SeoData>) {\n const t = useT()\n // Expose the translator to the module's `onBeforeSave` hook (which has no React\n // context) so the save-block message is localized (#3299).\n React.useEffect(() => {\n setProductSeoTranslator(t)\n return () => setProductSeoTranslator(null)\n }, [t])\n const title = (data?.title || data?.name || '') ?? ''\n const description = data?.description ?? ''\n const baselineIssueKeys = React.useMemo(() => computeIssueKeys(title, description), [title, description])\n const [validation, setValidation] = React.useState<ValidationState>({ ok: baselineIssueKeys.length === 0, issues: baselineIssueKeys })\n\n React.useEffect(() => {\n setValidation({ ok: baselineIssueKeys.length === 0, issues: baselineIssueKeys })\n }, [baselineIssueKeys])\n\n React.useEffect(() => {\n return subscribeProductSeoValidation((payload) => {\n setValidation({\n ok: payload.ok,\n issues: payload.issues,\n message: payload.message,\n })\n })\n }, [])\n\n const titleScore = React.useMemo<SeoScore>(() => {\n if (!title) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), variant: 'error' }\n if (title.length < 10) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), variant: 'warning' }\n if (title.length > 60) return { text: t('catalog.products.create.seoWidget.tooLong', 'Too long'), variant: 'warning' }\n return { text: t('catalog.products.create.seoWidget.good', 'Good'), variant: 'success' }\n }, [title, t])\n\n const descScore = React.useMemo<SeoScore>(() => {\n if (!description) return { text: t('catalog.products.create.seoWidget.missing', 'Missing'), variant: 'error' }\n if (description.length < 50) return { text: t('catalog.products.create.seoWidget.tooShort', 'Too short'), variant: 'warning' }\n return { text: t('catalog.products.create.seoWidget.good', 'Good'), variant: 'success' }\n }, [description, t])\n\n const statusBadge = validation.ok ? (\n <StatusBadge variant=\"success\">\n {t('catalog.products.create.seoWidget.ready', 'Ready')}\n </StatusBadge>\n ) : (\n <StatusBadge variant=\"warning\">\n {t('catalog.products.create.seoWidget.needsAttention', 'Needs attention')}\n </StatusBadge>\n )\n\n const translateIssue = (issueKey: string): string => {\n return t(`catalog.products.create.seoWidget.issues.${issueKey}`, issueKey)\n }\n\n return (\n <div className=\"mt-4 w-full space-y-3 rounded-lg border bg-card p-4 shadow-sm\">\n <div className=\"flex items-start justify-between gap-3\">\n <div>\n <div className=\"text-sm font-semibold text-foreground\">{t('catalog.products.create.seoWidget.title', 'SEO Optimization')}</div>\n <p className=\"text-xs text-muted-foreground\">{t('catalog.products.create.seoWidget.hint', 'Keep titles 10\u201360 chars and descriptions 50+ chars.')}</p>\n </div>\n {statusBadge}\n </div>\n\n {validation.message || validation.issues.length ? (\n <Alert\n status={validation.ok ? 'success' : 'warning'}\n style=\"lighter\"\n size=\"sm\"\n showIcon={false}\n className=\"text-xs\"\n >\n {validation.message ? <div className=\"font-medium\">{validation.message}</div> : null}\n {validation.issues.length ? (\n <ul className=\"ml-4 list-disc space-y-1 pt-1\">\n {validation.issues.map((issue) => (\n <li key={issue}>{translateIssue(issue)}</li>\n ))}\n </ul>\n ) : null}\n </Alert>\n ) : null}\n\n <div className=\"rounded border bg-muted/30 p-3 text-sm\">\n <div className=\"flex items-center justify-between\">\n <span className=\"text-muted-foreground\">{t('catalog.products.create.seoWidget.titleLabel', 'Title ({{count}} chars)', { count: title.length })}</span>\n <StatusBadge variant={titleScore.variant}>{titleScore.text}</StatusBadge>\n </div>\n <div className=\"mt-2 flex items-center justify-between\">\n <span className=\"text-muted-foreground\">{t('catalog.products.create.seoWidget.descriptionLabel', 'Description ({{count}} chars)', { count: description.length })}</span>\n <StatusBadge variant={descScore.variant}>{descScore.text}</StatusBadge>\n </div>\n </div>\n\n <p className=\"text-overline text-muted-foreground\">\n {t('catalog.products.create.seoWidget.footer', 'Example widget powered by the injection system.')}{' '}\n <a className=\"text-primary underline\" href=\"/docs/framework/admin-ui/widget-injection\" target=\"_blank\" rel=\"noreferrer\">\n {t('catalog.products.create.seoWidget.learnMore', 'Learn how to build your own')}\n </a>\n .\n </p>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import ProductSeoWidget from "./widget.client.js";
|
|
2
|
-
import { publishProductSeoValidation } from "./state.js";
|
|
2
|
+
import { publishProductSeoValidation, getProductSeoTranslator } from "./state.js";
|
|
3
3
|
import { evaluateProductSeo } from "./validation.js";
|
|
4
4
|
const widget = {
|
|
5
5
|
metadata: {
|
|
@@ -14,7 +14,7 @@ const widget = {
|
|
|
14
14
|
Widget: ProductSeoWidget,
|
|
15
15
|
eventHandlers: {
|
|
16
16
|
onBeforeSave: async (data) => {
|
|
17
|
-
const evaluation = evaluateProductSeo(data);
|
|
17
|
+
const evaluation = evaluateProductSeo(data, getProductSeoTranslator());
|
|
18
18
|
if (!evaluation.ok) {
|
|
19
19
|
publishProductSeoValidation({ ok: false, issues: evaluation.issues, message: evaluation.message });
|
|
20
20
|
return { ok: false, message: evaluation.message, fieldErrors: evaluation.fieldErrors };
|
|
@@ -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'\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
|
|
5
|
-
"mappings": "AACA,OAAO,sBAAsB;AAC7B,SAAS,
|
|
4
|
+
"sourcesContent": ["import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'\nimport ProductSeoWidget from './widget.client'\nimport { publishProductSeoValidation, getProductSeoTranslator } 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>, getProductSeoTranslator())\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,6BAA6B,+BAA+B;AACrE,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,MAAiC,wBAAwB,CAAC;AAEhG,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.6404.1.e2f8d5025a",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -248,16 +248,16 @@
|
|
|
248
248
|
"zod": "^4.4.3"
|
|
249
249
|
},
|
|
250
250
|
"peerDependencies": {
|
|
251
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
252
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
253
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
251
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
252
|
+
"@open-mercato/shared": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
253
|
+
"@open-mercato/ui": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
254
254
|
"react": "^19.0.0",
|
|
255
255
|
"react-dom": "^19.0.0"
|
|
256
256
|
},
|
|
257
257
|
"devDependencies": {
|
|
258
|
-
"@open-mercato/ai-assistant": "0.6.6-develop.
|
|
259
|
-
"@open-mercato/shared": "0.6.6-develop.
|
|
260
|
-
"@open-mercato/ui": "0.6.6-develop.
|
|
258
|
+
"@open-mercato/ai-assistant": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
259
|
+
"@open-mercato/shared": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
260
|
+
"@open-mercato/ui": "0.6.6-develop.6404.1.e2f8d5025a",
|
|
261
261
|
"@testing-library/dom": "^10.4.1",
|
|
262
262
|
"@testing-library/jest-dom": "^6.9.1",
|
|
263
263
|
"@testing-library/react": "^16.3.1",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Titel ({{count}} Zeichen)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Zu lang",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Zu kurz",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "SEO-Helfer: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "SEO-Helfer: {{count}} Probleme gefunden. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Geben Sie eine Beschreibung an, damit Suchmaschinen dieses Produkt besser verstehen.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "Die Beschreibung ist für gutes SEO zu kurz (mind. 50 Zeichen).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "Der Titel ist für optimales SEO zu lang (max. 60 Zeichen).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "Der Titel ist für gutes SEO zu kurz (mind. 10 Zeichen).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Fügen Sie eine Produktbeschreibung für besseres SEO hinzu.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "Die Beschreibung ist zu kurz (mind. 50 Zeichen).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "Der Titel ist zu lang (max. 60 Zeichen empfohlen).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "Der Titel ist zu kurz (mind. 10 Zeichen).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Eindeutige Produktkennung. Buchstaben, Zahlen, Bindestriche, Unterstriche, Punkte.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Weiter",
|
|
351
361
|
"catalog.products.create.steps.general": "Allgemeine Daten",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Title ({{count}} chars)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Too long",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Too short",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "SEO helper: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "SEO helper: {{count}} issues found. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Provide a description to help search engines understand this product.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "Description is too short for good SEO (min 50 characters).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "Title is too long for optimal SEO (max 60 characters).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "Title is too short for good SEO (min 10 characters).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Add a product description for better SEO.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "Description is too short (min 50 characters).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "Title is too long (max 60 characters recommended).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "Title is too short (min 10 characters).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Unique product identifier. Letters, numbers, hyphens, underscores, periods.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Continue",
|
|
351
361
|
"catalog.products.create.steps.general": "General data",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Título ({{count}} caracteres)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Muy largo",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Muy corto",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "Asistente SEO: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "Asistente SEO: {{count}} problemas encontrados. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Proporciona una descripción para ayudar a los motores de búsqueda a entender este producto.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "La descripción es demasiado corta para un buen SEO (mín. 50 caracteres).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "El título es demasiado largo para un SEO óptimo (máx. 60 caracteres).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "El título es demasiado corto para un buen SEO (mín. 10 caracteres).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Añade una descripción de producto para mejorar el SEO.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "La descripción es demasiado corta (mín. 50 caracteres).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "El título es demasiado largo (máx. 60 caracteres recomendado).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "El título es demasiado corto (mín. 10 caracteres).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Identificador único del producto. Letras, números, guiones, guiones bajos, puntos.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Continuar",
|
|
351
361
|
"catalog.products.create.steps.general": "Datos generales",
|
|
@@ -346,6 +346,16 @@
|
|
|
346
346
|
"catalog.products.create.seoWidget.titleLabel": "Tytuł ({{count}} znaków)",
|
|
347
347
|
"catalog.products.create.seoWidget.tooLong": "Za długi",
|
|
348
348
|
"catalog.products.create.seoWidget.tooShort": "Za krótki",
|
|
349
|
+
"catalog.products.create.seoWidget.validation.block.list": "Pomocnik SEO: {{issues}}",
|
|
350
|
+
"catalog.products.create.seoWidget.validation.block.summary": "Pomocnik SEO: znaleziono {{count}} problemów. {{issues}}",
|
|
351
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionMissing": "Podaj opis, aby wyszukiwarki lepiej rozumiały ten produkt.",
|
|
352
|
+
"catalog.products.create.seoWidget.validation.fieldError.descriptionTooShort": "Opis jest za krótki dla dobrego SEO (min. 50 znaków).",
|
|
353
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooLong": "Tytuł jest za długi dla optymalnego SEO (maks. 60 znaków).",
|
|
354
|
+
"catalog.products.create.seoWidget.validation.fieldError.titleTooShort": "Tytuł jest za krótki dla dobrego SEO (min. 10 znaków).",
|
|
355
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionMissing": "Dodaj opis produktu dla lepszego SEO.",
|
|
356
|
+
"catalog.products.create.seoWidget.validation.issue.descriptionTooShort": "Opis jest za krótki (min. 50 znaków).",
|
|
357
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooLong": "Tytuł jest za długi (zalecane maks. 60 znaków).",
|
|
358
|
+
"catalog.products.create.seoWidget.validation.issue.titleTooShort": "Tytuł jest za krótki (min. 10 znaków).",
|
|
349
359
|
"catalog.products.create.skuHelp": "Unikalny identyfikator produktu. Litery, cyfry, myślniki, podkreślenia, kropki.",
|
|
350
360
|
"catalog.products.create.steps.continue": "Dalej",
|
|
351
361
|
"catalog.products.create.steps.general": "Dane ogólne",
|
|
@@ -1,9 +1,26 @@
|
|
|
1
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
2
|
+
|
|
1
3
|
export type ProductSeoValidationPayload = {
|
|
2
4
|
ok: boolean
|
|
3
5
|
issues: string[]
|
|
4
6
|
message?: string
|
|
5
7
|
}
|
|
6
8
|
|
|
9
|
+
// The `onBeforeSave` validation hook (widget.ts) is a plain side-effect handler
|
|
10
|
+
// with no React context, so it cannot call `useT`. The widget component (which
|
|
11
|
+
// does have `useT`) publishes its translator here on mount; the hook reads it to
|
|
12
|
+
// localize the save-block message (#3299). Undefined until the widget mounts —
|
|
13
|
+
// callers fall back to English via `evaluateProductSeo`'s default translator.
|
|
14
|
+
let productSeoTranslator: TranslateFn | null = null
|
|
15
|
+
|
|
16
|
+
export function setProductSeoTranslator(t: TranslateFn | null) {
|
|
17
|
+
productSeoTranslator = t
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getProductSeoTranslator(): TranslateFn | undefined {
|
|
21
|
+
return productSeoTranslator ?? undefined
|
|
22
|
+
}
|
|
23
|
+
|
|
7
24
|
type Listener = (payload: ProductSeoValidationPayload) => void
|
|
8
25
|
|
|
9
26
|
const listeners = new Set<Listener>()
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
2
|
+
|
|
1
3
|
export type ProductSeoEvaluation = {
|
|
2
4
|
ok: boolean
|
|
3
5
|
issues: string[]
|
|
@@ -5,42 +7,63 @@ export type ProductSeoEvaluation = {
|
|
|
5
7
|
message?: string
|
|
6
8
|
}
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
// English-fallback translator: keeps this pure module usable (and unit-testable)
|
|
11
|
+
// when no real `t` is threaded in — every string still ships its English default,
|
|
12
|
+
// interpolating `{{param}}` placeholders the same way the real translator does.
|
|
13
|
+
const englishFallback: TranslateFn = (_key, fallbackOrParams, params) => {
|
|
14
|
+
const template = typeof fallbackOrParams === 'string' ? fallbackOrParams : ''
|
|
15
|
+
const resolvedParams = typeof fallbackOrParams === 'string' ? params : fallbackOrParams
|
|
16
|
+
if (!resolvedParams) return template
|
|
17
|
+
return template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleKey, singleKey) => {
|
|
18
|
+
const key = doubleKey ?? singleKey
|
|
19
|
+
const value = resolvedParams[key]
|
|
20
|
+
return value === undefined ? match : String(value)
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const K = 'catalog.products.create.seoWidget.validation'
|
|
9
25
|
|
|
10
|
-
export function buildSeoBlockMessage(issues: string[]): string {
|
|
11
|
-
if (issues.length
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
export function buildSeoBlockMessage(issues: string[], t: TranslateFn = englishFallback): string {
|
|
27
|
+
if (issues.length <= 3) {
|
|
28
|
+
return t(`${K}.block.list`, 'SEO helper: {{issues}}', { issues: issues.join(' ') })
|
|
29
|
+
}
|
|
30
|
+
return t(`${K}.block.summary`, 'SEO helper: {{count}} issues found. {{issues}}', {
|
|
31
|
+
count: issues.length,
|
|
32
|
+
issues: issues.slice(0, 2).join(' '),
|
|
33
|
+
})
|
|
14
34
|
}
|
|
15
35
|
|
|
16
|
-
export function evaluateProductSeo(
|
|
36
|
+
export function evaluateProductSeo(
|
|
37
|
+
data: Record<string, unknown> | null | undefined,
|
|
38
|
+
t: TranslateFn = englishFallback,
|
|
39
|
+
): ProductSeoEvaluation {
|
|
17
40
|
const issues: string[] = []
|
|
18
41
|
const fieldErrors: Record<string, string> = {}
|
|
19
42
|
|
|
20
43
|
const title = (data?.title as unknown) || (data?.name as unknown)
|
|
21
44
|
if (typeof title === 'string' && title.length > 0) {
|
|
22
45
|
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).'
|
|
46
|
+
issues.push(t(`${K}.issue.titleTooShort`, 'Title is too short (min 10 characters).'))
|
|
47
|
+
fieldErrors.title = t(`${K}.fieldError.titleTooShort`, 'Title is too short for good SEO (min 10 characters).')
|
|
25
48
|
} 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).'
|
|
49
|
+
issues.push(t(`${K}.issue.titleTooLong`, 'Title is too long (max 60 characters recommended).'))
|
|
50
|
+
fieldErrors.title = t(`${K}.fieldError.titleTooLong`, 'Title is too long for optimal SEO (max 60 characters).')
|
|
28
51
|
}
|
|
29
52
|
}
|
|
30
53
|
|
|
31
54
|
const description = data?.description
|
|
32
55
|
if (typeof description === 'string') {
|
|
33
56
|
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.'
|
|
57
|
+
issues.push(t(`${K}.issue.descriptionMissing`, 'Add a product description for better SEO.'))
|
|
58
|
+
fieldErrors.description = t(`${K}.fieldError.descriptionMissing`, 'Provide a description to help search engines understand this product.')
|
|
36
59
|
} 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).'
|
|
60
|
+
issues.push(t(`${K}.issue.descriptionTooShort`, 'Description is too short (min 50 characters).'))
|
|
61
|
+
fieldErrors.description = t(`${K}.fieldError.descriptionTooShort`, 'Description is too short for good SEO (min 50 characters).')
|
|
39
62
|
}
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
if (issues.length) {
|
|
43
|
-
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues) }
|
|
66
|
+
return { ok: false, issues, fieldErrors, message: buildSeoBlockMessage(issues, t) }
|
|
44
67
|
}
|
|
45
68
|
|
|
46
69
|
return { ok: true, issues: [], fieldErrors: {} }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import type { InjectionWidgetComponentProps } from '@open-mercato/shared/modules/widgets/injection'
|
|
4
|
-
import { subscribeProductSeoValidation } from './state'
|
|
4
|
+
import { subscribeProductSeoValidation, setProductSeoTranslator } from './state'
|
|
5
5
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
6
|
import { StatusBadge, type StatusBadgeVariant } from '@open-mercato/ui/primitives/status-badge'
|
|
7
7
|
import { Alert } from '@open-mercato/ui/primitives/alert'
|
|
@@ -36,6 +36,12 @@ function computeIssueKeys(title: string, description: string): IssueKey[] {
|
|
|
36
36
|
|
|
37
37
|
export default function ProductSeoWidget({ data }: InjectionWidgetComponentProps<unknown, SeoData>) {
|
|
38
38
|
const t = useT()
|
|
39
|
+
// Expose the translator to the module's `onBeforeSave` hook (which has no React
|
|
40
|
+
// context) so the save-block message is localized (#3299).
|
|
41
|
+
React.useEffect(() => {
|
|
42
|
+
setProductSeoTranslator(t)
|
|
43
|
+
return () => setProductSeoTranslator(null)
|
|
44
|
+
}, [t])
|
|
39
45
|
const title = (data?.title || data?.name || '') ?? ''
|
|
40
46
|
const description = data?.description ?? ''
|
|
41
47
|
const baselineIssueKeys = React.useMemo(() => computeIssueKeys(title, description), [title, description])
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { InjectionWidgetModule } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
2
|
import ProductSeoWidget from './widget.client'
|
|
3
|
-
import { publishProductSeoValidation } from './state'
|
|
3
|
+
import { publishProductSeoValidation, getProductSeoTranslator } from './state'
|
|
4
4
|
import { evaluateProductSeo } from './validation'
|
|
5
5
|
|
|
6
6
|
const widget: InjectionWidgetModule<any, any> = {
|
|
@@ -16,7 +16,7 @@ const widget: InjectionWidgetModule<any, any> = {
|
|
|
16
16
|
Widget: ProductSeoWidget,
|
|
17
17
|
eventHandlers: {
|
|
18
18
|
onBeforeSave: async (data) => {
|
|
19
|
-
const evaluation = evaluateProductSeo(data as Record<string, unknown
|
|
19
|
+
const evaluation = evaluateProductSeo(data as Record<string, unknown>, getProductSeoTranslator())
|
|
20
20
|
|
|
21
21
|
if (!evaluation.ok) {
|
|
22
22
|
publishProductSeoValidation({ ok: false, issues: evaluation.issues, message: evaluation.message })
|