@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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/attachments/api/route.js +10 -4
  3. package/dist/modules/attachments/api/route.js.map +2 -2
  4. package/dist/modules/catalog/widgets/injection/product-seo/state.js +9 -0
  5. package/dist/modules/catalog/widgets/injection/product-seo/state.js.map +2 -2
  6. package/dist/modules/catalog/widgets/injection/product-seo/validation.js +29 -15
  7. package/dist/modules/catalog/widgets/injection/product-seo/validation.js.map +2 -2
  8. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js +5 -1
  9. package/dist/modules/catalog/widgets/injection/product-seo/widget.client.js.map +2 -2
  10. package/dist/modules/catalog/widgets/injection/product-seo/widget.js +2 -2
  11. package/dist/modules/catalog/widgets/injection/product-seo/widget.js.map +2 -2
  12. package/dist/modules/customers/api/pipeline-stages/route.js +6 -4
  13. package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
  14. package/dist/modules/customers/api/pipelines/route.js +5 -3
  15. package/dist/modules/customers/api/pipelines/route.js.map +2 -2
  16. package/dist/modules/staff/api/team-members/assignable/route.js +7 -11
  17. package/dist/modules/staff/api/team-members/assignable/route.js.map +2 -2
  18. package/package.json +7 -7
  19. package/src/modules/attachments/api/route.ts +20 -4
  20. package/src/modules/attachments/i18n/de.json +1 -0
  21. package/src/modules/attachments/i18n/en.json +1 -0
  22. package/src/modules/attachments/i18n/es.json +1 -0
  23. package/src/modules/attachments/i18n/pl.json +1 -0
  24. package/src/modules/catalog/i18n/de.json +10 -0
  25. package/src/modules/catalog/i18n/en.json +10 -0
  26. package/src/modules/catalog/i18n/es.json +10 -0
  27. package/src/modules/catalog/i18n/pl.json +10 -0
  28. package/src/modules/catalog/widgets/injection/product-seo/state.ts +17 -0
  29. package/src/modules/catalog/widgets/injection/product-seo/validation.ts +38 -15
  30. package/src/modules/catalog/widgets/injection/product-seo/widget.client.tsx +7 -1
  31. package/src/modules/catalog/widgets/injection/product-seo/widget.ts +2 -2
  32. package/src/modules/customers/api/pipeline-stages/route.ts +6 -4
  33. package/src/modules/customers/api/pipelines/route.ts +5 -3
  34. 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
- const MESSAGE_PREFIX = 'SEO helper: '
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 === 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(' ')}`
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(data: Record<string, unknown> | null | undefined): ProductSeoEvaluation {
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, organizationId, tenantId, translate } = await buildContext(req)
60
- if (!organizationId || !tenantId) {
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> = { organizationId, tenantId }
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, organizationId, tenantId, translate } = await buildContext(req)
60
- if (!organizationId || !tenantId) {
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> = { organizationId, tenantId }
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, selectedOrganizationId } = await resolveCustomersRequestContext(request)
76
+ const { container, em, auth, scope: organizationScope } = await resolveCustomersRequestContext(request)
76
77
 
77
- if (!selectedOrganizationId) {
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: selectedOrganizationId }
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
- organizationId: selectedOrganizationId,
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
- organizationId: selectedOrganizationId,
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
- organizationId: selectedOrganizationId,
149
+ ...orgFilter.where,
154
150
  },
155
151
  undefined,
156
152
  scope,