@dainamite/cpq 0.6.0 → 0.7.0

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 (164) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js +3 -9
  3. package/dist/modules/cpq/backend/cpq/inventory/AssetsTable.js.map +2 -2
  4. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js +21 -25
  5. package/dist/modules/cpq/backend/cpq/inventory/SubscriptionsTable.js.map +2 -2
  6. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js +48 -47
  7. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.js.map +2 -2
  8. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js +1 -1
  9. package/dist/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.js.map +2 -2
  10. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js +2 -2
  11. package/dist/modules/cpq/backend/cpq/inventory/page.meta.js.map +2 -2
  12. package/dist/modules/cpq/backend/cpq/inventory/shared.js +37 -2
  13. package/dist/modules/cpq/backend/cpq/inventory/shared.js.map +2 -2
  14. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js +154 -178
  15. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.js.map +2 -2
  16. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js +1 -1
  17. package/dist/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.js.map +2 -2
  18. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js +168 -143
  19. package/dist/modules/cpq/backend/cpq/offerings/[id]/page.js.map +2 -2
  20. package/dist/modules/cpq/backend/cpq/offerings/page.js +36 -34
  21. package/dist/modules/cpq/backend/cpq/offerings/page.js.map +2 -2
  22. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js +69 -66
  23. package/dist/modules/cpq/backend/cpq/orders/[id]/page.js.map +2 -2
  24. package/dist/modules/cpq/backend/cpq/orders/page.js +6 -6
  25. package/dist/modules/cpq/backend/cpq/orders/page.js.map +2 -2
  26. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js +41 -36
  27. package/dist/modules/cpq/backend/cpq/price-rules/PriceRuleForm.js.map +2 -2
  28. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js +76 -72
  29. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.js.map +2 -2
  30. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js +1 -1
  31. package/dist/modules/cpq/backend/cpq/price-rules/[id]/page.meta.js.map +2 -2
  32. package/dist/modules/cpq/backend/cpq/price-rules/page.js +18 -15
  33. package/dist/modules/cpq/backend/cpq/price-rules/page.js.map +2 -2
  34. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js +23 -21
  35. package/dist/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.js.map +2 -2
  36. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js +22 -50
  37. package/dist/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.js.map +2 -2
  38. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js +74 -52
  39. package/dist/modules/cpq/backend/cpq/pricing/[id]/page.js.map +2 -2
  40. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js +20 -17
  41. package/dist/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.js.map +2 -2
  42. package/dist/modules/cpq/backend/cpq/pricing/page.js +1 -1
  43. package/dist/modules/cpq/backend/cpq/pricing/page.js.map +2 -2
  44. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js +115 -123
  45. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.js.map +2 -2
  46. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js +6 -3
  47. package/dist/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.js.map +2 -2
  48. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js +129 -94
  49. package/dist/modules/cpq/backend/cpq/quotes/[id]/page.js.map +2 -2
  50. package/dist/modules/cpq/backend/cpq/quotes/page.js +23 -19
  51. package/dist/modules/cpq/backend/cpq/quotes/page.js.map +2 -2
  52. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js +19 -15
  53. package/dist/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.js.map +2 -2
  54. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js +171 -178
  55. package/dist/modules/cpq/backend/cpq/specifications/[id]/page.js.map +2 -2
  56. package/dist/modules/cpq/backend/cpq/specifications/page.js +2 -2
  57. package/dist/modules/cpq/backend/cpq/specifications/page.js.map +2 -2
  58. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js +60 -50
  59. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.js.map +2 -2
  60. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js +22 -18
  61. package/dist/modules/cpq/backend/cpq/wizards/[definitionCode]/page.js.map +2 -2
  62. package/dist/modules/cpq/backend/cpq/wizards/page.js +1 -1
  63. package/dist/modules/cpq/backend/cpq/wizards/page.js.map +2 -2
  64. package/dist/modules/cpq/components/ArcLineDiff.js +2 -2
  65. package/dist/modules/cpq/components/ArcLineDiff.js.map +2 -2
  66. package/dist/modules/cpq/components/ChargeBreakdownTable.js +4 -10
  67. package/dist/modules/cpq/components/ChargeBreakdownTable.js.map +2 -2
  68. package/dist/modules/cpq/components/PriceWaterfall.js +3 -1
  69. package/dist/modules/cpq/components/PriceWaterfall.js.map +2 -2
  70. package/dist/modules/cpq/components/PricingSummaryCard.js +1 -6
  71. package/dist/modules/cpq/components/PricingSummaryCard.js.map +2 -2
  72. package/dist/modules/cpq/components/StatusPath.js +5 -3
  73. package/dist/modules/cpq/components/StatusPath.js.map +2 -2
  74. package/dist/modules/cpq/components/useCpqRowActions.js +10 -22
  75. package/dist/modules/cpq/components/useCpqRowActions.js.map +2 -2
  76. package/dist/modules/cpq/i18n/de.json +1220 -0
  77. package/dist/modules/cpq/i18n/en.json +1220 -0
  78. package/dist/modules/cpq/i18n/es.json +1220 -0
  79. package/dist/modules/cpq/i18n/pl.json +1220 -0
  80. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js +39 -31
  81. package/dist/modules/cpq/widgets/injection/customer-inventory/widget.client.js.map +2 -2
  82. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js +13 -5
  83. package/dist/modules/cpq/widgets/injection/order-cpq-tab/widget.client.js.map +2 -2
  84. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js +41 -21
  85. package/dist/modules/cpq/widgets/injection/quote-configurator/widget.client.js.map +2 -2
  86. package/dist/modules/cpq/widgets/injection-table.js +2 -2
  87. package/dist/modules/cpq/widgets/injection-table.js.map +2 -2
  88. package/dist/modules/cpq/workflows/WizardRunner.js +10 -8
  89. package/dist/modules/cpq/workflows/WizardRunner.js.map +2 -2
  90. package/dist/modules/cpq/workflows/WizardTrigger.js +15 -9
  91. package/dist/modules/cpq/workflows/WizardTrigger.js.map +2 -2
  92. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js +9 -7
  93. package/dist/modules/cpq/workflows/steps/CustomerSelectStep.js.map +2 -2
  94. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js +9 -4
  95. package/dist/modules/cpq/workflows/steps/InventorySelectStep.js.map +2 -2
  96. package/dist/modules/cpq/workflows/steps/ItemListStep.js +16 -19
  97. package/dist/modules/cpq/workflows/steps/ItemListStep.js.map +2 -2
  98. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js +19 -32
  99. package/dist/modules/cpq/workflows/steps/OfferingSelectStep.js.map +2 -2
  100. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js +8 -6
  101. package/dist/modules/cpq/workflows/steps/ProductConfigureStep.js.map +2 -2
  102. package/dist/modules/cpq/workflows/steps/ReviewStep.js +32 -30
  103. package/dist/modules/cpq/workflows/steps/ReviewStep.js.map +2 -2
  104. package/dist/modules/cpq/workflows/steps/index.js +12 -0
  105. package/dist/modules/cpq/workflows/steps/index.js.map +2 -2
  106. package/package.json +1 -1
  107. package/src/modules/cpq/__tests__/i18n/dictionaries.test.ts +62 -0
  108. package/src/modules/cpq/__tests__/i18n/hardcodedStrings.test.ts +35 -0
  109. package/src/modules/cpq/__tests__/i18n/sourceScan.ts +109 -0
  110. package/src/modules/cpq/backend/cpq/inventory/AssetsTable.tsx +3 -9
  111. package/src/modules/cpq/backend/cpq/inventory/SubscriptionsTable.tsx +22 -26
  112. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.meta.ts +1 -1
  113. package/src/modules/cpq/backend/cpq/inventory/assets/[id]/page.tsx +43 -46
  114. package/src/modules/cpq/backend/cpq/inventory/page.meta.ts +2 -2
  115. package/src/modules/cpq/backend/cpq/inventory/shared.tsx +37 -1
  116. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.meta.ts +1 -1
  117. package/src/modules/cpq/backend/cpq/inventory/subscriptions/[id]/page.tsx +134 -112
  118. package/src/modules/cpq/backend/cpq/offerings/[id]/page.tsx +169 -130
  119. package/src/modules/cpq/backend/cpq/offerings/page.tsx +34 -24
  120. package/src/modules/cpq/backend/cpq/orders/[id]/page.tsx +66 -43
  121. package/src/modules/cpq/backend/cpq/orders/page.tsx +6 -6
  122. package/src/modules/cpq/backend/cpq/price-rules/PriceRuleForm.tsx +48 -34
  123. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.meta.ts +1 -1
  124. package/src/modules/cpq/backend/cpq/price-rules/[id]/page.tsx +76 -70
  125. package/src/modules/cpq/backend/cpq/price-rules/page.tsx +25 -16
  126. package/src/modules/cpq/backend/cpq/pricing/[id]/EntriesEditor.tsx +24 -18
  127. package/src/modules/cpq/backend/cpq/pricing/[id]/ImportPreview.tsx +29 -19
  128. package/src/modules/cpq/backend/cpq/pricing/[id]/page.tsx +95 -47
  129. package/src/modules/cpq/backend/cpq/pricing/[id]/xlsxImport.ts +23 -15
  130. package/src/modules/cpq/backend/cpq/pricing/page.tsx +1 -1
  131. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/ArcQuoteConfigurator.tsx +111 -88
  132. package/src/modules/cpq/backend/cpq/quotes/[id]/_components/PriceAdjustmentDialog.tsx +3 -3
  133. package/src/modules/cpq/backend/cpq/quotes/[id]/page.tsx +134 -89
  134. package/src/modules/cpq/backend/cpq/quotes/page.tsx +29 -21
  135. package/src/modules/cpq/backend/cpq/specifications/[id]/DependsOnRulesEditor.tsx +19 -15
  136. package/src/modules/cpq/backend/cpq/specifications/[id]/page.tsx +171 -171
  137. package/src/modules/cpq/backend/cpq/specifications/page.tsx +2 -2
  138. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/detail/page.tsx +51 -41
  139. package/src/modules/cpq/backend/cpq/wizards/[definitionCode]/page.tsx +17 -13
  140. package/src/modules/cpq/backend/cpq/wizards/page.tsx +1 -1
  141. package/src/modules/cpq/components/ArcLineDiff.tsx +2 -2
  142. package/src/modules/cpq/components/ChargeBreakdownTable.tsx +4 -2
  143. package/src/modules/cpq/components/PriceWaterfall.tsx +4 -2
  144. package/src/modules/cpq/components/PricingSummaryCard.tsx +1 -1
  145. package/src/modules/cpq/components/StatusPath.tsx +7 -4
  146. package/src/modules/cpq/components/useCpqRowActions.tsx +17 -25
  147. package/src/modules/cpq/i18n/de.json +1220 -0
  148. package/src/modules/cpq/i18n/en.json +1220 -0
  149. package/src/modules/cpq/i18n/es.json +1220 -0
  150. package/src/modules/cpq/i18n/pl.json +1220 -0
  151. package/src/modules/cpq/widgets/injection/customer-inventory/widget.client.tsx +38 -31
  152. package/src/modules/cpq/widgets/injection/order-cpq-tab/widget.client.tsx +7 -5
  153. package/src/modules/cpq/widgets/injection/quote-configurator/widget.client.tsx +36 -21
  154. package/src/modules/cpq/widgets/injection-table.ts +8 -2
  155. package/src/modules/cpq/workflows/WizardRunner.tsx +8 -5
  156. package/src/modules/cpq/workflows/WizardTrigger.tsx +15 -9
  157. package/src/modules/cpq/workflows/steps/CustomerSelectStep.tsx +11 -7
  158. package/src/modules/cpq/workflows/steps/InventorySelectStep.tsx +9 -5
  159. package/src/modules/cpq/workflows/steps/ItemListStep.tsx +18 -12
  160. package/src/modules/cpq/workflows/steps/OfferingSelectStep.tsx +20 -16
  161. package/src/modules/cpq/workflows/steps/ProductConfigureStep.tsx +8 -6
  162. package/src/modules/cpq/workflows/steps/ReviewStep.tsx +33 -18
  163. package/src/modules/cpq/workflows/steps/index.ts +12 -0
  164. package/src/modules/cpq/workflows/types.ts +4 -0
