@open-mercato/core 0.7.1-develop.7123.1.25365e718d → 0.7.1-develop.7130.1.fef2396fd8

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 (32) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js +5 -117
  3. package/dist/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.js.map +2 -2
  4. package/dist/modules/customers/backend/customers/deals/[id]/page.js +1 -3
  5. package/dist/modules/customers/backend/customers/deals/[id]/page.js.map +2 -2
  6. package/dist/modules/customers/commands/deals.js +33 -9
  7. package/dist/modules/customers/commands/deals.js.map +3 -3
  8. package/dist/modules/customers/components/AddressEditor.js +71 -1
  9. package/dist/modules/customers/components/AddressEditor.js.map +2 -2
  10. package/dist/modules/customers/utils/addressFormat.js +18 -2
  11. package/dist/modules/customers/utils/addressFormat.js.map +2 -2
  12. package/dist/modules/sales/components/documents/AddressesSection.js +17 -2
  13. package/dist/modules/sales/components/documents/AddressesSection.js.map +2 -2
  14. package/dist/modules/sales/components/documents/SalesDocumentForm.js +7 -24
  15. package/dist/modules/sales/components/documents/SalesDocumentForm.js.map +2 -2
  16. package/dist/modules/sales/components/documents/normalizeAddressDraft.js +29 -0
  17. package/dist/modules/sales/components/documents/normalizeAddressDraft.js.map +7 -0
  18. package/package.json +7 -7
  19. package/src/modules/customers/backend/customers/deals/[id]/hooks/types.ts +0 -23
  20. package/src/modules/customers/backend/customers/deals/[id]/hooks/useDealAssociations.ts +11 -172
  21. package/src/modules/customers/backend/customers/deals/[id]/page.tsx +1 -1
  22. package/src/modules/customers/commands/deals.ts +84 -9
  23. package/src/modules/customers/components/AddressEditor.tsx +116 -1
  24. package/src/modules/customers/i18n/de.json +6 -0
  25. package/src/modules/customers/i18n/en.json +6 -0
  26. package/src/modules/customers/i18n/es.json +6 -0
  27. package/src/modules/customers/i18n/ko.json +6 -0
  28. package/src/modules/customers/i18n/pl.json +6 -0
  29. package/src/modules/customers/utils/addressFormat.tsx +59 -1
  30. package/src/modules/sales/components/documents/AddressesSection.tsx +48 -5
  31. package/src/modules/sales/components/documents/SalesDocumentForm.tsx +5 -23
  32. package/src/modules/sales/components/documents/normalizeAddressDraft.ts +29 -0
@@ -6,29 +6,6 @@ export type DealAssociation = {
6
6
  isPrimary?: boolean
7
7
  }
8
8
 
