@open-mercato/core 0.6.6-develop.6404.1.e2f8d5025a → 0.6.6-develop.6407.1.9a4c26cf6a

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 (40) 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/api/settings/route.js +142 -0
  5. package/dist/modules/catalog/api/settings/route.js.map +7 -0
  6. package/dist/modules/catalog/backend/config/catalog/page.js +6 -2
  7. package/dist/modules/catalog/backend/config/catalog/page.js.map +2 -2
  8. package/dist/modules/catalog/components/UnitPriceDisplaySettings.js +89 -0
  9. package/dist/modules/catalog/components/UnitPriceDisplaySettings.js.map +7 -0
  10. package/dist/modules/catalog/components/products/ProductUomSection.js +4 -2
  11. package/dist/modules/catalog/components/products/ProductUomSection.js.map +2 -2
  12. package/dist/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.js +25 -0
  13. package/dist/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.js.map +7 -0
  14. package/dist/modules/catalog/lib/settings.js +9 -0
  15. package/dist/modules/catalog/lib/settings.js.map +7 -0
  16. package/dist/modules/customers/api/pipeline-stages/route.js +6 -4
  17. package/dist/modules/customers/api/pipeline-stages/route.js.map +2 -2
  18. package/dist/modules/customers/api/pipelines/route.js +5 -3
  19. package/dist/modules/customers/api/pipelines/route.js.map +2 -2
  20. package/dist/modules/staff/api/team-members/assignable/route.js +7 -11
  21. package/dist/modules/staff/api/team-members/assignable/route.js.map +2 -2
  22. package/package.json +7 -7
  23. package/src/modules/attachments/api/route.ts +20 -4
  24. package/src/modules/attachments/i18n/de.json +1 -0
  25. package/src/modules/attachments/i18n/en.json +1 -0
  26. package/src/modules/attachments/i18n/es.json +1 -0
  27. package/src/modules/attachments/i18n/pl.json +1 -0
  28. package/src/modules/catalog/api/settings/route.ts +165 -0
  29. package/src/modules/catalog/backend/config/catalog/page.tsx +2 -0
  30. package/src/modules/catalog/components/UnitPriceDisplaySettings.tsx +106 -0
  31. package/src/modules/catalog/components/products/ProductUomSection.tsx +4 -0
  32. package/src/modules/catalog/components/products/hooks/useUnitPriceDisplayEnabled.ts +39 -0
  33. package/src/modules/catalog/i18n/de.json +8 -0
  34. package/src/modules/catalog/i18n/en.json +8 -0
  35. package/src/modules/catalog/i18n/es.json +8 -0
  36. package/src/modules/catalog/i18n/pl.json +8 -0
  37. package/src/modules/catalog/lib/settings.ts +8 -0
  38. package/src/modules/customers/api/pipeline-stages/route.ts +6 -4
  39. package/src/modules/customers/api/pipelines/route.ts +5 -3
  40. package/src/modules/staff/api/team-members/assignable/route.ts +8 -12
@@ -4,6 +4,7 @@ import { CrudHttpError, isCrudHttpError } from "@open-mercato/shared/lib/crud/er
4
4
  import { findWithDecryption } from "@open-mercato/shared/lib/encryption/find";
5
5
  import { resolveTranslations } from "@open-mercato/shared/lib/i18n/server";
6
6
  import { createPagedListResponseSchema as createSharedPagedListResponseSchema } from "@open-mercato/shared/lib/openapi/crud";
7
+ import { resolveOrganizationScopeFilter } from "@open-mercato/core/modules/directory/utils/organizationScopeFilter";
7
8
  import { User } from "@open-mercato/core/modules/auth/data/entities";
8
9
  import {
9
10
  resolveAuthActorId,
@@ -49,16 +50,11 @@ async function GET(request) {
49
50
  const { translate } = await resolveTranslations();
50
51
  try {
51
52
  const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams));
52
- const { container, em, auth, selectedOrganizationId } = await resolveCustomersRequestContext(request);
53
- if (!selectedOrganizationId) {
54
- throw new CrudHttpError(
55
- 400,
56
- { error: translate("customers.errors.organization_required", "Organization context is required") }
57
- );
58
- }
53
+ const { container, em, auth, scope: organizationScope } = await resolveCustomersRequestContext(request);
54
+ const orgFilter = resolveOrganizationScopeFilter(organizationScope, auth);
59
55
  const actorId = resolveAuthActorId(auth);
60
56
  const rbacService = container.resolve("rbacService");
61
- const scope = { tenantId: auth.tenantId, organizationId: selectedOrganizationId };
57
+ const scope = { tenantId: auth.tenantId, organizationId: orgFilter.rbacOrganizationId };
62
58
  const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope);
