@open-mercato/core 0.6.6-develop.6403.1.b57089b6fe → 0.6.6-develop.6406.1.a0bd770daf
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/attachments/api/route.js +10 -4
- package/dist/modules/attachments/api/route.js.map +2 -2
- 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/dist/modules/customers/api/pipeline-stages/route.js +6 -4
- package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
- package/dist/modules/customers/api/pipelines/route.js +5 -3
- package/dist/modules/customers/api/pipelines/route.js.map +2 -2
- package/dist/modules/staff/api/team-members/assignable/route.js +7 -11
- package/dist/modules/staff/api/team-members/assignable/route.js.map +2 -2
- package/package.json +7 -7
- package/src/modules/attachments/api/route.ts +20 -4
- package/src/modules/attachments/i18n/de.json +1 -0
- package/src/modules/attachments/i18n/en.json +1 -0
- package/src/modules/attachments/i18n/es.json +1 -0
- package/src/modules/attachments/i18n/pl.json +1 -0
- 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/src/modules/customers/api/pipeline-stages/route.ts +6 -4
- package/src/modules/customers/api/pipelines/route.ts +5 -3
- package/src/modules/staff/api/team-members/assignable/route.ts +8 -12
|
@@ -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 })
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
6
7
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
7
8
|
import type { CommandRuntimeContext, CommandBus } from '@open-mercato/shared/lib/commands'
|
|
8
9
|
import { CustomerPipelineStage, CustomerDictionaryEntry } from '../../data/entities'
|
|
@@ -56,15 +57,16 @@ async function buildContext(
|
|
|
56
57
|
|
|
57
58
|
export async function GET(req: Request) {
|
|
58
59
|
try {
|
|
59
|
-
const { ctx,
|
|
60
|
-
if (!
|
|
60
|
+
const { ctx, tenantId, translate } = await buildContext(req)
|
|
61
|
+
if (!tenantId) {
|
|
61
62
|
return NextResponse.json({ error: translate('customers.errors.context_required', 'Organization and tenant context required') }, { status: 400 })
|
|
62
63
|
}
|
|
64
|
+
const orgFilter = resolveOrganizationScopeFilter(ctx.organizationScope, ctx.auth)
|
|
63
65
|
const url = new URL(req.url)
|
|
64
66
|
const pipelineId = url.searchParams.get('pipelineId')
|
|
65
67
|
|
|
66
68
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
67
|
-
const where: Record<string, unknown> = {
|
|
69
|
+
const where: Record<string, unknown> = { tenantId, ...orgFilter.where }
|
|
68
70
|
if (pipelineId) where.pipelineId = pipelineId
|
|
69
71
|
|
|
70
72
|
const stages = await em.find(CustomerPipelineStage, where, { orderBy: { order: 'ASC' } })
|
|
@@ -72,8 +74,8 @@ export async function GET(req: Request) {
|
|
|
72
74
|
const stageLabels = stages.map((s) => s.label.trim().toLowerCase())
|
|
73
75
|
const dictEntries = stageLabels.length
|
|
74
76
|
? await em.find(CustomerDictionaryEntry, {
|
|
75
|
-
organizationId,
|
|
76
77
|
tenantId,
|
|
78
|
+
...orgFilter.where,
|
|
77
79
|
kind: 'pipeline_stage',
|
|
78
80
|
normalizedValue: { $in: stageLabels },
|
|
79
81
|
})
|
|
@@ -3,6 +3,7 @@ import { z } from 'zod'
|
|
|
3
3
|
import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
|
|
4
4
|
import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
|
|
5
5
|
import { resolveOrganizationScopeForRequest } from '@open-mercato/core/modules/directory/utils/organizationScope'
|
|
6
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
6
7
|
import type { EntityManager } from '@mikro-orm/postgresql'
|
|
7
8
|
import type { CommandRuntimeContext, CommandBus } from '@open-mercato/shared/lib/commands'
|
|
8
9
|
import { CustomerPipeline } from '../../data/entities'
|
|
@@ -56,15 +57,16 @@ async function buildContext(
|
|
|
56
57
|
|
|
57
58
|
export async function GET(req: Request) {
|
|
58
59
|
try {
|
|
59
|
-
const { ctx,
|
|
60
|
-
if (!
|
|
60
|
+
const { ctx, tenantId, translate } = await buildContext(req)
|
|
61
|
+
if (!tenantId) {
|
|
61
62
|
return NextResponse.json({ error: translate('customers.errors.context_required', 'Organization and tenant context required') }, { status: 400 })
|
|
62
63
|
}
|
|
64
|
+
const orgFilter = resolveOrganizationScopeFilter(ctx.organizationScope, ctx.auth)
|
|
63
65
|
const url = new URL(req.url)
|
|
64
66
|
const isDefaultParam = url.searchParams.get('isDefault')
|
|
65
67
|
|
|
66
68
|
const em = (ctx.container.resolve('em') as EntityManager)
|
|
67
|
-
const where: Record<string, unknown> = {
|
|
69
|
+
const where: Record<string, unknown> = { tenantId, ...orgFilter.where }
|
|
68
70
|
if (isDefaultParam === 'true') where.isDefault = true
|
|
69
71
|
if (isDefaultParam === 'false') where.isDefault = false
|
|
70
72
|
|
|
@@ -6,6 +6,7 @@ import { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
|
6
6
|
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
7
7
|
import { createPagedListResponseSchema as createSharedPagedListResponseSchema } from '@open-mercato/shared/lib/openapi/crud'
|
|
8
8
|
import type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'
|
|
9
|
+
import { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'
|
|
9
10
|
import { User } from '@open-mercato/core/modules/auth/data/entities'
|
|
10
11
|
import {
|
|
11
12
|
resolveAuthActorId,
|
|
@@ -57,7 +58,7 @@ export const metadata = {
|
|
|
57
58
|
async function canAccessAssignableStaff(
|
|
58
59
|
rbac: RbacService | undefined,
|
|
59
60
|
userId: string,
|
|
60
|
-
scope: { tenantId: string; organizationId: string },
|
|
61
|
+
scope: { tenantId: string; organizationId: string | null },
|
|
61
62
|
): Promise<boolean> {
|
|
62
63
|
if (!rbac) return false
|
|
63
64
|
if (
|
|
@@ -72,18 +73,13 @@ export async function GET(request: Request) {
|
|
|
72
73
|
const { translate } = await resolveTranslations()
|
|
73
74
|
try {
|
|
74
75
|
const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams))
|
|
75
|
-
const { container, em, auth,
|
|
76
|
+
const { container, em, auth, scope: organizationScope } = await resolveCustomersRequestContext(request)
|
|
76
77
|
|
|
77
|
-
|
|
78
|
-
throw new CrudHttpError(
|
|
79
|
-
400,
|
|
80
|
-
{ error: translate('customers.errors.organization_required', 'Organization context is required') },
|
|
81
|
-
)
|
|
82
|
-
}
|
|
78
|
+
const orgFilter = resolveOrganizationScopeFilter(organizationScope, auth)
|
|
83
79
|
|
|
84
80
|
const actorId = resolveAuthActorId(auth)
|
|
85
81
|
const rbacService = container.resolve('rbacService') as RbacService | undefined
|
|
86
|
-
const scope = { tenantId: auth.tenantId, organizationId:
|
|
82
|
+
const scope = { tenantId: auth.tenantId, organizationId: orgFilter.rbacOrganizationId }
|
|
87
83
|
const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope)
|
|
88
84
|
if (!hasAccess) {
|
|
89
85
|
throw new CrudHttpError(
|
|
@@ -104,7 +100,7 @@ export async function GET(request: Request) {
|
|
|
104
100
|
StaffTeamMember,
|
|
105
101
|
{
|
|
106
102
|
tenantId: auth.tenantId,
|
|
107
|
-
|
|
103
|
+
...orgFilter.where,
|
|
108
104
|
deletedAt: null,
|
|
109
105
|
isActive: true,
|
|
110
106
|
},
|
|
@@ -136,7 +132,7 @@ export async function GET(request: Request) {
|
|
|
136
132
|
id: { $in: userIds },
|
|
137
133
|
deletedAt: null,
|
|
138
134
|
tenantId: auth.tenantId,
|
|
139
|
-
|
|
135
|
+
...orgFilter.where,
|
|
140
136
|
},
|
|
141
137
|
undefined,
|
|
142
138
|
scope,
|
|
@@ -150,7 +146,7 @@ export async function GET(request: Request) {
|
|
|
150
146
|
id: { $in: teamIds },
|
|
151
147
|
deletedAt: null,
|
|
152
148
|
tenantId: auth.tenantId,
|
|
153
|
-
|
|
149
|
+
...orgFilter.where,
|
|
154
150
|
},
|
|
155
151
|
undefined,
|
|
156
152
|
scope,
|