9
- export type PersonAssociationApiRecord = {
10
- id?: string
11
- displayName?: string | null
12
- display_name?: string | null
13
- primaryEmail?: string | null
14
- primary_email?: string | null
15
- primaryPhone?: string | null
16
- primary_phone?: string | null
17
- personProfile?: { jobTitle?: string | null } | null
18
- person_profile?: { jobTitle?: string | null } | null
19
- }
20
-
21
- export type CompanyAssociationApiRecord = {
22
- id?: string
23
- displayName?: string | null
24
- display_name?: string | null
25
- domain?: string | null
26
- websiteUrl?: string | null
27
- website_url?: string | null
28
- companyProfile?: { domain?: string | null; websiteUrl?: string | null } | null
29
- company_profile?: { domain?: string | null; websiteUrl?: string | null } | null
30
- }
31
-
32
9
  export type PipelineStageInfo = {
33
10
  id: string
34
11
  label: string
@@ -6,81 +6,11 @@ import { readApiResultOrThrow, withScopedApiRequestHeaders } from '@open-mercato
6
6
  import { buildOptimisticLockHeader } from '@open-mercato/ui/backend/utils/optimisticLock'
7
7
  import { useT } from '@open-mercato/shared/lib/i18n/context'
8
8
  import type {
9
- CompanyAssociationApiRecord,
10
9
  DealAssociation,
11
10
  DealDetailPayload,
12
11
  GuardedMutationRunner,
13
- PersonAssociationApiRecord,
14
12
  } from './types'
15
13
 
16
- export function normalizePersonAssociationRecord(
17
- record: PersonAssociationApiRecord,
18
- fallbackId: string,
19
- ): DealAssociation {
20
- const displayName =
21
- typeof record.displayName === 'string' && record.displayName.trim().length
22
- ? record.displayName.trim()
23
- : typeof record.display_name === 'string' && record.display_name.trim().length
24
- ? record.display_name.trim()
25
- : null
26
- const email =
27
- typeof record.primaryEmail === 'string' && record.primaryEmail.trim().length
28
- ? record.primaryEmail.trim()
29
- : typeof record.primary_email === 'string' && record.primary_email.trim().length
30
- ? record.primary_email.trim()
31
- : null
32
- const phone =
33
- typeof record.primaryPhone === 'string' && record.primaryPhone.trim().length
34
- ? record.primaryPhone.trim()
35
- : typeof record.primary_phone === 'string' && record.primary_phone.trim().length
36
- ? record.primary_phone.trim()
37
- : null
38
- const profile = record.personProfile ?? record.person_profile ?? null
39
- const jobTitle =
40
- profile && typeof profile.jobTitle === 'string' && profile.jobTitle.trim().length
41
- ? profile.jobTitle.trim()
42
- : null
43
- return {
44
- id: typeof record.id === 'string' ? record.id : fallbackId,
45
- label: displayName ?? email ?? phone ?? fallbackId,
46
- subtitle: jobTitle ?? email ?? phone ?? null,
47
- kind: 'person',
48
- }
49
- }
50
-
51
- export function normalizeCompanyAssociationRecord(
52
- record: CompanyAssociationApiRecord,
53
- fallbackId: string,
54
- ): DealAssociation {
55
- const displayName =
56
- typeof record.displayName === 'string' && record.displayName.trim().length
57
- ? record.displayName.trim()
58
- : typeof record.display_name === 'string' && record.display_name.trim().length
59
- ? record.display_name.trim()
60
- : null
61
- const profile = record.companyProfile ?? record.company_profile ?? null
62
- const domain =
63
- typeof record.domain === 'string' && record.domain.trim().length
64
- ? record.domain.trim()
65
- : profile && typeof profile.domain === 'string' && profile.domain.trim().length
66
- ? profile.domain.trim()
67
- : null
68
- const website =
69
- typeof record.websiteUrl === 'string' && record.websiteUrl.trim().length
70
- ? record.websiteUrl.trim()
71
- : typeof record.website_url === 'string' && record.website_url.trim().length
72
- ? record.website_url.trim()
73
- : profile && typeof profile.websiteUrl === 'string' && profile.websiteUrl.trim().length
74
- ? profile.websiteUrl.trim()
75
- : null
76
- return {
77
- id: typeof record.id === 'string' ? record.id : fallbackId,
78
- label: displayName ?? domain ?? website ?? fallbackId,
79
- subtitle: domain ?? website ?? null,
80
- kind: 'company',
81
- }
82
- }
83
-
84
14
  function sameIdList(left: string[], right: string[]): boolean {
85
15
  if (left.length !== right.length) return false
86
16
  return left.every((value, index) => value === right[index])
@@ -97,8 +27,12 @@ type UseDealAssociationsOptions = {
97
27
  data: DealDetailPayload | null
98
28
  setData: React.Dispatch<React.SetStateAction<DealDetailPayload | null>>
99
29
  runMutationWithContext: GuardedMutationRunner
100
- /** Re-fetch the deal detail; wired into the conflict bar's refresh action on a 409. */
101
- onRefresh?: (() => void) | null
30
+ /**
31
+ * Re-fetch the deal detail. Required, not optional: this hook no longer patches the list
32
+ * itself on success, so a caller without it would show a stale list *and* keep a superseded
33
+ * lock token that 409s on the next save. It also backs the conflict bar's refresh on a 409.
34
+ */
35
+ onRefresh: () => void | Promise<void>
102
36
  }
103
37
 
104
38
  type UseDealAssociationsResult = {
@@ -130,80 +64,6 @@ export function useDealAssociations({
130
64
  setCompaniesEditorIds(data?.linkedCompanyIds ?? [])
131
65
  }, [data?.linkedCompanyIds, data?.linkedPersonIds])
132
66
 
133
- const loadPeopleAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {
134
- const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))
135
- if (!uniqueIds.length) return []
136
- try {
137
- const params = new URLSearchParams({
138
- ids: uniqueIds.join(','),
139
- pageSize: String(Math.max(uniqueIds.length, 1)),
140
- })
141
- const payload = await readApiResultOrThrow<{ items?: PersonAssociationApiRecord[] }>(
142
- `/api/customers/people?${params.toString()}`,
143
- )
144
- const items = Array.isArray(payload.items) ? payload.items : []
145
- const byId = new Map<string, PersonAssociationApiRecord>()
146
- items.forEach((record) => {
147
- if (record && typeof record.id === 'string') byId.set(record.id, record)
148
- })
149
- return uniqueIds.map((personId) => {
150
- const record = byId.get(personId)
151
- return record
152
- ? normalizePersonAssociationRecord(record, personId)
153
- : {
154
- id: personId,
155
- label: personId,
156
- subtitle: null,
157
- kind: 'person' as const,
158
- }
159
- })
160
- } catch {
161
- return uniqueIds.map((personId) => ({
162
- id: personId,
163
- label: personId,
164
- subtitle: null,
165
- kind: 'person' as const,
166
- }))
167
- }
168
- }, [])
169
-
170
- const loadCompanyAssociations = React.useCallback(async (ids: string[]): Promise<DealAssociation[]> => {
171
- const uniqueIds = Array.from(new Set(ids.map((value) => value.trim()).filter(Boolean)))
172
- if (!uniqueIds.length) return []
173
- try {
174
- const params = new URLSearchParams({
175
- ids: uniqueIds.join(','),
176
- pageSize: String(Math.max(uniqueIds.length, 1)),
177
- })
178
- const payload = await readApiResultOrThrow<{ items?: CompanyAssociationApiRecord[] }>(
179
- `/api/customers/companies?${params.toString()}`,
180
- )
181
- const items = Array.isArray(payload.items) ? payload.items : []
182
- const byId = new Map<string, CompanyAssociationApiRecord>()
183
- items.forEach((record) => {
184
- if (record && typeof record.id === 'string') byId.set(record.id, record)
185
- })
186
- return uniqueIds.map((companyId) => {
187
- const record = byId.get(companyId)
188
- return record
189
- ? normalizeCompanyAssociationRecord(record, companyId)
190
- : {
191
- id: companyId,
192
- label: companyId,
193
- subtitle: null,
194
- kind: 'company' as const,
195
- }
196
- })
197
- } catch {
198
- return uniqueIds.map((companyId) => ({
199
- id: companyId,
200
- label: companyId,
201
- subtitle: null,
202
- kind: 'company' as const,
203
- }))
204
- }
205
- }, [])
206
-
207
67
  const loadLinkedPeoplePage = React.useCallback(
208
68
  async (page: number, query: string): Promise<LinkedPageResult> => {
209
69
  if (!currentDealId) {
@@ -274,17 +134,7 @@ export function useDealAssociations({
274
134
  ),
275
135
  { id: currentDealId, personIds: nextIds, operation: 'updateDealPeople' },
276
136
  )
277
- const nextPeople = await loadPeopleAssociations(nextIds.slice(0, 3))
278
- setData((prev) =>
279
- prev
280
- ? {
281
- ...prev,
282
- people: nextPeople,
283
- linkedPersonIds: nextIds,
284
- counts: { ...prev.counts, people: nextIds.length },
285
- }
286
- : prev,
287
- )
137
+ await onRefresh()
288
138
  } catch (error) {
289
139
  setPeopleEditorIds(previousIds)
290
140
  setData((prev) =>
@@ -299,14 +149,14 @@ export function useDealAssociations({
299
149
  )
300
150
  // runMutationWithContext already surfaces the conflict bar on a 409; only
301
151
  // fall back to the generic flash when this is not a record conflict.
302
- if (!surfaceRecordConflict(error, t, { onRefresh: onRefresh ?? null })) {
152
+ if (!surfaceRecordConflict(error, t, { onRefresh })) {
303
153
  flash(t('customers.deals.detail.peopleUpdateError', 'Failed to update linked people.'), 'error')
304
154
  }
305
155
  } finally {
306
156
  setPeopleSaving(false)
307
157
  }
308
158
  },
309
- [currentDealId, data?.deal.updatedAt, data?.people, loadPeopleAssociations, onRefresh, peopleEditorIds, runMutationWithContext, setData, t],
159
+ [currentDealId, data?.deal.updatedAt, data?.people, onRefresh, peopleEditorIds, runMutationWithContext, setData, t],
310
160
  )
311
161
 
312
162
  const handleCompaniesAssociationsChange = React.useCallback(
@@ -325,17 +175,7 @@ export function useDealAssociations({
325
175
  ),
326
176
  { id: currentDealId, companyIds: nextIds, operation: 'updateDealCompanies' },
327
177
  )
328
- const nextCompanies = await loadCompanyAssociations(nextIds.slice(0, 3))
329
- setData((prev) =>
330
- prev
331
- ? {
332
- ...prev,
333
- companies: nextCompanies,
334
- linkedCompanyIds: nextIds,
335
- counts: { ...prev.counts, companies: nextIds.length },
336
- }
337
- : prev,
338
- )
178
+ await onRefresh()
339
179
  } catch (error) {
340
180
  setCompaniesEditorIds(previousIds)
341
181
  setData((prev) =>
@@ -350,7 +190,7 @@ export function useDealAssociations({
350
190
  )
351
191
  // runMutationWithContext already surfaces the conflict bar on a 409; only
352
192
  // fall back to the generic flash when this is not a record conflict.
353
- if (!surfaceRecordConflict(error, t, { onRefresh: onRefresh ?? null })) {
193
+ if (!surfaceRecordConflict(error, t, { onRefresh })) {
354
194
  flash(t('customers.deals.detail.companiesUpdateError', 'Failed to update linked companies.'), 'error')
355
195
  }
356
196
  } finally {
@@ -362,7 +202,6 @@ export function useDealAssociations({
362
202
  currentDealId,
363
203
  data?.companies,
364
204
  data?.deal.updatedAt,
365
- loadCompanyAssociations,
366
205
  onRefresh,
367
206
  runMutationWithContext,
368
207
  setData,
@@ -198,7 +198,7 @@ export default function DealDetailPage({ params }: { params?: { id?: string } })
198
198
  data,
199
199
  setData,
200
200
  runMutationWithContext,
201
- onRefresh: () => { void loadData() },
201
+ onRefresh: () => loadData(),
202
202
  })
203
203
 
204
204
  const { isStageSaving, handleStageChange } = useDealPipeline({
@@ -383,12 +383,49 @@ function toNumericString(value: number | null | undefined): string | null {
383
383
  return value.toString()
384
384
  }
385
385
 
386
+ function sameLinkIdSet(next: Set<string>, current: Set<string>): boolean {
387
+ if (next.size !== current.size) return false
388
+ for (const id of next) {
389
+ if (!current.has(id)) return false
390
+ }
391
+ return true
392
+ }
393
+
394
+ /**
395
+ * The deal's optimistic-lock token is `customer_deals.updated_at`, and `CustomerDeal.updatedAt`
396
+ * is declared `onUpdate`-only — it advances only when the deal entity itself enters the change
397
+ * set. A `{ id, personIds }` or `{ id, companyIds }` payload mutates link rows exclusively, so
398
+ * without this explicit touch the token never moves: two clients editing the same deal's links
399
+ * from the same base version both pass the version check and the later stale whole-set payload
400
+ * silently reinstates what the earlier one removed.
401
+ *
402
+ * Assigning the property is what dirties the entity; the `onUpdate` hook then supplies the
403
+ * committed value, so the two do not fight. Same pattern as the profile-only branch in
404
+ * `people.ts`, which touches its parent for exactly this reason.
405
+ *
406
+ * Callers MUST only invoke this when the links actually changed — stamping on a no-op write
407
+ * would invalidate every other session's token on an idle save.
408
+ *
409
+ * ORDERING: this MUST be the last thing a sync helper does. MikroORM v7 discards a pending
410
+ * scalar change on a managed entity when a query runs on the same EntityManager before the
411
+ * flush (SPEC-018) — the same footgun the CRITICAL comment in `updateDealCommand` guards
412
+ * against, and these helpers are exactly the queries it names. Touching before
413
+ * `requireCustomerEntity` runs would silently drop the UPDATE and leave the token frozen.
414
+ */
415
+ function touchDealLockToken(deal: CustomerDeal): void {
416
+ deal.updatedAt = new Date()
417
+ }
418
+
386
419
  async function syncDealPeople(
387
420
  em: EntityManager,
388
421
  deal: CustomerDeal,
389
422
  personIds: string[] | undefined | null,
390
- primaryPersonEntityId?: string | null
423
+ primaryPersonEntityId?: string | null,
424
+ options?: { stampLockToken?: boolean }
391
425
  ): Promise<void> {
426
+ // A freshly created deal has no other session holding a token to invalidate, and stamping
427
+ // there would only make `updated_at` overtake `created_at` on every new deal with links.
428
+ const stampLockToken = options?.stampLockToken !== false
392
429
  if (personIds === undefined) {
393
430
  if (primaryPersonEntityId === undefined) return
394
431
  const links = await em.find(CustomerDealPersonLink, { deal })
@@ -401,6 +438,18 @@ async function syncDealPeople(
401
438
  ),
402
439
  })
403
440
  }
441
+ const currentPrimaryId = links.find((link) => link.isPrimary)?.person?.id ?? null
442
+ // Nothing changed — same invariant the whole-set branch below enforces. Without this the
443
+ // clear loop and its flush would rewrite every link row just to put the flag back on the
444
+ // row it was already on, and would briefly leave the deal with no primary at all inside
445
+ // the transaction. Only one row can carry the flag (partial unique index on
446
+ // `deal_id where is_primary`), so there is no second stale flag left to clean up here.
447
+ if (currentPrimaryId === primaryPersonEntityId) return
448
+ // Safe here: only assignments and the explicit flush below follow — no query runs
449
+ // between this touch and the flush that persists it.
450
+ if (stampLockToken) {
451
+ touchDealLockToken(deal)
452
+ }
404
453
  for (const link of links) {
405
454
  link.isPrimary = false
406
455
  }
@@ -421,13 +470,30 @@ async function syncDealPeople(
421
470
  ),
422
471
  })
423
472
  }
473
+ // Read the current links once: this both resolves the inherited primary (as the previous
474
+ // `findOne(..., { isPrimary: true })` did) and lets us tell a real change from a no-op save,
475
+ // which the lock stamp below depends on.
476
+ const existingLinks = await em.find(CustomerDealPersonLink, { deal })
477
+ const currentPersonIds = new Set(existingLinks.map((link) => link.person.id))
478
+ const currentPrimaryId = existingLinks.find((link) => link.isPrimary)?.person?.id ?? null
479
+
424
480
  let effectivePrimaryId = primaryPersonEntityId
425
481
  if (effectivePrimaryId === undefined) {
426
- const existingPrimary = await em.findOne(CustomerDealPersonLink, { deal, isPrimary: true })
427
- effectivePrimaryId = existingPrimary?.person?.id ?? null
482
+ effectivePrimaryId = currentPrimaryId
428
483
  }
484
+ const linksChanged =
485
+ !sameLinkIdSet(new Set(unique), currentPersonIds) || effectivePrimaryId !== currentPrimaryId
486
+ // Nothing to do. Returning here also stops a no-op save from deleting and recreating every
487
+ // row, which would otherwise discard `participant_role`, `created_at` and the link ids while
488
+ // this function reports the write as a no-op to every other session.
489
+ //
490
+ // NOTE: this only covers the pure no-op. A genuine change below still deletes and recreates
491
+ // every row, so surviving participants do lose those columns — pre-existing behaviour that
492
+ // the set-diff in PR 3 of the linked-people parity spec removes. It is out of scope here
493
+ // because the linked date it corrupts is not rendered until that PR.
494
+ if (!linksChanged) return
495
+
429
496
  await em.nativeDelete(CustomerDealPersonLink, { deal })
430
- if (!unique.length) return
431
497
  for (const personId of unique) {
432
498
  const person = await requireCustomerEntity(em, personId, { tenantId: deal.tenantId, organizationId: deal.organizationId }, 'person', 'Person not found')
433
499
  ensureSameScope(person, deal.organizationId, deal.tenantId)
@@ -438,17 +504,24 @@ async function syncDealPeople(
438
504
  })
439
505
  em.persist(link)
440
506
  }
507
+ // Last statement on purpose — see the ORDERING note on `touchDealLockToken`.
508
+ if (stampLockToken) touchDealLockToken(deal)
441
509
  }
442
510
 
443
511
  async function syncDealCompanies(
444
512
  em: EntityManager,
445
513
  deal: CustomerDeal,
446
- companyIds: string[] | undefined | null
514
+ companyIds: string[] | undefined | null,
515
+ options?: { stampLockToken?: boolean }
447
516
  ): Promise<void> {
448
517
  if (companyIds === undefined) return
518
+ const stampLockToken = options?.stampLockToken !== false
519
+ const unique = Array.from(new Set(companyIds ?? []))
520
+ const existingLinks = await em.find(CustomerDealCompanyLink, { deal })
521
+ const currentCompanyIds = new Set(existingLinks.map((link) => link.company.id))
522
+ if (sameLinkIdSet(new Set(unique), currentCompanyIds)) return
523
+
449
524
  await em.nativeDelete(CustomerDealCompanyLink, { deal })
450
- if (!companyIds || !companyIds.length) return
451
- const unique = Array.from(new Set(companyIds))
452
525
  for (const companyId of unique) {
453
526
  const company = await requireCustomerEntity(em, companyId, { tenantId: deal.tenantId, organizationId: deal.organizationId }, 'company', 'Company not found')
454
527
  ensureSameScope(company, deal.organizationId, deal.tenantId)
@@ -458,6 +531,8 @@ async function syncDealCompanies(
458
531
  })
459
532
  em.persist(link)
460
533
  }
534
+ // Last statement on purpose — see the ORDERING note on `touchDealLockToken`.
535
+ if (stampLockToken) touchDealLockToken(deal)
461
536
  }
462
537
 
463
538
  const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
@@ -530,8 +605,8 @@ const createDealCommand: CommandHandler<DealCreateInput, { dealId: string }> = {
530
605
  transitionedByUserId: normalizedTransitionAuthorUserId,
531
606
  })
532
607
  },