63
59
  if (!hasAccess) {
64
60
  throw new CrudHttpError(
@@ -77,7 +73,7 @@ async function GET(request) {
77
73
  StaffTeamMember,
78
74
  {
79
75
  tenantId: auth.tenantId,
80
- organizationId: selectedOrganizationId,
76
+ ...orgFilter.where,
81
77
  deletedAt: null,
82
78
  isActive: true
83
79
  },
@@ -102,7 +98,7 @@ async function GET(request) {
102
98
  id: { $in: userIds },
103
99
  deletedAt: null,
104
100
  tenantId: auth.tenantId,
105
- organizationId: selectedOrganizationId
101
+ ...orgFilter.where
106
102
  },
107
103
  void 0,
108
104
  scope
@@ -114,7 +110,7 @@ async function GET(request) {
114
110
  id: { $in: teamIds },
115
111
  deletedAt: null,
116
112
  tenantId: auth.tenantId,
117
- organizationId: selectedOrganizationId
113
+ ...orgFilter.where
118
114
  },
119
115
  void 0,
120
116
  scope
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../../src/modules/staff/api/team-members/assignable/route.ts"],
4
- "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createPagedListResponseSchema as createSharedPagedListResponseSchema } from '@open-mercato/shared/lib/openapi/crud'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport {\n resolveAuthActorId,\n resolveCustomersRequestContext,\n} from '@open-mercato/core/modules/customers/lib/interactionRequestContext'\nimport { StaffTeam, StaffTeamMember } from '../../../data/entities'\n\nconst querySchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(24),\n search: z.string().optional(),\n })\n .passthrough()\n\nconst itemSchema = z.object({\n id: z.string().uuid(),\n teamMemberId: z.string().uuid(),\n userId: z.string().uuid(),\n displayName: z.string(),\n email: z.string().nullable().optional(),\n teamName: z.string().nullable().optional(),\n user: z\n .object({\n id: z.string().uuid(),\n email: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n team: z\n .object({\n id: z.string().uuid(),\n name: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nconst pagedListSchema = createSharedPagedListResponseSchema(itemSchema, {\n paginationMetaOptional: true,\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.roles.view'] },\n}\n\nasync function canAccessAssignableStaff(\n rbac: RbacService | undefined,\n userId: string,\n scope: { tenantId: string; organizationId: string },\n): Promise<boolean> {\n if (!rbac) return false\n if (\n await rbac.userHasAllFeatures(userId, ['customers.roles.manage'], scope)\n ) {\n return true\n }\n return rbac.userHasAllFeatures(userId, ['customers.activities.manage'], scope)\n}\n\nexport async function GET(request: Request) {\n const { translate } = await resolveTranslations()\n try {\n const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams))\n const { container, em, auth, selectedOrganizationId } = await resolveCustomersRequestContext(request)\n\n if (!selectedOrganizationId) {\n throw new CrudHttpError(\n 400,\n { error: translate('customers.errors.organization_required', 'Organization context is required') },\n )\n }\n\n const actorId = resolveAuthActorId(auth)\n const rbacService = container.resolve('rbacService') as RbacService | undefined\n const scope = { tenantId: auth.tenantId, organizationId: selectedOrganizationId }\n const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope)\n if (!hasAccess) {\n throw new CrudHttpError(\n 403,\n {\n error: translate(\n 'customers.assignableStaff.forbidden',\n 'Insufficient permissions to load assignable staff.',\n ),\n },\n )\n }\n\n const normalizedSearch = query.search?.trim().toLowerCase() ?? ''\n\n const members = await findWithDecryption(\n em,\n StaffTeamMember,\n {\n tenantId: auth.tenantId,\n organizationId: selectedOrganizationId,\n deletedAt: null,\n isActive: true,\n },\n { orderBy: { displayName: 'asc' } },\n scope,\n )\n\n const userIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.userId === 'string' && member.userId.trim().length > 0 ? member.userId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n const teamIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.teamId === 'string' && member.teamId.trim().length > 0 ? member.teamId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n\n const [users, teams] = await Promise.all([\n userIds.length > 0\n ? findWithDecryption(\n em,\n User,\n {\n id: { $in: userIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n organizationId: selectedOrganizationId,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n teamIds.length > 0\n ? findWithDecryption(\n em,\n StaffTeam,\n {\n id: { $in: teamIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n organizationId: selectedOrganizationId,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n ])\n\n const userById = new Map(\n users.map((user) => [\n user.id,\n {\n id: user.id,\n email: user.email ?? null,\n },\n ]),\n )\n const teamById = new Map(\n teams.map((team) => [\n team.id,\n {\n id: team.id,\n name: team.name ?? null,\n },\n ]),\n )\n\n const items = members\n .filter((member) => typeof member.userId === 'string' && member.userId.trim().length > 0)\n .map((member) => {\n const userId = member.userId as string\n const user = userById.get(userId) ?? { id: userId, email: null }\n const team = member.teamId ? teamById.get(member.teamId) ?? null : null\n return {\n id: member.id,\n teamMemberId: member.id,\n userId,\n displayName: member.displayName?.trim() || user.email || userId,\n email: user.email,\n teamName: team?.name ?? null,\n user,\n team,\n }\n })\n .filter((item) => {\n if (!normalizedSearch) return true\n const haystack = [item.displayName, item.email, item.teamName]\n .filter((value): value is string => typeof value === 'string' && value.length > 0)\n .join(' ')\n .toLowerCase()\n return haystack.includes(normalizedSearch)\n })\n\n const deduped = Array.from(\n items.reduce((acc, item) => {\n if (!acc.has(item.userId)) {\n acc.set(item.userId, item)\n }\n return acc\n }, new Map<string, (typeof items)[number]>()),\n ).map(([, item]) => item)\n\n const start = (query.page - 1) * query.pageSize\n return NextResponse.json({\n items: deduped.slice(start, start + query.pageSize),\n total: deduped.length,\n page: query.page,\n pageSize: query.pageSize,\n })\n } catch (error) {\n if (isCrudHttpError(error)) {\n return NextResponse.json(error.body, { status: error.status })\n }\n if (error instanceof z.ZodError) {\n return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed') }, { status: 400 })\n }\n console.error('staff.assignable-team-members.get failed', error)\n return NextResponse.json({ error: translate('customers.errors.assignable_staff_load_failed', 'Failed to load assignable staff') }, { status: 500 })\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Assignable staff candidates',\n methods: {\n GET: {\n summary: 'List staff members that can be assigned from customer flows',\n query: querySchema,\n description:\n 'Returns active staff members linked to auth users. Access requires either customers.roles.manage or customers.activities.manage. Owned by the staff module; consumed from customer flows via this canonical URL. Replaces the deprecated /api/customers/assignable-staff route.',\n responses: [\n {\n status: 200,\n description: 'Assignable staff members',\n schema: pagedListSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { NextResponse } from 'next/server'\nimport { z } from 'zod'\nimport type { OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'\nimport { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'\nimport { findWithDecryption } from '@open-mercato/shared/lib/encryption/find'\nimport { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'\nimport { createPagedListResponseSchema as createSharedPagedListResponseSchema } from '@open-mercato/shared/lib/openapi/crud'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\nimport { resolveOrganizationScopeFilter } from '@open-mercato/core/modules/directory/utils/organizationScopeFilter'\nimport { User } from '@open-mercato/core/modules/auth/data/entities'\nimport {\n resolveAuthActorId,\n resolveCustomersRequestContext,\n} from '@open-mercato/core/modules/customers/lib/interactionRequestContext'\nimport { StaffTeam, StaffTeamMember } from '../../../data/entities'\n\nconst querySchema = z\n .object({\n page: z.coerce.number().min(1).default(1),\n pageSize: z.coerce.number().min(1).max(100).default(24),\n search: z.string().optional(),\n })\n .passthrough()\n\nconst itemSchema = z.object({\n id: z.string().uuid(),\n teamMemberId: z.string().uuid(),\n userId: z.string().uuid(),\n displayName: z.string(),\n email: z.string().nullable().optional(),\n teamName: z.string().nullable().optional(),\n user: z\n .object({\n id: z.string().uuid(),\n email: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n team: z\n .object({\n id: z.string().uuid(),\n name: z.string().nullable().optional(),\n })\n .nullable()\n .optional(),\n})\n\nconst errorSchema = z.object({ error: z.string() })\n\nconst pagedListSchema = createSharedPagedListResponseSchema(itemSchema, {\n paginationMetaOptional: true,\n})\n\nexport const metadata = {\n GET: { requireAuth: true, requireFeatures: ['customers.roles.view'] },\n}\n\nasync function canAccessAssignableStaff(\n rbac: RbacService | undefined,\n userId: string,\n scope: { tenantId: string; organizationId: string | null },\n): Promise<boolean> {\n if (!rbac) return false\n if (\n await rbac.userHasAllFeatures(userId, ['customers.roles.manage'], scope)\n ) {\n return true\n }\n return rbac.userHasAllFeatures(userId, ['customers.activities.manage'], scope)\n}\n\nexport async function GET(request: Request) {\n const { translate } = await resolveTranslations()\n try {\n const query = querySchema.parse(Object.fromEntries(new URL(request.url).searchParams))\n const { container, em, auth, scope: organizationScope } = await resolveCustomersRequestContext(request)\n\n const orgFilter = resolveOrganizationScopeFilter(organizationScope, auth)\n\n const actorId = resolveAuthActorId(auth)\n const rbacService = container.resolve('rbacService') as RbacService | undefined\n const scope = { tenantId: auth.tenantId, organizationId: orgFilter.rbacOrganizationId }\n const hasAccess = await canAccessAssignableStaff(rbacService, actorId, scope)\n if (!hasAccess) {\n throw new CrudHttpError(\n 403,\n {\n error: translate(\n 'customers.assignableStaff.forbidden',\n 'Insufficient permissions to load assignable staff.',\n ),\n },\n )\n }\n\n const normalizedSearch = query.search?.trim().toLowerCase() ?? ''\n\n const members = await findWithDecryption(\n em,\n StaffTeamMember,\n {\n tenantId: auth.tenantId,\n ...orgFilter.where,\n deletedAt: null,\n isActive: true,\n },\n { orderBy: { displayName: 'asc' } },\n scope,\n )\n\n const userIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.userId === 'string' && member.userId.trim().length > 0 ? member.userId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n const teamIds = Array.from(\n new Set(\n members\n .map((member) => (typeof member.teamId === 'string' && member.teamId.trim().length > 0 ? member.teamId : null))\n .filter((value): value is string => typeof value === 'string'),\n ),\n )\n\n const [users, teams] = await Promise.all([\n userIds.length > 0\n ? findWithDecryption(\n em,\n User,\n {\n id: { $in: userIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n ...orgFilter.where,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n teamIds.length > 0\n ? findWithDecryption(\n em,\n StaffTeam,\n {\n id: { $in: teamIds },\n deletedAt: null,\n tenantId: auth.tenantId,\n ...orgFilter.where,\n },\n undefined,\n scope,\n )\n : Promise.resolve([]),\n ])\n\n const userById = new Map(\n users.map((user) => [\n user.id,\n {\n id: user.id,\n email: user.email ?? null,\n },\n ]),\n )\n const teamById = new Map(\n teams.map((team) => [\n team.id,\n {\n id: team.id,\n name: team.name ?? null,\n },\n ]),\n )\n\n const items = members\n .filter((member) => typeof member.userId === 'string' && member.userId.trim().length > 0)\n .map((member) => {\n const userId = member.userId as string\n const user = userById.get(userId) ?? { id: userId, email: null }\n const team = member.teamId ? teamById.get(member.teamId) ?? null : null\n return {\n id: member.id,\n teamMemberId: member.id,\n userId,\n displayName: member.displayName?.trim() || user.email || userId,\n email: user.email,\n teamName: team?.name ?? null,\n user,\n team,\n }\n })\n .filter((item) => {\n if (!normalizedSearch) return true\n const haystack = [item.displayName, item.email, item.teamName]\n .filter((value): value is string => typeof value === 'string' && value.length > 0)\n .join(' ')\n .toLowerCase()\n return haystack.includes(normalizedSearch)\n })\n\n const deduped = Array.from(\n items.reduce((acc, item) => {\n if (!acc.has(item.userId)) {\n acc.set(item.userId, item)\n }\n return acc\n }, new Map<string, (typeof items)[number]>()),\n ).map(([, item]) => item)\n\n const start = (query.page - 1) * query.pageSize\n return NextResponse.json({\n items: deduped.slice(start, start + query.pageSize),\n total: deduped.length,\n page: query.page,\n pageSize: query.pageSize,\n })\n } catch (error) {\n if (isCrudHttpError(error)) {\n return NextResponse.json(error.body, { status: error.status })\n }\n if (error instanceof z.ZodError) {\n return NextResponse.json({ error: translate('customers.errors.validationFailed', 'Validation failed') }, { status: 400 })\n }\n console.error('staff.assignable-team-members.get failed', error)\n return NextResponse.json({ error: translate('customers.errors.assignable_staff_load_failed', 'Failed to load assignable staff') }, { status: 500 })\n }\n}\n\nexport const openApi: OpenApiRouteDoc = {\n tag: 'Staff',\n summary: 'Assignable staff candidates',\n methods: {\n GET: {\n summary: 'List staff members that can be assigned from customer flows',\n query: querySchema,\n description:\n 'Returns active staff members linked to auth users. Access requires either customers.roles.manage or customers.activities.manage. Owned by the staff module; consumed from customer flows via this canonical URL. Replaces the deprecated /api/customers/assignable-staff route.',\n responses: [\n {\n status: 200,\n description: 'Assignable staff members',\n schema: pagedListSchema,\n },\n ],\n errors: [\n { status: 400, description: 'Invalid request', schema: errorSchema },\n { status: 401, description: 'Unauthorized', schema: errorSchema },\n { status: 403, description: 'Forbidden', schema: errorSchema },\n ],\n },\n },\n}\n"],
5
+ "mappings": "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",
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.6404.1.e2f8d5025a",
3
+ "version": "0.6.6-develop.6407.1.9a4c26cf6a",
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.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",
251
+ "@open-mercato/ai-assistant": "0.6.6-develop.6407.1.9a4c26cf6a",
252
+ "@open-mercato/shared": "0.6.6-develop.6407.1.9a4c26cf6a",
253
+ "@open-mercato/ui": "0.6.6-develop.6407.1.9a4c26cf6a",
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.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",
258
+ "@open-mercato/ai-assistant": "0.6.6-develop.6407.1.9a4c26cf6a",
259
+ "@open-mercato/shared": "0.6.6-develop.6407.1.9a4c26cf6a",
260
+ "@open-mercato/ui": "0.6.6-develop.6407.1.9a4c26cf6a",
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",
@@ -259,7 +259,18 @@ export async function GET(req: Request) {
259
259
  export async function POST(req: Request) {
260
260
  const { t } = await resolveTranslations()
261
261
  const auth = await getAuthFromRequest(req)
262
- if (!auth || !auth.tenantId || !auth.orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
262
+ if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
263
+ // A superadmin browsing with "All organizations" selected has no concrete
264
+ // organization scope, but an attachment must be stored under exactly one
265
+ // organization (the scope invariant forbids partial-null rows). Reject with a
266
+ // clear, actionable error instead of a 401 — a 401 makes the client-side
267
+ // fetch layer treat it as session expiry, show a misleading toast, and reset
268
+ // the in-progress form (#3764).
269
+ if (!auth.orgId) {
270
+ return NextResponse.json({
271
+ error: t('attachments.errors.selectOrganization', 'Select a specific organization before uploading an attachment.'),
272
+ }, { status: 400 })
273
+ }
263
274
  const tenantId = auth.tenantId
264
275
  const orgId = auth.orgId
265
276
 
@@ -527,7 +538,7 @@ async function readTenantAttachmentUsageBytes(em: EntityManager, tenantId: strin
527
538
 
528
539
  export async function DELETE(req: Request) {
529
540
  const auth = await getAuthFromRequest(req)
530
- if (!auth || !auth.tenantId || !auth.orgId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
541
+ if (!auth || !auth.tenantId || (!auth.orgId && !auth.isSuperAdmin)) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
531
542
  const url = new URL(req.url)
532
543
  const id = url.searchParams.get('id') || ''
533
544
  if (!id) return NextResponse.json({ error: 'Attachment id is required' }, { status: 400 })
@@ -536,7 +547,12 @@ export async function DELETE(req: Request) {
536
547
  const dataEngine = resolve('dataEngine')
537
548
  const storageDriverFactory =
538
549
  (resolve('storageDriverFactory') as StorageDriverFactory | null) ?? new StorageDriverFactory(em)
539
- const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId!, organizationId: auth.orgId }
550
+ // Mirror the GET handler's superadmin bypass: a superadmin browsing with "All
551
+ // organizations" selected has no concrete org, so scope the delete by tenant
552
+ // only and let them remove any attachment in the tenant. Non-superadmins stay
553
+ // scoped to their own organization (#3764).
554
+ const deleteFilter: Record<string, unknown> = { id, tenantId: auth.tenantId! }
555
+ if (auth.orgId) deleteFilter.organizationId = auth.orgId
540
556
  const record = await em.findOne(Attachment, deleteFilter)
541
557
  if (!record) return NextResponse.json({ error: 'Attachment not found' }, { status: 404 })
542
558
  await em.remove(record).flush()
@@ -546,7 +562,7 @@ export async function DELETE(req: Request) {
546
562
  if (record.storagePath) {
547
563
  const delDriver = await storageDriverFactory.resolveForPartition(record.partitionCode, {
548
564
  tenantId: record.tenantId ?? auth.tenantId!,
549
- organizationId: record.organizationId ?? auth.orgId,
565
+ organizationId: record.organizationId ?? auth.orgId ?? '',
550
566
  })
551
567
  await delDriver.delete(record.partitionCode, record.storagePath)
552
568
  }
@@ -5,6 +5,7 @@
5
5
  "attachments.errors.maxUploadSize": "Der Anhang überschreitet die maximal zulässige Upload-Größe.",
6
6
  "attachments.errors.publicPartitionBlocked": "Öffentliche Speicherpartitionen können für diesen Upload nicht explizit ausgewählt werden.",
7
7
  "attachments.errors.quotaExceeded": "Das Speicherlimit für Anhänge dieses Tenants wurde überschritten.",
8
+ "attachments.errors.selectOrganization": "Wählen Sie eine bestimmte Organisation aus, bevor Sie einen Anhang hochladen.",
8
9
  "attachments.library.actions.copied": "Link kopiert.",
9
10
  "attachments.library.actions.copyError": "Link konnte nicht kopiert werden.",
10
11
  "attachments.library.actions.copyUrl": "URL kopieren",
@@ -5,6 +5,7 @@
5
5
  "attachments.errors.maxUploadSize": "Attachment exceeds the maximum upload size.",
6
6
  "attachments.errors.publicPartitionBlocked": "Public storage partitions cannot be selected explicitly for this upload.",
7
7
  "attachments.errors.quotaExceeded": "Attachment storage quota exceeded for this tenant.",
8
+ "attachments.errors.selectOrganization": "Select a specific organization before uploading an attachment.",
8
9
  "attachments.library.actions.copied": "Link copied.",
9
10
  "attachments.library.actions.copyError": "Unable to copy link.",
10
11
  "attachments.library.actions.copyUrl": "Copy URL",
@@ -5,6 +5,7 @@
5
5
  "attachments.errors.maxUploadSize": "El archivo adjunto supera el tamaño máximo de carga permitido.",
6
6
  "attachments.errors.publicPartitionBlocked": "No se pueden seleccionar explícitamente particiones de almacenamiento públicas para esta carga.",
7
7
  "attachments.errors.quotaExceeded": "Se superó la cuota de almacenamiento de adjuntos para este tenant.",
8
+ "attachments.errors.selectOrganization": "Selecciona una organización específica antes de subir un adjunto.",
8
9
  "attachments.library.actions.copied": "Enlace copiado.",
9
10
  "attachments.library.actions.copyError": "No se pudo copiar el enlace.",
10
11
  "attachments.library.actions.copyUrl": "Copiar URL",
@@ -5,6 +5,7 @@
5
5
  "attachments.errors.maxUploadSize": "Załącznik przekracza maksymalny dopuszczalny rozmiar przesyłania.",
6
6
  "attachments.errors.publicPartitionBlocked": "Nie można jawnie wybrać publicznej partycji do tego przesyłania.",
7
7
  "attachments.errors.quotaExceeded": "Przekroczono limit miejsca na załączniki dla tego tenantu.",
8
+ "attachments.errors.selectOrganization": "Wybierz konkretną organizację przed przesłaniem załącznika.",
8
9
  "attachments.library.actions.copied": "Link skopiowany.",
9
10
  "attachments.library.actions.copyError": "Nie można skopiować linku.",
10
11
  "attachments.library.actions.copyUrl": "Kopiuj URL",
@@ -0,0 +1,165 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
4
+ import { getAuthFromRequest } from '@open-mercato/shared/lib/auth/server'
5
+ import { CrudHttpError, isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
6
+ import {
7
+ runCrudMutationGuardAfterSuccess,
8
+ validateCrudMutationGuard,
9
+ } from '@open-mercato/shared/lib/crud/mutation-guard'
10
+ import type { ModuleConfigService } from '@open-mercato/core/modules/configs/lib/module-config-service'
11
+ import type { OpenApiMethodDoc, OpenApiRouteDoc } from '@open-mercato/shared/lib/openapi'
12
+ import {
13
+ CATALOG_SETTINGS_MODULE_ID,
14
+ UNIT_PRICE_DISPLAY_ENABLED_DEFAULT,
15
+ UNIT_PRICE_DISPLAY_ENABLED_KEY,
16
+ } from '../../lib/settings'
17
+
18
+ export const metadata = {
19
+ GET: { requireAuth: true, requireFeatures: ['catalog.products.view'] },
20
+ PUT: { requireAuth: true, requireFeatures: ['catalog.settings.manage'] },
21
+ }
22
+
23
+ const bodySchema = z.object({
24
+ unitPriceDisplayEnabled: z.boolean(),
25
+ })
26
+
27
+ const responseSchema = z.object({
28
+ unitPriceDisplayEnabled: z.boolean(),
29
+ })
30
+
31
+ type SettingsContext = {
32
+ container: Awaited<ReturnType<typeof createRequestContainer>>
33
+ auth: NonNullable<Awaited<ReturnType<typeof getAuthFromRequest>>>
34
+ tenantId: string
35
+ organizationId: string | null
36
+ actorId: string
37
+ }
38
+
39
+ async function resolveSettingsContext(req: Request): Promise<SettingsContext> {
40
+ const container = await createRequestContainer()
41
+ const auth = await getAuthFromRequest(req)
42
+ if (!auth || !auth.tenantId) {
43
+ throw new CrudHttpError(401, { error: 'Unauthorized' })
44
+ }
45
+ const actorId =
46
+ (typeof auth.sub === 'string' && auth.sub.trim().length > 0 && auth.sub) ||
47
+ (typeof auth.userId === 'string' && auth.userId.trim().length > 0 && auth.userId) ||
48
+ (typeof auth.keyId === 'string' && auth.keyId.trim().length > 0 && auth.keyId) ||
49
+ 'system'
50
+ return {
51
+ container,
52
+ auth,
53
+ tenantId: auth.tenantId,
54
+ organizationId: auth.orgId ?? null,
55
+ actorId,
56
+ }
57
+ }
58
+
59
+ async function readUnitPriceDisplayEnabled(context: SettingsContext): Promise<boolean> {
60
+ const configService = context.container.resolve('moduleConfigService') as ModuleConfigService
61
+ const value = await configService.getValue<boolean>(
62
+ CATALOG_SETTINGS_MODULE_ID,
63
+ UNIT_PRICE_DISPLAY_ENABLED_KEY,
64
+ { defaultValue: UNIT_PRICE_DISPLAY_ENABLED_DEFAULT, scope: { tenantId: context.tenantId } },
65
+ )
66
+ return typeof value === 'boolean' ? value : UNIT_PRICE_DISPLAY_ENABLED_DEFAULT
67
+ }
68
+
69
+ async function GET(req: Request) {
70
+ try {
71
+ const context = await resolveSettingsContext(req)
72
+ const unitPriceDisplayEnabled = await readUnitPriceDisplayEnabled(context)
73
+ return NextResponse.json({ unitPriceDisplayEnabled })
74
+ } catch (err) {
75
+ if (isCrudHttpError(err)) {
76
+ return NextResponse.json(err.body, { status: err.status })
77
+ }
78
+ console.error('[catalog/settings.GET] Unexpected error', err)
79
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
80
+ }
81
+ }
82
+
83
+ async function PUT(req: Request) {
84
+ try {
85
+ const context = await resolveSettingsContext(req)
86
+ const body = bodySchema.parse(await req.json())
87
+
88
+ const guardResult = await validateCrudMutationGuard(context.container, {
89
+ tenantId: context.tenantId,
90
+ organizationId: context.organizationId,
91
+ userId: context.actorId,
92
+ resourceKind: 'catalog.settings',
93
+ resourceId: UNIT_PRICE_DISPLAY_ENABLED_KEY,
94
+ operation: 'custom',
95
+ requestMethod: req.method,
96
+ requestHeaders: req.headers,
97
+ mutationPayload: { unitPriceDisplayEnabled: body.unitPriceDisplayEnabled },
98
+ })
99
+ if (guardResult && !guardResult.ok) {
100
+ return NextResponse.json(guardResult.body, { status: guardResult.status })
101
+ }
102
+
103
+ const configService = context.container.resolve('moduleConfigService') as ModuleConfigService
104
+ await configService.setValue(
105
+ CATALOG_SETTINGS_MODULE_ID,
106
+ UNIT_PRICE_DISPLAY_ENABLED_KEY,
107
+ body.unitPriceDisplayEnabled,
108
+ { tenantId: context.tenantId },
109
+ )
110
+
111
+ if (guardResult?.ok && guardResult.shouldRunAfterSuccess) {
112
+ await runCrudMutationGuardAfterSuccess(context.container, {
113
+ tenantId: context.tenantId,
114
+ organizationId: context.organizationId,
115
+ userId: context.actorId,
116
+ resourceKind: 'catalog.settings',
117
+ resourceId: UNIT_PRICE_DISPLAY_ENABLED_KEY,
118
+ operation: 'custom',
119
+ requestMethod: req.method,
120
+ requestHeaders: req.headers,
121
+ metadata: guardResult.metadata ?? null,
122
+ })
123
+ }
124
+
125
+ return NextResponse.json({ unitPriceDisplayEnabled: body.unitPriceDisplayEnabled })
126
+ } catch (err) {
127
+ if (isCrudHttpError(err)) {
128
+ return NextResponse.json(err.body, { status: err.status })
129
+ }
130
+ if (err instanceof z.ZodError) {
131
+ return NextResponse.json({ error: 'Invalid request', details: err.issues }, { status: 400 })
132
+ }
133
+ console.error('[catalog/settings.PUT] Unexpected error', err)
134
+ return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
135
+ }
136
+ }
137
+
138
+ const getDoc: OpenApiMethodDoc = {
139
+ summary: 'Read catalog settings',
140
+ tags: ['Catalog'],
141
+ responses: [
142
+ { status: 200, description: 'Catalog settings', schema: responseSchema },
143
+ ],
144
+ }
145
+
146
+ const putDoc: OpenApiMethodDoc = {
147
+ summary: 'Update catalog settings',
148
+ tags: ['Catalog'],
149
+ requestBody: { schema: bodySchema },
150
+ responses: [
151
+ { status: 200, description: 'Updated catalog settings', schema: responseSchema },
152
+ ],
153
+ errors: [{ status: 400, description: 'Invalid request body' }],
154
+ }
155
+
156
+ export const openApi: OpenApiRouteDoc = {
157
+ tag: 'Catalog',
158
+ summary: 'Catalog module settings',
159
+ methods: {
160
+ GET: getDoc,
161
+ PUT: putDoc,
162
+ },
163
+ }
164
+
165
+ export { GET, PUT }
@@ -1,11 +1,13 @@
1
1
  import { Page, PageBody } from '@open-mercato/ui/backend/Page'
2
2
  import { PriceKindSettings } from '../../../components/PriceKindSettings'
3
+ import { UnitPriceDisplaySettings } from '../../../components/UnitPriceDisplaySettings'
3
4
 
4
5
  export default function CatalogConfigurationPage() {
5
6
  return (
6
7
  <Page>
7
8
  <PageBody className="space-y-8">
8
9
  <PriceKindSettings />
10
+ <UnitPriceDisplaySettings />
9
11
  </PageBody>
10
12
  </Page>
11
13
  )
@@ -0,0 +1,106 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { SwitchField } from '@open-mercato/ui/primitives/switch-field'
5
+ import { Spinner } from '@open-mercato/ui/primitives/spinner'
6
+ import { flash } from '@open-mercato/ui/backend/FlashMessages'
7
+ import { apiCall, readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
8
+ import { raiseCrudError } from '@open-mercato/ui/backend/utils/serverErrors'
9
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
10
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
11
+
12
+ type CatalogSettingsResponse = {
13
+ unitPriceDisplayEnabled?: boolean
14
+ }
15
+
16
+ export function UnitPriceDisplaySettings() {
17
+ const t = useT()
18
+ const scopeVersion = useOrganizationScopeVersion()
19
+ const [enabled, setEnabled] = React.useState<boolean | null>(null)
20
+ const [saving, setSaving] = React.useState(false)
21
+
22
+ const loadError = t('catalog.settings.unitPriceDisplay.errors.load', 'Failed to load catalog settings.')
23
+
24
+ const load = React.useCallback(async () => {
25
+ try {
26
+ const payload = await readApiResultOrThrow<CatalogSettingsResponse>(
27
+ '/api/catalog/settings',
28
+ undefined,
29
+ { errorMessage: loadError, fallback: { unitPriceDisplayEnabled: true } },
30
+ )
31
+ setEnabled(payload.unitPriceDisplayEnabled !== false)
32
+ } catch (err) {
33
+ console.error('catalog.settings.load failed', err)
34
+ flash(loadError, 'error')
35
+ setEnabled(true)
36
+ }
37
+ }, [loadError])
38
+
39
+ React.useEffect(() => {
40
+ load().catch(() => {})
41
+ }, [load, scopeVersion])
42
+
43
+ const handleChange = React.useCallback(async (next: boolean) => {
44
+ const previous = enabled
45
+ setEnabled(next)
46
+ setSaving(true)
47
+ try {
48
+ // optimistic-lock-exempt: tenant-scoped module-config toggle (single
49
+ // settings row via ModuleConfigService), not a versioned editable entity —
50
+ // no updated_at to lock against and no lost-update concern for this admin preference.
51
+ const call = await apiCall('/api/catalog/settings', {
52
+ method: 'PUT',
53
+ headers: { 'content-type': 'application/json' },
54
+ body: JSON.stringify({ unitPriceDisplayEnabled: next }),
55
+ })
56
+ if (!call.ok) {
57
+ await raiseCrudError(call.response, t('catalog.settings.unitPriceDisplay.errors.save', 'Failed to save catalog settings.'))
58
+ }
59
+ flash(t('catalog.settings.messages.saved', 'Settings saved.'), 'success')
60
+ } catch (err) {
61
+ console.error('catalog.settings.save failed', err)
62
+ setEnabled(previous)
63
+ const message = err instanceof Error
64
+ ? err.message
65
+ : t('catalog.settings.unitPriceDisplay.errors.save', 'Failed to save catalog settings.')
66
+ flash(message, 'error')
67
+ } finally {
68
+ setSaving(false)
69
+ }
70
+ }, [enabled, t])
71
+
72
+ return (
73
+ <section className="border bg-card text-card-foreground shadow-sm">
74
+ <div className="border-b px-6 py-4 space-y-1">
75
+ <h2 className="text-lg font-semibold">
76
+ {t('catalog.settings.unitPriceDisplay.title', 'Unit price presentation')}
77
+ </h2>
78
+ <p className="text-sm text-muted-foreground">
79
+ {t(
80
+ 'catalog.settings.unitPriceDisplay.description',
81
+ 'Controls whether the EU unit price presentation feature is available on the product form.',
82
+ )}
83
+ </p>
84
+ </div>
85
+ <div className="px-6 py-4">
86
+ {enabled === null ? (
87
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
88
+ <Spinner className="h-4 w-4" />
89
+ {t('catalog.settings.loading', 'Loading…')}
90
+ </div>
91
+ ) : (
92
+ <SwitchField
93
+ label={t('catalog.settings.unitPriceDisplay.toggleLabel', 'Enable EU unit price presentation')}
94
+ description={t(
95
+ 'catalog.settings.unitPriceDisplay.toggleDescription',
96
+ 'When off, the EU unit price settings are hidden from every product form. Turn this off for manufacturers or other tenants that do not sell to consumers.',
97
+ )}
98
+ checked={enabled}
99
+ disabled={saving}
100
+ onCheckedChange={(next) => { void handleChange(next) }}
101
+ />
102
+ )}
103
+ </div>
104
+ </section>
105
+ )
106
+ }
@@ -23,6 +23,7 @@ import type {
23
23
  import { createProductUnitConversionDraft } from "./productForm";
24
24
  import { REFERENCE_UNIT_CODES } from "@open-mercato/shared/lib/units/unitCodes";
25
25
  import { toTrimmedOrNull } from "./productFormUtils";
26
+ import { useUnitPriceDisplayEnabled } from "./hooks/useUnitPriceDisplayEnabled";
26
27
 
27
28
  type UnitDictionaryEntry = {
28
29
  id?: string;
@@ -134,6 +135,7 @@ export function ProductUomSection({
134
135
  embedded = false,
135
136
  }: ProductUomSectionProps) {
136
137
  const t = useT();
138
+ const { enabled: unitPriceDisplayEnabled } = useUnitPriceDisplayEnabled();
137
139
  const [unitOptions, setUnitOptions] = React.useState<UnitOption[]>([]);
138
140
  const [loadingUnits, setLoadingUnits] = React.useState(false);
139
141
  const [errorLoadingUnits, setErrorLoadingUnits] = React.useState(false);
@@ -473,6 +475,7 @@ export function ProductUomSection({
473
475
  </div>
474
476
  </div>
475
477
 
478
+ {unitPriceDisplayEnabled ? (
476
479
  <div className="space-y-3">
477
480
  <div className="flex items-center gap-2">
478
481
  <Checkbox
@@ -564,6 +567,7 @@ export function ProductUomSection({
564
567
  </p>
565
568
  ) : null}
566
569
  </div>
570
+ ) : null}
567
571
 
568
572
  <div className="space-y-3">
569
573
  <div className="flex items-center justify-between gap-2">
@@ -0,0 +1,39 @@
1
+ "use client"
2
+
3
+ import { useQuery } from '@tanstack/react-query'
4
+ import { readApiResultOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
5
+
6
+ type CatalogSettingsResponse = {
7
+ unitPriceDisplayEnabled?: boolean
8
+ }
9
+
10
+ export type UseUnitPriceDisplayEnabledResult = {
11
+ enabled: boolean
12
+ isLoading: boolean
13
+ }
14
+
15
+ /**
16
+ * Resolves the tenant-scoped catalog setting that controls whether the EU unit
17
+ * price presentation feature is available on the product form. Defaults to
18
+ * enabled while loading (and on failure) so the common retail case is never
19
+ * hidden by a transient error.
20
+ */
21
+ export function useUnitPriceDisplayEnabled(): UseUnitPriceDisplayEnabledResult {
22
+ const query = useQuery({
23
+ queryKey: ['catalog', 'settings', 'unitPriceDisplayEnabled'],
24
+ queryFn: async () => {
25
+ const result = await readApiResultOrThrow<CatalogSettingsResponse>(
26
+ '/api/catalog/settings',
27
+ undefined,
28
+ { errorMessage: 'Failed to load catalog settings.', fallback: { unitPriceDisplayEnabled: true } },
29
+ )
30
+ return result.unitPriceDisplayEnabled !== false
31
+ },
32
+ staleTime: 60_000,
33
+ })
34
+
35
+ return {
36
+ enabled: query.data ?? true,
37
+ isLoading: query.isLoading,
38
+ }
39
+ }
@@ -614,6 +614,14 @@
614
614
  "catalog.search.priceKind.promotion": "Aktion",
615
615
  "catalog.search.status.inactive": "Inaktiv",
616
616
  "catalog.search.variant.default": "Standard",
617
+ "catalog.settings.loading": "Wird geladen…",
618
+ "catalog.settings.messages.saved": "Einstellungen gespeichert.",
619
+ "catalog.settings.unitPriceDisplay.description": "Legt fest, ob die EU-Grundpreisdarstellung im Produktformular verfügbar ist.",
620
+ "catalog.settings.unitPriceDisplay.errors.load": "Katalogeinstellungen konnten nicht geladen werden.",
621
+ "catalog.settings.unitPriceDisplay.errors.save": "Katalogeinstellungen konnten nicht gespeichert werden.",
622
+ "catalog.settings.unitPriceDisplay.title": "Grundpreisdarstellung",
623
+ "catalog.settings.unitPriceDisplay.toggleDescription": "Wenn deaktiviert, werden die EU-Grundpreiseinstellungen in jedem Produktformular ausgeblendet. Deaktivieren Sie dies für Hersteller oder andere Mandanten, die nicht an Verbraucher verkaufen.",
624
+ "catalog.settings.unitPriceDisplay.toggleLabel": "EU-Grundpreisdarstellung aktivieren",
617
625
  "catalog.stats.active": "Aktiv",
618
626
  "catalog.stats.categories": "Kategorien",
619
627
  "catalog.stats.products": "Produkte",
@@ -614,6 +614,14 @@
614
614
  "catalog.search.priceKind.promotion": "Promotion",
615
615
  "catalog.search.status.inactive": "Inactive",
616
616
  "catalog.search.variant.default": "Default",
617
+ "catalog.settings.loading": "Loading…",
618
+ "catalog.settings.messages.saved": "Settings saved.",
619
+ "catalog.settings.unitPriceDisplay.description": "Controls whether the EU unit price presentation feature is available on the product form.",
620
+ "catalog.settings.unitPriceDisplay.errors.load": "Failed to load catalog settings.",
621
+ "catalog.settings.unitPriceDisplay.errors.save": "Failed to save catalog settings.",
622
+ "catalog.settings.unitPriceDisplay.title": "Unit price presentation",
623
+ "catalog.settings.unitPriceDisplay.toggleDescription": "When off, the EU unit price settings are hidden from every product form. Turn this off for manufacturers or other tenants that do not sell to consumers.",
624
+ "catalog.settings.unitPriceDisplay.toggleLabel": "Enable EU unit price presentation",
617
625
  "catalog.stats.active": "Active",
618
626
  "catalog.stats.categories": "Categories",
619
627
  "catalog.stats.products": "Products",