@@ -1,6 +1,6 @@
1
1
  "use client"
2
2
  import * as React from 'react'
3
- import { useT } from '@open-mercato/shared/lib/i18n/context'
3
+ import { useT, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
4
4
  import { useRouter, useParams, useSearchParams } from 'next/navigation'
5
5
  import ArcQuoteConfigurator, {
6
6
  type AttachedTarget,
@@ -169,13 +169,26 @@ function fmt(amount: number, currency: string): string {
169
169
  return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(amount)
170
170
  }
171
171
 
172
- async function apiJson<T>(url: string, init?: RequestInit): Promise<T> {
172
+ async function apiJson<T>(t: TranslateFn, url: string, init?: RequestInit): Promise<T> {
173
173
  const res = await fetch(url, init)
174
174
  const json = await res.json()
175
- if (!res.ok) throw new Error(json?.error ?? `Request failed (${res.status})`)
175
+ if (!res.ok) throw new Error(json?.error ?? t('cpq.common.requestFailed', 'Request failed ({status})', { status: res.status }))
176
176
  return json as T
177
177
  }
178
178
 
179
+ function quoteTypeLabel(t: TranslateFn, quoteType: string): string {
180
+ switch (quoteType) {
181
+ case 'amend':
182
+ return t('cpq.quotes.type.amend', 'amend')
183
+ case 'renew':
184
+ return t('cpq.quotes.type.renew', 'renew')
185
+ case 'cancel':
186
+ return t('cpq.quotes.type.cancel', 'cancel')
187
+ default:
188
+ return quoteType
189
+ }
190
+ }
191
+
179
192
  // ─── Header sub-components ─────────────────────────────────────
180
193
 
181
194
  function MetaCard({ label, children }: { label: string; children: React.ReactNode }) {
@@ -197,6 +210,7 @@ function MoreActionsDropdown({ items }: { items: ActionItem[] }) {
197
210
  // <details>-style trigger; menuActions on FormHeader would render it
198
211
  // inside the header layout, but here we want it adjacent to other
199
212
  // primary buttons.
213
+ const t = useT()
200
214
  const [open, setOpen] = React.useState(false)
201
215
  const ref = React.useRef<HTMLDivElement>(null)
202
216
  React.useEffect(() => {
@@ -212,7 +226,7 @@ function MoreActionsDropdown({ items }: { items: ActionItem[] }) {
212
226
  return (
213
227
  <div className="relative" ref={ref}>
214
228
  <Button type="button" variant="outline" onClick={() => setOpen((v) => !v)}>
215
- Actions
229
+ {t('cpq.quotes.detail.actions', 'Actions')}
216
230
  <svg className="h-3 w-3 ml-1" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
217
231
  <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5" />
218
232
  </svg>
@@ -249,6 +263,7 @@ function MoreActionsDropdown({ items }: { items: ActionItem[] }) {
249
263
  }
250
264
 
251
265
  function buildMoreActions({
266
+ t,
252
267
  recalculate,
253
268
  canRecalculate,
254
269
  submitting,
@@ -256,6 +271,7 @@ function buildMoreActions({
256
271
  onDelete,
257
272
  deleting,
258
273
  }: {
274
+ t: TranslateFn
259
275
  recalculate: () => void
260
276
  canRecalculate: boolean
261
277
  submitting: boolean
@@ -269,7 +285,7 @@ function buildMoreActions({
269
285
  if (canRecalculate) {
270
286
  items.push({
271
287
  id: 'recalculate',
272
- label: 'Recalculate',
288
+ label: t('cpq.quotes.detail.recalculate', 'Recalculate'),
273
289
  onSelect: recalculate,
274
290
  loading: submitting,
275
291
  disabled: submitting,
@@ -278,7 +294,7 @@ function buildMoreActions({
278
294
  if (deletable) {
279
295
  items.push({
280
296
  id: 'delete',
281
- label: 'Delete',
297
+ label: t('cpq.common.delete', 'Delete'),
282
298
  onSelect: onDelete,
283
299
  disabled: deleting,
284
300
  loading: deleting,
@@ -356,11 +372,11 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
356
372
  // ─── Negotiated prices ─────────────────────────────────────────
357
373
  const reloadQuote = React.useCallback(async () => {
358
374
  try {
359
- setCpqQuote(await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqConfigId}`))
375
+ setCpqQuote(await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqConfigId}`))
360
376
  } catch (err) {
361
377
  setError((err as Error).message)
362
378
  }
363
- }, [cpqConfigId])
379
+ }, [cpqConfigId, t])
364
380
  const priceAdjustments = usePriceAdjustments<QuoteResult>({
365
381
  quote: isNew ? null : cpqQuote,
366
382
  quoteType: arcQuoteType,
@@ -377,7 +393,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
377
393
  const quoteId = searchParams.get('quoteId')
378
394
  const customerId = searchParams.get('customerId')
379
395
  if (!customerId) {
380
- setError('customerId is required to create a CPQ quote')
396
+ setError(t('cpq.quotes.detail.customerIdRequired', 'customerId is required to create a CPQ quote'))
381
397
  setLoading(false)
382
398
  return
383
399
  }
@@ -385,7 +401,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
385
401
  try {
386
402
  const body: Record<string, string> = { customerId: customerId! }
387
403
  if (quoteId) body.quoteId = quoteId
388
- const result = await apiJson<QuoteResult>('/api/cpq/quotes', {
404
+ const result = await apiJson<QuoteResult>(t, '/api/cpq/quotes', {
389
405
  method: 'POST',
390
406
  headers: { 'Content-Type': 'application/json' },
391
407
  body: JSON.stringify(body),
@@ -403,7 +419,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
403
419
  let cancelled = false
404
420
  async function load() {
405
421
  try {
406
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqConfigId}`)
422
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqConfigId}`)
407
423
  if (!cancelled) setCpqQuote(result)
408
424
  } catch (err) {
409
425
  if (!cancelled) setError((err as Error).message)
@@ -413,14 +429,14 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
413
429
  }
414
430
  load()
415
431
  return () => { cancelled = true }
416
- }, [cpqConfigId, isNew, searchParams, router])
432
+ }, [cpqConfigId, isNew, searchParams, router, t])
417
433
 
418
434
  // ─── Actions ─────────────────────────────────────────────────
419
435
 
420
436
  const loadOfferings = async () => {
421
437
  setOfferingsLoading(true)
422
438
  try {
423
- const result = await apiJson<{ items: Offering[] }>('/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
439
+ const result = await apiJson<{ items: Offering[] }>(t, '/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
424
440
  setOfferings(result.items ?? [])
425
441
  } catch { setOfferings([]) }
426
442
  finally { setOfferingsLoading(false) }
@@ -430,7 +446,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
430
446
  try {
431
447
  const params = new URLSearchParams({ offeringId })
432
448
  if (Object.keys(currentConfig).length > 0) params.set('configuration', JSON.stringify(currentConfig))
433
- const result = await apiJson<{ attributes: ConstrainedAttribute[] }>(`/api/cpq/quotes/attributes?${params}`)
449
+ const result = await apiJson<{ attributes: ConstrainedAttribute[] }>(t, `/api/cpq/quotes/attributes?${params}`)
434
450
  setAttributes(result.attributes ?? [])
435
451
  } catch { setAttributes([]) }
436
452
  }
@@ -449,7 +465,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
449
465
  const body: Record<string, unknown> = { offeringId: selectedOffering.id, configuration: config, quantity }
450
466
  if (addingToParentLineId) body.parentLineId = addingToParentLineId
451
467
  if (tid) body.targetSubscriptionId = tid
452
- lastResult = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/items`, {
468
+ lastResult = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/items`, {
453
469
  method: 'POST',
454
470
  headers: { 'Content-Type': 'application/json' },
455
471
  body: JSON.stringify(body),
@@ -466,7 +482,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
466
482
  if (!cpqQuote) return
467
483
  try {
468
484
  setSubmitting(true); setError(null)
469
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/items/${lineId}`, {
485
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/items/${lineId}`, {
470
486
  method: 'PUT',
471
487
  headers: { 'Content-Type': 'application/json' },
472
488
  body: JSON.stringify({ configuration: config, quantity }),
@@ -492,7 +508,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
492
508
  if (!cpqQuote) return
493
509
  try {
494
510
  setSubmitting(true); setError(null)
495
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/recalculate`, {
511
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/recalculate`, {
496
512
  method: 'POST',
497
513
  headers: { 'Content-Type': 'application/json' },
498
514
  body: JSON.stringify({ save: true }),
@@ -506,7 +522,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
506
522
  if (!cpqQuote) return
507
523
  try {
508
524
  setConverting(true); setError(null)
509
- const result = await apiJson<{ id: string }>('/api/cpq/orders', {
525
+ const result = await apiJson<{ id: string }>(t, '/api/cpq/orders', {
510
526
  method: 'POST',
511
527
  headers: { 'Content-Type': 'application/json' },
512
528
  body: JSON.stringify({ quoteId: cpqQuote.id }),
@@ -520,7 +536,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
520
536
  if (!cpqQuote) return
521
537
  try {
522
538
  setTransitioning(true); setError(null)
523
- const result = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}/status`, {
539
+ const result = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}/status`, {
524
540
  method: 'POST',
525
541
  headers: { 'Content-Type': 'application/json' },
526
542
  body: JSON.stringify({ targetStatus }),
@@ -534,7 +550,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
534
550
  if (!cpqQuote) return
535
551
  try {
536
552
  setDeleting(true); setError(null); setShowDeleteConfirm(false)
537
- await apiJson<{ ok: boolean }>(`/api/cpq/quotes/${cpqQuote.id}`, { method: 'DELETE' })
553
+ await apiJson<{ ok: boolean }>(t, `/api/cpq/quotes/${cpqQuote.id}`, { method: 'DELETE' })
538
554
  router.push('/backend/cpq/quotes')
539
555
  } catch (err) { setError((err as Error).message) }
540
556
  finally { setDeleting(false) }
@@ -572,7 +588,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
572
588
  setAddingTargetSubIds(inherited ? [inherited] : [])
573
589
  if (offeringId) {
574
590
  try {
575
- const result = await apiJson<{ items: Offering[] }>('/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
591
+ const result = await apiJson<{ items: Offering[] }>(t, '/api/cpq/product-offerings?pageSize=100&lifecycleStatus=active')
576
592
  const match = (result.items ?? []).find((o) => o.id === offeringId)
577
593
  if (match) {
578
594
  selectOffering(match)
@@ -607,7 +623,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
607
623
  if (loading) {
608
624
  return (
609
625
  <div className="flex items-center gap-2 py-12 justify-center text-sm text-muted-foreground">
610
- <Spinner /> Loading…
626
+ <Spinner /> {t('cpq.quotes.detail.loading', 'Loading…')}
611
627
  </div>
612
628
  )
613
629
  }
@@ -615,9 +631,9 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
615
631
  if (!cpqQuote) {
616
632
  return (
617
633
  <div className="space-y-4">
618
- <button onClick={() => router.push('/backend/cpq/quotes')} className="text-sm text-muted-foreground hover:text-foreground">← Back to Quotes</button>
634
+ <button onClick={() => router.push('/backend/cpq/quotes')} className="text-sm text-muted-foreground hover:text-foreground">{t('cpq.quotes.detail.backToQuotes', '← Back to Quotes')}</button>
619
635
  {error && <ErrorBanner message={error} />}
620
- <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">Quote not found.</div>
636
+ <div className="rounded-lg border bg-card p-6 text-center text-sm text-muted-foreground">{t('cpq.quotes.detail.notFound', 'Quote not found.')}</div>
621
637
  </div>
622
638
  )
623
639
  }
@@ -666,7 +682,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
666
682
  // items from new targets (XD-250 multi-target ARC), so refetch
667
683
  // the full quote to pick up the new lines without a hard refresh.
668
684
  try {
669
- const fresh = await apiJson<QuoteResult>(`/api/cpq/quotes/${cpqQuote.id}`)
685
+ const fresh = await apiJson<QuoteResult>(t, `/api/cpq/quotes/${cpqQuote.id}`)
670
686
  setCpqQuote(fresh)
671
687
  } catch {
672
688
  /* ignore — loadArcTargets already surfaced any error */
@@ -682,18 +698,18 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
682
698
  <FormHeader
683
699
  mode="detail"
684
700
  backHref="/backend/cpq/quotes"
685
- backLabel="Back to CPQ Quotes"
686
- entityTypeLabel="CPQ Quote"
687
- title={cpqQuote.quoteNumber || cpqQuote.customerName || 'Untitled'}
701
+ backLabel={t('cpq.quotes.detail.backLabel', 'Back to CPQ Quotes')}
702
+ entityTypeLabel={t('cpq.quotes.detail.entityType', 'CPQ Quote')}
703
+ title={cpqQuote.quoteNumber || cpqQuote.customerName || t('cpq.quotes.detail.untitled', 'Untitled')}
688
704
  statusBadge={
689
705
  <div className="flex flex-wrap items-center gap-2 pt-1">
690
706
  <Tag variant={quoteCpqStatusMap[cpqQuote.cpqStatus as QuoteCpqStatus] ?? 'neutral'} dot>
691
707
  {formatStatusLabel(cpqQuote.cpqStatus)}
692
708
  </Tag>
693
709
  {arcQuoteType !== 'new' && (
694
- <Tag variant="brand" className="uppercase">{arcQuoteType}</Tag>
710
+ <Tag variant="brand" className="uppercase">{quoteTypeLabel(t, arcQuoteType)}</Tag>
695
711
  )}
696
- {cpqQuote.convertedOrderId && <Tag variant="success" dot>Converted</Tag>}
712
+ {cpqQuote.convertedOrderId && <Tag variant="success" dot>{t('cpq.quotes.detail.converted', 'Converted')}</Tag>}
697
713
  </div>
698
714
  }
699
715
  // FormHeader.detail ignores `utilityActions` whenever `actionsContent`
@@ -707,7 +723,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
707
723
  onClick={() => router.push(`/backend/sales/quotes/${cpqQuote.quoteId}`)}
708
724
  >
709
725
  <ExternalLink className="h-3.5 w-3.5" />
710
- View Sales Quote
726
+ {t('cpq.quotes.detail.viewSalesQuote', 'View Sales Quote')}
711
727
  </Button>
712
728
  {cpqQuote.convertedOrderId ? (
713
729
  <Button
@@ -715,31 +731,32 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
715
731
  onClick={() => router.push(`/backend/cpq/orders/${cpqQuote.convertedOrderId}`)}
716
732
  >
717
733
  <ArrowRight className="h-4 w-4" />
718
- Go to order
734
+ {t('cpq.quotes.detail.goToOrder', 'Go to order')}
719
735
  </Button>
720
736
  ) : view !== 'summary' ? (
721
737
  <Button type="button" variant="outline" onClick={() => { resetConfigState(); setView('summary') }}>
722
- Cancel
738
+ {t('cpq.common.cancel', 'Cancel')}
723
739
  </Button>
724
740
  ) : (
725
741
  <>
726
742
  <Button type="button" onClick={startAddOffering}>
727
743
  <Plus className="h-4 w-4" />
728
- Add Offering
744
+ {t('cpq.quotes.detail.addOffering', 'Add Offering')}
729
745
  </Button>
730
746
  {arcEditable && (
731
747
  <Button type="button" variant="outline" onClick={() => setArcDrawerOpen(true)}>
732
- Modify Subscription
748
+ {t('cpq.quotes.detail.modifySubscription', 'Modify Subscription')}
733
749
  </Button>
734
750
  )}
735
751
  {cpqQuote.cpqStatus === 'accepted' && (
736
752
  <Button type="button" onClick={convertToOrder} disabled={converting}>
737
753
  {converting ? <Spinner /> : <ArrowRight className="h-4 w-4" />}
738
- Convert to Order
754
+ {t('cpq.quotes.detail.convertToOrder', 'Convert to Order')}
739
755
  </Button>
740
756
  )}
741
757
  <MoreActionsDropdown
742
758
  items={buildMoreActions({
759
+ t,
743
760
  recalculate,
744
761
  canRecalculate: canRecalculateQuote(grantedFeatures, cpqQuote.cpqStatus),
745
762
  submitting,
@@ -782,7 +799,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
782
799
  statusMap={quoteCpqStatusMap}
783
800
  onTransition={transitionStatus}
784
801
  disabled={transitioning}
785
- ariaLabel="Quote status path"
802
+ ariaLabel={t('cpq.quotes.detail.statusPath', 'Quote status path')}
786
803
  />
787
804
  {transitioning && <Spinner />}
788
805
  </div>
@@ -793,9 +810,11 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
793
810
  <div className="rounded-md border border-brand-violet/30 bg-brand-violet/10 px-4 py-3 text-sm text-brand-violet">
794
811
  <div className="flex items-center justify-between">
795
812
  <div>
796
- <span className="font-medium uppercase">{arcQuoteType}</span>
813
+ <span className="font-medium uppercase">{quoteTypeLabel(t, arcQuoteType)}</span>
797
814
  {' • '}
798
- {arcTargets.length} target{arcTargets.length === 1 ? '' : 's'}:
815
+ {arcTargets.length === 1
816
+ ? t('cpq.quotes.detail.arcTargets.one', '{count} target:', { count: arcTargets.length })
817
+ : t('cpq.quotes.detail.arcTargets.other', '{count} targets:', { count: arcTargets.length })}
799
818
  {' '}
800
819
  {arcTargets.map((t, i) => (
801
820
  <span key={t.id}>
@@ -812,7 +831,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
812
831
  ))}
813
832
  {isMergeRenew && (
814
833
  <span className="ml-2 text-status-warning-text font-medium">
815
- • Merging into a new contract
834
+ {t('cpq.quotes.detail.mergingRenew', '• Merging into a new contract')}
816
835
  </span>
817
836
  )}
818
837
  </div>
@@ -824,7 +843,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
824
843
  currency). Mirrors the sidebar tiles in the standard
825
844
  sales-document detail layout. */}
826
845
  <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
827
- <MetaCard label="Customer">
846
+ <MetaCard label={t('cpq.quotes.detail.meta.customer', 'Customer')}>
828
847
  {cpqQuote.customerName ? (
829
848
  <span className="font-medium">{cpqQuote.customerName}</span>
830
849
  ) : (
@@ -833,10 +852,10 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
833
852
  </span>
834
853
  )}
835
854
  </MetaCard>
836
- <MetaCard label="Version">
855
+ <MetaCard label={t('cpq.quotes.detail.meta.version', 'Version')}>
837
856
  <span className="font-medium">v{cpqQuote.version}</span>
838
857
  </MetaCard>
839
- <MetaCard label="Currency">
858
+ <MetaCard label={t('cpq.quotes.detail.meta.currency', 'Currency')}>
840
859
  <span className="font-medium">{currency}</span>
841
860
  </MetaCard>
842
861
  </div>
@@ -845,17 +864,17 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
845
864
  <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={() => setShowDeleteConfirm(false)}>
846
865
  <div className="w-full max-w-sm rounded-lg border bg-card shadow-xl" onClick={(e) => e.stopPropagation()}>
847
866
  <div className="px-4 py-3 border-b">
848
- <h3 className="text-sm font-medium">Delete Quote</h3>
867
+ <h3 className="text-sm font-medium">{t('cpq.quotes.detail.deleteConfirm.title', 'Delete Quote')}</h3>
849
868
  </div>
850
869
  <div className="p-4">
851
- <p className="text-sm text-muted-foreground">Are you sure you want to delete this quote? This will soft-delete the quote and all its line items.</p>
870
+ <p className="text-sm text-muted-foreground">{t('cpq.quotes.detail.deleteConfirm.text', 'Are you sure you want to delete this quote? This will soft-delete the quote and all its line items.')}</p>
852
871
  </div>
853
872
  <div className="flex justify-end gap-2 px-4 py-3 border-t">
854
873
  <Button type="button" variant="outline" onClick={() => setShowDeleteConfirm(false)}>
855
- Cancel
874
+ {t('cpq.common.cancel', 'Cancel')}
856
875
  </Button>
857
876
  <Button type="button" variant="destructive" onClick={deleteQuote} disabled={deleting}>
858
- {deleting ? <Spinner /> : null} Delete
877
+ {deleting ? <Spinner /> : null} {t('cpq.common.delete', 'Delete')}
859
878
  </Button>
860
879
  </div>
861
880
  </div>
@@ -870,7 +889,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
870
889
  {/* Validation errors */}
871
890
  {!cpqQuote.validationResult.valid && (
872
891
  <Alert variant="warning">
873
- <AlertTitle>Validation Issues</AlertTitle>
892
+ <AlertTitle>{t('cpq.quotes.detail.validationIssues', 'Validation Issues')}</AlertTitle>
874
893
  <AlertDescription>
875
894
  {/* Stacked <span>s rather than <ul>/<li>: before Open Mercato
876
895
  0.8 AlertDescription rendered a <p>, where a list is invalid
@@ -938,7 +957,8 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
938
957
  <>
939
958
  {addingToParentLineId && (
940
959
  <Alert variant="info">
941
- Adding component to bundle: <span className="font-medium">{cpqQuote.lines.find((l) => l.lineId === addingToParentLineId)?.offeringName ?? 'Bundle'}</span>
960
+ {t('cpq.quotes.detail.addingComponentTo', 'Adding component to bundle:')}{' '}
961
+ <span className="font-medium">{cpqQuote.lines.find((l) => l.lineId === addingToParentLineId)?.offeringName ?? t('cpq.quotes.detail.bundleFallback', 'Bundle')}</span>
942
962
  </Alert>
943
963
  )}
944
964
  <OfferingBrowser
@@ -954,7 +974,11 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
954
974
  {/* Configure new offering */}
955
975
  {view === 'configure' && selectedOffering && (
956
976
  <ConfigurePanel
957
- title={addingToParentLineId ? `Add Component: ${selectedOffering.name}` : `Configure: ${selectedOffering.name}`}
977
+ title={
978
+ addingToParentLineId
979
+ ? t('cpq.quotes.detail.configure.addComponentTitle', 'Add Component: {name}', { name: selectedOffering.name })
980
+ : t('cpq.quotes.detail.configure.configureTitle', 'Configure: {name}', { name: selectedOffering.name })
981
+ }
958
982
  attributes={attributes}
959
983
  config={config}
960
984
  quantity={quantity}
@@ -980,7 +1004,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
980
1004
  }}
981
1005
  onQuantityChange={setQuantity}
982
1006
  onSubmit={addItem}
983
- submitLabel="Add to Quote"
1007
+ submitLabel={t('cpq.quotes.detail.configure.addToQuote', 'Add to Quote')}
984
1008
  submitting={submitting}
985
1009
  />
986
1010
  )}
@@ -988,7 +1012,9 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
988
1012
  {/* Edit existing line */}
989
1013
  {view === 'edit' && editingLineId && (
990
1014
  <ConfigurePanel
991
- title={`Edit: ${cpqQuote.lines.find((l) => l.lineId === editingLineId)?.offeringName ?? 'Line'}`}
1015
+ title={t('cpq.quotes.detail.configure.editTitle', 'Edit: {name}', {
1016
+ name: cpqQuote.lines.find((l) => l.lineId === editingLineId)?.offeringName ?? t('cpq.quotes.detail.configure.lineFallback', 'Line'),
1017
+ })}
992
1018
  attributes={attributes}
993
1019
  config={config}
994
1020
  quantity={quantity}
@@ -1003,7 +1029,7 @@ export default function CpqQuoteDetailPage(props: { params?: { id?: string } })
1003
1029
  }}
1004
1030
  onQuantityChange={setQuantity}
1005
1031
  onSubmit={() => updateItem(editingLineId)}
1006
- submitLabel="Update Line"
1032
+ submitLabel={t('cpq.quotes.detail.configure.updateLine', 'Update Line')}
1007
1033
  submitting={submitting}
1008
1034
  />
1009
1035
  )}
@@ -1023,6 +1049,7 @@ function QuoteLineTree({ lines, currency, expandedLines, pricingDetail, targetCo
1023
1049
  onRemoveLine: (lineId: string) => void; onAddComponent: (parentLineId: string, offeringId?: string) => void
1024
1050
  onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1025
1051
  }) {
1052
+ const t = useT()
1026
1053
  const [bundleTrees, setBundleTrees] = React.useState<Map<string, BundleTree>>(new Map())
1027
1054
  const [loadingTrees, setLoadingTrees] = React.useState<Set<string>>(new Set())
1028
1055
 
@@ -1040,18 +1067,18 @@ function QuoteLineTree({ lines, currency, expandedLines, pricingDetail, targetCo
1040
1067
  if (bundleTrees.has(offeringId) || loadingTrees.has(offeringId)) return
1041
1068
  setLoadingTrees((prev) => new Set(prev).add(offeringId))
1042
1069
  try {
1043
- const result = await apiJson<{ tree: BundleTree }>(`/api/cpq/bundle-tree?offeringId=${offeringId}`)
1070
+ const result = await apiJson<{ tree: BundleTree }>(t, `/api/cpq/bundle-tree?offeringId=${offeringId}`)
1044
1071
  setBundleTrees((prev) => new Map(prev).set(offeringId, result.tree))
1045
1072
  } catch { /* ignore — will just not show structure */ }
1046
1073
  finally { setLoadingTrees((prev) => { const n = new Set(prev); n.delete(offeringId); return n }) }
1047
- }, [bundleTrees, loadingTrees])
1074
+ }, [bundleTrees, loadingTrees, t])
1048
1075
 
1049
1076
  return (
1050
1077
  <div>
1051
- <h3 className="text-sm font-medium mb-2 px-1">Line Items</h3>
1078
+ <h3 className="text-sm font-medium mb-2 px-1">{t('cpq.quotes.detail.lineItems', 'Line Items')}</h3>
1052
1079
  {lines.length === 0 ? (
1053
1080
  <div className="rounded-lg border bg-card py-8 text-center text-sm text-muted-foreground">
1054
- No items configured yet. Click &quot;Add Offering&quot; to start.
1081
+ {t('cpq.quotes.detail.noLines', 'No items configured yet. Click "Add Offering" to start.')}
1055
1082
  </div>
1056
1083
  ) : (
1057
1084
  <div className="rounded-lg border bg-card divide-y">
@@ -1119,10 +1146,11 @@ function BundleSlotPanel({ bundleTree, treeLoading, children, allLines, currency
1119
1146
  onRemoveLine: (lineId: string) => void; onAddComponent: (parentLineId: string, offeringId?: string) => void
1120
1147
  onTogglePricingDetail: (lineIdx: number, chargeIdx: number) => void
1121
1148
  }) {
1149
+ const t = useT()
1122
1150
  if (treeLoading) {
1123
1151
  return (
1124
1152
  <div className="border-t bg-muted/5 px-8 py-3">
1125
- <div className="flex items-center gap-2 text-xs text-muted-foreground"><Spinner /> Loading bundle structure…</div>
1153
+ <div className="flex items-center gap-2 text-xs text-muted-foreground"><Spinner /> {t('cpq.quotes.bundle.loadingStructure', 'Loading bundle structure…')}</div>
1126
1154
  </div>
1127
1155
  )
1128
1156
  }
@@ -1171,13 +1199,14 @@ function BundleSlotPanel({ bundleTree, treeLoading, children, allLines, currency
1171
1199
  <div className="flex items-center gap-2 px-6 py-2 bg-muted/20">
1172
1200
  <span className="text-xs font-medium text-muted-foreground uppercase tracking-wide">{slot.name}</span>
1173
1201
  {isRequired ? (
1174
- <Tag variant="warning" className="text-[10px] px-1.5">required</Tag>
1202
+ <Tag variant="warning" className="text-[10px] px-1.5">{t('cpq.quotes.bundle.slotRequired', 'required')}</Tag>
1175
1203
  ) : (
1176
- <Tag variant="neutral" className="text-[10px] px-1.5">optional</Tag>
1204
+ <Tag variant="neutral" className="text-[10px] px-1.5">{t('cpq.quotes.bundle.slotOptional', 'optional')}</Tag>
1177
1205
  )}
1178
1206
  <span className="text-[10px] text-muted-foreground">
1179
1207
  {filledCount}/{slot.cardinalityMax ?? '∞'}
1180
- {slot.cardinalityMin > 0 && ` (min ${slot.cardinalityMin})`}
1208
+ {slot.cardinalityMin > 0 && ' '}
1209
+ {slot.cardinalityMin > 0 && t('cpq.quotes.bundle.slotMin', '(min {min})', { min: slot.cardinalityMin })}
1181
1210
  </span>
1182
1211
  <div className="flex-1" />
1183
1212
  {/* Available offerings in this slot */}
@@ -1196,7 +1225,13 @@ function BundleSlotPanel({ bundleTree, treeLoading, children, allLines, currency
1196
1225
  ? 'border-border hover:border-primary hover:bg-primary/5 hover:text-primary cursor-pointer'
1197
1226
  : 'border-border text-muted-foreground opacity-50 cursor-not-allowed'
1198
1227
  }`}
1199
- title={isFilled ? 'Already added' : canAdd ? `Add ${comp.childOffering.offeringName}` : 'Slot is full'}
1228
+ title={
1229
+ isFilled
1230
+ ? t('cpq.quotes.bundle.alreadyAdded', 'Already added')
1231
+ : canAdd
1232
+ ? t('cpq.quotes.bundle.addComponent', 'Add {name}', { name: comp.childOffering.offeringName })
1233
+ : t('cpq.quotes.bundle.slotFull', 'Slot is full')
1234
+ }
1200
1235
  >
1201
1236
  {isFilled ? (
1202
1237
  <svg className="h-3 w-3" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
@@ -1293,7 +1328,7 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1293
1328
  <span className="text-muted-foreground text-xs">└</span>
1294
1329
  )}
1295
1330
  {isBundle && (
1296
- <Tag variant="brand" className="px-1.5 text-xs">bundle</Tag>
1331
+ <Tag variant="brand" className="px-1.5 text-xs">{t('cpq.lines.bundle', 'bundle')}</Tag>
1297
1332
  )}
1298
1333
  <span className={`font-medium text-sm ${isRemoved ? 'text-muted-foreground line-through' : ''}`}>{line.offeringName}</span>
1299
1334
  {line.quantity > 1 && <span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium">×{line.quantity}</span>}
@@ -1318,18 +1353,22 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1318
1353
  <Tag
1319
1354
  variant="brand"
1320
1355
  className="px-1.5 text-xs"
1321
- title={`Acts on subscription ${targetCodes.get(line.targetSubscriptionId)}`}
1356
+ title={t('cpq.quotes.detail.actsOnSubscription', 'Acts on subscription {code}', { code: targetCodes.get(line.targetSubscriptionId) ?? '' })}
1322
1357
  >
1323
1358
  → {targetCodes.get(line.targetSubscriptionId)}
1324
1359
  </Tag>
1325
1360
  )}
1326
1361
  {isRemoved ? null : line.isConfigured ? (
1327
- <Tag variant="success" className="px-1.5 text-xs">configured</Tag>
1362
+ <Tag variant="success" className="px-1.5 text-xs">{t('cpq.quotes.detail.lineConfigured', 'configured')}</Tag>
1328
1363
  ) : (
1329
- <Tag variant="warning" className="px-1.5 text-xs">incomplete</Tag>
1364
+ <Tag variant="warning" className="px-1.5 text-xs">{t('cpq.quotes.detail.lineIncomplete', 'incomplete')}</Tag>
1330
1365
  )}
1331
1366
  {isBundle && (
1332
- <span className="text-xs text-muted-foreground">{childCount} component{childCount !== 1 ? 's' : ''}</span>
1367
+ <span className="text-xs text-muted-foreground">
1368
+ {childCount !== 1
1369
+ ? t('cpq.lines.componentCount.other', '{count} components', { count: childCount })
1370
+ : t('cpq.lines.componentCount.one', '{count} component', { count: childCount })}
1371
+ </span>
1333
1372
  )}
1334
1373
  </div>
1335
1374
  {line.validationErrors && line.validationErrors.length > 0 && (
@@ -1341,12 +1380,12 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1341
1380
  <div className="shrink-0 text-right text-xs space-y-0.5">
1342
1381
  {isRemoved && line.arcSource && line.arcSource.mrcAmount > 0 && (
1343
1382
  <div>
1344
- <span className="text-muted-foreground">MRC </span>
1383
+ <span className="text-muted-foreground">{t('cpq.lines.mrc', 'MRC')} </span>
1345
1384
  <span className="font-mono text-muted-foreground line-through">{fmt(line.arcSource.mrcAmount, currency)}</span>
1346
1385
  </div>
1347
1386
  )}
1348
- {line.nrcTotal > 0 && <div><span className="text-muted-foreground">NRC </span><span className="font-mono font-medium">{fmt(line.nrcTotal, currency)}</span></div>}
1349
- {line.mrcTotal > 0 && <div><span className="text-muted-foreground">MRC </span><span className="font-mono font-medium">{fmt(line.mrcTotal, currency)}</span></div>}
1387
+ {line.nrcTotal > 0 && <div><span className="text-muted-foreground">{t('cpq.lines.nrc', 'NRC')} </span><span className="font-mono font-medium">{fmt(line.nrcTotal, currency)}</span></div>}
1388
+ {line.mrcTotal > 0 && <div><span className="text-muted-foreground">{t('cpq.lines.mrc', 'MRC')} </span><span className="font-mono font-medium">{fmt(line.mrcTotal, currency)}</span></div>}
1350
1389
  </div>
1351
1390
 
1352
1391
  {/* Actions */}
@@ -1366,18 +1405,18 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1366
1405
  <div className="flex items-center gap-1 shrink-0">
1367
1406
  <DiscountLineAction line={line} />
1368
1407
  {onAddComponent && (
1369
- <button onClick={onAddComponent} className="rounded p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/5 transition-colors" title="Add Component">
1408
+ <button onClick={onAddComponent} className="rounded p-1.5 text-muted-foreground hover:text-primary hover:bg-primary/5 transition-colors" title={t('cpq.quotes.detail.addComponent', 'Add Component')}>
1370
1409
  <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1371
1410
  <path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
1372
1411
  </svg>
1373
1412
  </button>
1374
1413
  )}
1375
- <button onClick={onEdit} className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title="Edit">
1414
+ <button onClick={onEdit} className="rounded p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted transition-colors" title={t('cpq.common.edit', 'Edit')}>
1376
1415
  <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1377
1416
  <path strokeLinecap="round" strokeLinejoin="round" d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0115.75 21H5.25A2.25 2.25 0 013 18.75V8.25A2.25 2.25 0 015.25 6H10" />
1378
1417
  </svg>
1379
1418
  </button>
1380
- <button onClick={onRemove} className="rounded p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" title="Remove">
1419
+ <button onClick={onRemove} className="rounded p-1.5 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors" title={t('cpq.quotes.detail.removeLine', 'Remove')}>
1381
1420
  <svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor">
1382
1421
  <path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
1383
1422
  </svg>
@@ -1409,21 +1448,23 @@ function QuoteLineRow({ line, lineIdx, currency, isExpanded, pricingDetail, isBu
1409
1448
  }
1410
1449
 
1411
1450
  function LineSummaryFallback({ line, currency }: { line: QuoteLine; currency: string }) {
1451
+ const t = useT()
1412
1452
  const hasTotals = line.nrcTotal > 0 || line.mrcTotal > 0
1413
1453
  const mirroredFromItemId = (line.configuration?._arcMirroredFromItemId as string | undefined) ?? null
1414
1454
  return (
1415
1455
  <div className="border-t bg-muted/10 px-12 py-2 text-xs text-muted-foreground space-y-1">
1416
1456
  <div>
1417
- No detailed charge breakdown available for this line.
1418
- {mirroredFromItemId && ' Mirrored from existing subscription item.'}
1457
+ {t('cpq.quotes.detail.noBreakdown', 'No detailed charge breakdown available for this line.')}
1458
+ {mirroredFromItemId && ' '}
1459
+ {mirroredFromItemId && t('cpq.orders.detail.mirroredItem', 'Mirrored from existing subscription item.')}
1419
1460
  </div>
1420
1461
  {hasTotals && (
1421
1462
  <div className="flex gap-4">
1422
1463
  {line.nrcTotal > 0 && (
1423
- <span>NRC <span className="font-mono font-medium text-foreground">{fmt(line.nrcTotal, currency)}</span></span>
1464
+ <span>{t('cpq.lines.nrc', 'NRC')} <span className="font-mono font-medium text-foreground">{fmt(line.nrcTotal, currency)}</span></span>
1424
1465
  )}
1425
1466
  {line.mrcTotal > 0 && (
1426
- <span>MRC <span className="font-mono font-medium text-foreground">{fmt(line.mrcTotal, currency)}</span></span>
1467
+ <span>{t('cpq.lines.mrc', 'MRC')} <span className="font-mono font-medium text-foreground">{fmt(line.mrcTotal, currency)}</span></span>
1427
1468
  )}
1428
1469
  </div>
1429
1470
  )}
@@ -1461,6 +1502,7 @@ function Spinner() {
1461
1502
  }
1462
1503
 
1463
1504
  function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () => void }) {
1505
+ const t = useT()
1464
1506
  return (
1465
1507
  <Alert variant="destructive" className="flex items-start gap-2">
1466
1508
  <span className="flex-1">{message}</span>
@@ -1468,7 +1510,7 @@ function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () =
1468
1510
  <button
1469
1511
  onClick={onDismiss}
1470
1512
  className="text-destructive/70 hover:text-destructive text-sm font-bold"
1471
- aria-label="Dismiss"
1513
+ aria-label={t('cpq.common.dismiss', 'Dismiss')}
1472
1514
  >
1473
1515
  ×
1474
1516
  </button>
@@ -1480,6 +1522,7 @@ function ErrorBanner({ message, onDismiss }: { message: string; onDismiss?: () =
1480
1522
  function OfferingBrowser({ offerings, loading, search, onSearchChange, onSelect }: {
1481
1523
  offerings: Offering[]; loading: boolean; search: string; onSearchChange: (v: string) => void; onSelect: (o: Offering) => void
1482
1524
  }) {
1525
+ const t = useT()
1483
1526
  const filtered = offerings.filter((o) => {
1484
1527
  if (!search.trim()) return true
1485
1528
  const q = search.toLowerCase()
@@ -1488,18 +1531,18 @@ function OfferingBrowser({ offerings, loading, search, onSearchChange, onSelect
1488
1531
 
1489
1532
  return (
1490
1533
  <div className="space-y-3">
1491
- <h3 className="text-sm font-medium px-1">Select Product Offering</h3>
1534
+ <h3 className="text-sm font-medium px-1">{t('cpq.quotes.offeringBrowser.title', 'Select Product Offering')}</h3>
1492
1535
  <div className="relative">
1493
1536
  <svg className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" fill="none" viewBox="0 0 24 24" strokeWidth="2" stroke="currentColor">
1494
1537
  <path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z" />
1495
1538
  </svg>
1496
- <input type="text" placeholder="Search offerings…" value={search} onChange={(e) => onSearchChange(e.target.value)}
1539
+ <input type="text" placeholder={t('cpq.quotes.offeringBrowser.searchPlaceholder', 'Search offerings…')} value={search} onChange={(e) => onSearchChange(e.target.value)}
1497
1540
  className="w-full rounded-md border bg-background pl-10 pr-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50" />
1498
1541
  </div>
1499
1542
  {loading ? (
1500
- <div className="flex items-center gap-2 py-8 justify-center text-sm text-muted-foreground"><Spinner /> Loading offerings…</div>
1543
+ <div className="flex items-center gap-2 py-8 justify-center text-sm text-muted-foreground"><Spinner /> {t('cpq.quotes.offeringBrowser.loading', 'Loading offerings…')}</div>
1501
1544
  ) : filtered.length === 0 ? (
1502
- <div className="py-8 text-center text-sm text-muted-foreground">{search ? 'No offerings match your search.' : 'No active offerings available.'}</div>
1545
+ <div className="py-8 text-center text-sm text-muted-foreground">{search ? t('cpq.quotes.offeringBrowser.noMatch', 'No offerings match your search.') : t('cpq.quotes.offeringBrowser.empty', 'No active offerings available.')}</div>
1503
1546
  ) : (
1504
1547
  <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
1505
1548
  {filtered.map((offering) => (
@@ -1524,6 +1567,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1524
1567
  onConfigChange: (key: string, value: unknown) => void; onQuantityChange: (q: number) => void
1525
1568
  onSubmit: () => void; submitLabel: string; submitting: boolean
1526
1569
  }) {
1570
+ const t = useT()
1527
1571
  const noTargetsPicked =
1528
1572
  arcTargetOptions !== null && arcTargetOptions.length > 0 && arcSelectedTargetIds.length === 0
1529
1573
  return (
@@ -1533,7 +1577,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1533
1577
  {arcTargetOptions && arcTargetOptions.length > 0 && (
1534
1578
  <div>
1535
1579
  <label className="block text-sm font-medium mb-1">
1536
- Apply to subscription(s)
1580
+ {t('cpq.quotes.detail.configure.applyToSubscriptions', 'Apply to subscription(s)')}
1537
1581
  <span className="text-destructive ml-1">*</span>
1538
1582
  </label>
1539
1583
  <div className="space-y-1.5 rounded-md border bg-background px-3 py-2">
@@ -1557,15 +1601,15 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1557
1601
  })}
1558
1602
  </div>
1559
1603
  <p className="text-xs text-muted-foreground mt-1">
1560
- Pick one or more — a separate line will be created on each selected subscription.
1604
+ {t('cpq.quotes.detail.configure.applyToSubscriptionsHint', 'Pick one or more — a separate line will be created on each selected subscription.')}
1561
1605
  </p>
1562
1606
  {noTargetsPicked && (
1563
- <p className="text-xs text-destructive mt-1">Select at least one target.</p>
1607
+ <p className="text-xs text-destructive mt-1">{t('cpq.quotes.detail.configure.selectTarget', 'Select at least one target.')}</p>
1564
1608
  )}
1565
1609
  </div>
1566
1610
  )}
1567
1611
  <div>
1568
- <label className="block text-sm font-medium mb-1">Quantity</label>
1612
+ <label className="block text-sm font-medium mb-1">{t('cpq.quotes.detail.configure.quantity', 'Quantity')}</label>
1569
1613
  <NumberInput
1570
1614
  integer
1571
1615
  min={1}
@@ -1575,7 +1619,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1575
1619
  />
1576
1620
  </div>
1577
1621
  {attributes.length === 0 ? (
1578
- <p className="text-sm text-muted-foreground">No configurable attributes for this offering.</p>
1622
+ <p className="text-sm text-muted-foreground">{t('cpq.quotes.detail.configure.noAttributes', 'No configurable attributes for this offering.')}</p>
1579
1623
  ) : (
1580
1624
  attributes.map((attr) => <AttributeField key={attr.code} attribute={attr} value={config[attr.code]} onChange={(v) => onConfigChange(attr.code, v)} />)
1581
1625
  )}
@@ -1591,6 +1635,7 @@ function ConfigurePanel({ title, attributes, config, quantity, arcTargetOptions,
1591
1635
  }
1592
1636
 
1593
1637
  function AttributeField({ attribute, value, onChange }: { attribute: ConstrainedAttribute; value: unknown; onChange: (v: unknown) => void }) {
1638
+ const t = useT()
1594
1639
  const { code, name, attributeType, isRequired, options, constraints, helpText, defaultValue } = attribute
1595
1640
  const currentValue = value ?? defaultValue ?? ''
1596
1641
 
@@ -1602,13 +1647,13 @@ function AttributeField({ attribute, value, onChange }: { attribute: Constrained
1602
1647
  {(attributeType === 'select' || attributeType === 'enum') && options && options.length > 0 ? (
1603
1648
  <select value={String(currentValue)} onChange={(e) => onChange(e.target.value)}
1604
1649
  className="w-full rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50">
1605
- <option value="">— Select —</option>
1650
+ <option value="">{t('cpq.quotes.detail.configure.selectPlaceholder', '— Select —')}</option>
1606
1651
  {options.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)}
1607
1652
  </select>
1608
1653
  ) : attributeType === 'boolean' ? (
1609
1654
  <label className="flex items-center gap-2">
1610
1655
  <input type="checkbox" checked={Boolean(currentValue)} onChange={(e) => onChange(e.target.checked)} className="rounded border-border" />
1611
- <span className="text-sm text-muted-foreground">{helpText ?? 'Enable'}</span>
1656
+ <span className="text-sm text-muted-foreground">{helpText ?? t('cpq.quotes.detail.configure.enable', 'Enable')}</span>
1612
1657
  </label>
1613
1658
  ) : attributeType === 'number' ? (
1614
1659
  <NumberInput