533
- () => syncDealPeople(em, deal, parsed.personIds ?? [], parsed.primaryPersonEntityId),
534
- () => syncDealCompanies(em, deal, parsed.companyIds ?? []),
608
+ () => syncDealPeople(em, deal, parsed.personIds ?? [], parsed.primaryPersonEntityId, { stampLockToken: false }),
609
+ () => syncDealCompanies(em, deal, parsed.companyIds ?? [], { stampLockToken: false }),
535
610
  ], { transaction: true })
536
611
 
537
612
  const de = (ctx.container.resolve('dataEngine') as DataEngine)
@@ -24,7 +24,7 @@ import {
24
24
  } from '@open-mercato/ui/primitives/dialog'
25
25
  import { buildCountryOptions } from '@open-mercato/shared/lib/location/countries'
26
26
  import { buildHrefWithReturnTo } from '@open-mercato/shared/lib/navigation/returnTo'
27
- import type { AddressFormatStrategy } from '../utils/addressFormat'
27
+ import { resolveTaxIdLabel, type AddressFormatStrategy } from '../utils/addressFormat'
28
28
  import { useAddressTypes } from './detail/hooks/useAddressTypes'
29
29
 
30
30
  type Translator = (key: string, fallback?: string, params?: Record<string, string | number>) => string
@@ -41,6 +41,20 @@ export type AddressEditorDraft = {
41
41
  region: string
42
42
  postalCode: string
43
43
  country: string
44
+ /**
45
+ * Contact details that belong to the ADDRESS rather than to the customer: the tax identifier it was
46
+ * invoiced under, and the phone a carrier calls about a delivery. Optional so every existing caller
47
+ * keeps compiling; callers must opt in with the matching `show*Field` props before they render.
48
+ */
49
+ taxId?: string
50
+ /**
51
+ * Which scheme the identifier belongs to, in Stripe's `{country}_{kind}` vocabulary. Chosen, not
52
+ * inferred: `PL1234567890` and `1234567890` are the same business written two ways, and a rule
53
+ * that reads the form of the value is guessing — the more schemes the vocabulary carries, the more
54
+ * often it guesses wrong, and a wrong type is worse than none because it names the number.
55
+ */
56
+ taxIdType?: string
57
+ phone?: string
44
58
  latitude?: string
45
59
  longitude?: string
46
60
  isPrimary: boolean
@@ -58,6 +72,9 @@ export type AddressEditorField =
58
72
  | 'region'
59
73
  | 'postalCode'
60
74
  | 'country'
75
+ | 'taxId'
76
+ | 'taxIdType'
77
+ | 'phone'
61
78
  | 'latitude'
62
79
  | 'longitude'
63
80
  | 'isPrimary'
@@ -72,8 +89,33 @@ type AddressEditorProps = {
72
89
  hidePrimaryToggle?: boolean
73
90
  showFormatHint?: boolean
74
91
  showCoordinateFields?: boolean
92
+ /**
93
+ * Render the phone. Off by default, opt-in for the same reason `showCoordinateFields` is: only a
94
+ * caller whose storage can hold a field should offer it.
95
+ */
96
+ showPhoneField?: boolean
97
+ /**
98
+ * Render the tax identifier and its scheme. Separate from the phone, and not for symmetry — a
99
+ * phone is a contact detail and a tax identifier is not, and the two stop travelling together at
100
+ * the next phase: `CustomerAddress` gains a `phone` column and no tax id, so the address book will
101
+ * offer one and not the other.
102
+ *
103
+ * Both gate the CALLER, not the field. Inside a tile that opts in, the input renders whether or not
104
+ * it carries a value and takes the same `disabled` as every neighbour.
105
+ */
106
+ showTaxIdField?: boolean
75
107
  }
76
108
 
109
+ /**
110
+ * The schemes offered in the picker, in the order a Polish deployment meets them. The vocabulary is
111
+ * additive under the backward-compatibility contract, so a new scheme is a new entry here rather
112
+ * than a new branch in a rule that has to guess.
113
+ *
114
+ * The labels are the ones `resolveTaxIdLabel` resolves, so the picker and the marker beside the
115
+ * filled field always read the same.
116
+ */
117
+ const TAX_ID_TYPES = ['pl_nip', 'eu_vat', 'other'] as const
118
+
77
119
  export function AddressEditor({
78
120
  value,
79
121
  onChange,
@@ -84,6 +126,8 @@ export function AddressEditor({
84
126
  hidePrimaryToggle = false,
85
127
  showFormatHint = true,
86
128
  showCoordinateFields = false,
129
+ showPhoneField = false,
130
+ showTaxIdField = false,
87
131
  }: AddressEditorProps) {
88
132
  const pathname = usePathname()
89
133
  const searchParams = useSearchParams()
@@ -102,6 +146,11 @@ export function AddressEditor({
102
146
  [t],
103
147
  )
104
148
 
149
+ const taxIdLabels = {
150
+ plNip: t('customers.people.detail.addresses.fields.taxId.plNip', 'Tax ID'),
151
+ euVat: t('customers.people.detail.addresses.fields.taxId.euVat', 'EU VAT'),
152
+ other: t('customers.people.detail.addresses.fields.taxId.other', 'Tax number'),
153
+ }
105
154
  const current: AddressEditorDraft = {
106
155
  name: value.name ?? '',
107
156
  purpose: value.purpose ?? '',
@@ -114,6 +163,9 @@ export function AddressEditor({
114
163
  region: value.region ?? '',
115
164
  postalCode: value.postalCode ?? '',
116
165
  country: value.country ?? '',
166
+ taxId: value.taxId ?? '',
167
+ taxIdType: value.taxIdType ?? '',
168
+ phone: value.phone ?? '',
117
169
  ...(showCoordinateFields
118
170
  ? { latitude: value.latitude ?? '', longitude: value.longitude ?? '' }
119
171
  : {}),
@@ -472,6 +524,69 @@ export function AddressEditor({
472
524
  {errors.longitude ? <p className="text-xs text-destructive">{errors.longitude}</p> : null}
473
525
  </>
474
526
  ) : null}
527
+ {showTaxIdField ? (
528
+ <>
529
+ <Select
530
+ value={current.taxIdType || undefined}
531
+ onValueChange={(next) => update('taxIdType', next ?? '')}
532
+ disabled={disabled}
533
+ >
534
+ <SelectTrigger
535
+ className={errors.taxIdType ? 'border-destructive' : undefined}
536
+ aria-label={t('customers.people.detail.addresses.fields.taxIdType', 'Tax id type')}
537
+ aria-invalid={errors.taxIdType ? 'true' : undefined}
538
+ >
539
+ <SelectValue
540
+ placeholder={t('customers.people.detail.addresses.fields.taxIdType', 'Tax id type')}
541
+ />
542
+ </SelectTrigger>
543
+ <SelectContent>
544
+ {TAX_ID_TYPES.map((type) => (
545
+ <SelectItem key={type} value={type}>
546
+ {resolveTaxIdLabel(taxIdLabels, type)}
547
+ </SelectItem>
548
+ ))}
549
+ </SelectContent>
550
+ </Select>
551
+ <Input
552
+ className={inputClass('taxId')}
553
+ placeholder={t('customers.people.detail.addresses.fields.taxId', 'Tax number')}
554
+ aria-label={t('customers.people.detail.addresses.fields.taxId', 'Tax number')}
555
+ rightIcon={
556
+ current.taxId ? (
557
+ <span className="text-xs">{resolveTaxIdLabel(taxIdLabels, current.taxIdType)}</span>
558
+ ) : null
559
+ }
560
+ value={current.taxId ?? ''}
561
+ onChange={(evt) => update('taxId', evt.target.value)}
562
+ disabled={disabled}
563
+ aria-invalid={errors.taxId ? 'true' : undefined}
564
+ />
565
+ {errors.taxId ? <p className="text-xs text-destructive">{errors.taxId}</p> : null}
566
+ </>
567
+ ) : null}
568
+ {showPhoneField ? (
569
+ <>
570
+ <Input
571
+ className={inputClass('phone')}
572
+ placeholder={t('customers.people.detail.addresses.fields.phone', 'Phone')}
573
+ aria-label={t('customers.people.detail.addresses.fields.phone', 'Phone')}
574
+ rightIcon={
575
+ current.phone ? (
576
+ <span className="text-xs">
577
+ {t('customers.people.detail.addresses.fields.phone', 'Phone')}
578
+ </span>
579
+ ) : null
580
+ }
581
+ inputMode="tel"
582
+ value={current.phone ?? ''}
583
+ onChange={(evt) => update('phone', evt.target.value)}
584
+ disabled={disabled}
585
+ aria-invalid={errors.phone ? 'true' : undefined}
586
+ />
587
+ {errors.phone ? <p className="text-xs text-destructive">{errors.phone}</p> : null}
588
+ </>
589
+ ) : null}
475
590
  </div>
476
591
  {!hidePrimaryToggle ? (
477
592
  <label className="inline-flex items-center gap-2 text-sm">
@@ -1867,11 +1867,17 @@
1867
1867
  "customers.people.detail.addresses.fields.line1": "Adresszeile 1",
1868
1868
  "customers.people.detail.addresses.fields.line2": "Adresszeile 2",
1869
1869
  "customers.people.detail.addresses.fields.longitude": "Längengrad",
1870
+ "customers.people.detail.addresses.fields.phone": "Telefon",
1870
1871
  "customers.people.detail.addresses.fields.postalCode": "Postleitzahl",
1871
1872
  "customers.people.detail.addresses.fields.primary": "Als primär markieren",
1872
1873
  "customers.people.detail.addresses.fields.region": "Bundesland / Region",
1873
1874
  "customers.people.detail.addresses.fields.street": "Straße",
1874
1875
  "customers.people.detail.addresses.fields.streetExtra": "Weitere Adressangaben",
1876
+ "customers.people.detail.addresses.fields.taxId": "Steuernummer",
1877
+ "customers.people.detail.addresses.fields.taxId.euVat": "USt-IdNr.",
1878
+ "customers.people.detail.addresses.fields.taxId.other": "Steuerliche Nummer",
1879
+ "customers.people.detail.addresses.fields.taxId.plNip": "Steuernummer",
1880
+ "customers.people.detail.addresses.fields.taxIdType": "Art der Steuernummer",
1875
1881
  "customers.people.detail.addresses.fields.type": "Adresstyp",
1876
1882
  "customers.people.detail.addresses.formatLoadError": "Adresskonfiguration konnte nicht geladen werden",
1877
1883
  "customers.people.detail.addresses.formatLoading": "Adresspräferenzen werden geladen…",
@@ -1867,11 +1867,17 @@
1867
1867
  "customers.people.detail.addresses.fields.line1": "Address line 1",
1868
1868
  "customers.people.detail.addresses.fields.line2": "Address line 2",
1869
1869
  "customers.people.detail.addresses.fields.longitude": "Longitude",
1870
+ "customers.people.detail.addresses.fields.phone": "Phone",
1870
1871
  "customers.people.detail.addresses.fields.postalCode": "Postal code",
1871
1872
  "customers.people.detail.addresses.fields.primary": "Set as primary",
1872
1873
  "customers.people.detail.addresses.fields.region": "Region / State",
1873
1874
  "customers.people.detail.addresses.fields.street": "Street",
1874
1875
  "customers.people.detail.addresses.fields.streetExtra": "Additional address details",
1876
+ "customers.people.detail.addresses.fields.taxId": "Tax number",
1877
+ "customers.people.detail.addresses.fields.taxId.euVat": "EU VAT",
1878
+ "customers.people.detail.addresses.fields.taxId.other": "Tax number",
1879
+ "customers.people.detail.addresses.fields.taxId.plNip": "Tax ID",
1880
+ "customers.people.detail.addresses.fields.taxIdType": "Tax id type",
1875
1881
  "customers.people.detail.addresses.fields.type": "Address type",
1876
1882
  "customers.people.detail.addresses.formatLoadError": "Failed to load address configuration",
1877
1883
  "customers.people.detail.addresses.formatLoading": "Loading address preferences…",
@@ -1867,11 +1867,17 @@
1867
1867
  "customers.people.detail.addresses.fields.line1": "Dirección (línea 1)",
1868
1868
  "customers.people.detail.addresses.fields.line2": "Dirección (línea 2)",
1869
1869
  "customers.people.detail.addresses.fields.longitude": "Longitud",
1870
+ "customers.people.detail.addresses.fields.phone": "Teléfono",
1870
1871
  "customers.people.detail.addresses.fields.postalCode": "Código postal",
1871
1872
  "customers.people.detail.addresses.fields.primary": "Marcar como principal",
1872
1873
  "customers.people.detail.addresses.fields.region": "Provincia / región",
1873
1874
  "customers.people.detail.addresses.fields.street": "Calle",
1874
1875
  "customers.people.detail.addresses.fields.streetExtra": "Detalles adicionales de la dirección",
1876
+ "customers.people.detail.addresses.fields.taxId": "Número fiscal",
1877
+ "customers.people.detail.addresses.fields.taxId.euVat": "IVA UE",
1878
+ "customers.people.detail.addresses.fields.taxId.other": "Número fiscal",
1879
+ "customers.people.detail.addresses.fields.taxId.plNip": "NIF",
1880
+ "customers.people.detail.addresses.fields.taxIdType": "Tipo de número fiscal",
1875
1881
  "customers.people.detail.addresses.fields.type": "Tipo de dirección",
1876
1882
  "customers.people.detail.addresses.formatLoadError": "No se pudo cargar la configuración de la dirección",
1877
1883
  "customers.people.detail.addresses.formatLoading": "Cargando preferencias de dirección…",