@stapel/listings-react 0.4.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 (107) hide show
  1. package/CHANGELOG.md +102 -0
  2. package/dist/api/generated/schema.d.ts +2 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/default/FavoritesPane.d.ts +40 -3
  5. package/dist/default/FavoritesPane.d.ts.map +1 -1
  6. package/dist/default/FavoritesPane.js +30 -18
  7. package/dist/default/FavoritesPane.js.map +1 -1
  8. package/dist/default/ListingCard.d.ts +25 -4
  9. package/dist/default/ListingCard.d.ts.map +1 -1
  10. package/dist/default/ListingCard.js +19 -12
  11. package/dist/default/ListingCard.js.map +1 -1
  12. package/dist/default/ListingComposerPage.d.ts +163 -23
  13. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  14. package/dist/default/ListingComposerPage.js +85 -43
  15. package/dist/default/ListingComposerPage.js.map +1 -1
  16. package/dist/default/ListingDetailPane.d.ts +41 -4
  17. package/dist/default/ListingDetailPane.d.ts.map +1 -1
  18. package/dist/default/ListingDetailPane.js +38 -25
  19. package/dist/default/ListingDetailPane.js.map +1 -1
  20. package/dist/default/ListingPhoto.d.ts +13 -1
  21. package/dist/default/ListingPhoto.d.ts.map +1 -1
  22. package/dist/default/ListingPhoto.js +47 -9
  23. package/dist/default/ListingPhoto.js.map +1 -1
  24. package/dist/default/ListingPrice.d.ts +13 -0
  25. package/dist/default/ListingPrice.d.ts.map +1 -0
  26. package/dist/default/ListingPrice.js +60 -0
  27. package/dist/default/ListingPrice.js.map +1 -0
  28. package/dist/default/MyListingsPane.d.ts +7 -28
  29. package/dist/default/MyListingsPane.d.ts.map +1 -1
  30. package/dist/default/MyListingsPane.js +136 -67
  31. package/dist/default/MyListingsPane.js.map +1 -1
  32. package/dist/default/SignInLink.js +8 -7
  33. package/dist/default/SignInLink.js.map +1 -1
  34. package/dist/default/StatusTags.d.ts +16 -5
  35. package/dist/default/StatusTags.d.ts.map +1 -1
  36. package/dist/default/StatusTags.js +19 -13
  37. package/dist/default/StatusTags.js.map +1 -1
  38. package/dist/default/index.d.ts +15 -9
  39. package/dist/default/index.d.ts.map +1 -1
  40. package/dist/default/index.js +13 -6
  41. package/dist/default/index.js.map +1 -1
  42. package/dist/headless/Favorites.d.ts +6 -0
  43. package/dist/headless/Favorites.d.ts.map +1 -1
  44. package/dist/headless/Favorites.js +4 -0
  45. package/dist/headless/Favorites.js.map +1 -1
  46. package/dist/headless/ListingActions.d.ts +12 -0
  47. package/dist/headless/ListingActions.d.ts.map +1 -1
  48. package/dist/headless/ListingActions.js +3 -0
  49. package/dist/headless/ListingActions.js.map +1 -1
  50. package/dist/headless/ListingComposer.d.ts +23 -0
  51. package/dist/headless/ListingComposer.d.ts.map +1 -1
  52. package/dist/headless/ListingComposer.js +33 -7
  53. package/dist/headless/ListingComposer.js.map +1 -1
  54. package/dist/i18n/es.d.ts +6 -1
  55. package/dist/i18n/es.d.ts.map +1 -1
  56. package/dist/i18n/es.js +21 -4
  57. package/dist/i18n/es.js.map +1 -1
  58. package/dist/i18n/keys.d.ts +19 -2
  59. package/dist/i18n/keys.d.ts.map +1 -1
  60. package/dist/i18n/keys.js +35 -5
  61. package/dist/i18n/keys.js.map +1 -1
  62. package/dist/i18n/ru.d.ts +16 -1
  63. package/dist/i18n/ru.d.ts.map +1 -1
  64. package/dist/i18n/ru.js +33 -4
  65. package/dist/i18n/ru.js.map +1 -1
  66. package/dist/model/draft.d.ts +9 -6
  67. package/dist/model/draft.d.ts.map +1 -1
  68. package/dist/model/draft.js +3 -1
  69. package/dist/model/draft.js.map +1 -1
  70. package/dist/model/features.d.ts +21 -7
  71. package/dist/model/features.d.ts.map +1 -1
  72. package/dist/model/features.js +26 -0
  73. package/dist/model/features.js.map +1 -1
  74. package/llms.txt +7 -6
  75. package/manifest.json +53 -11
  76. package/nav-manifest.json +1 -1
  77. package/package.json +17 -12
  78. package/src/analytics/generated/events.json +1 -1
  79. package/src/api/generated/schema.ts +2 -2
  80. package/src/default/FavoritesPane.tsx +156 -82
  81. package/src/default/ListingCard.tsx +88 -46
  82. package/src/default/ListingComposerPage.tsx +403 -187
  83. package/src/default/ListingDetailPane.tsx +200 -98
  84. package/src/default/ListingPhoto.tsx +89 -16
  85. package/src/default/ListingPrice.tsx +77 -0
  86. package/src/default/MyListingsPane.tsx +286 -228
  87. package/src/default/SignInLink.tsx +4 -4
  88. package/src/default/StatusTags.tsx +36 -18
  89. package/src/default/index.ts +18 -7
  90. package/src/headless/Favorites.tsx +10 -0
  91. package/src/headless/ListingActions.tsx +19 -0
  92. package/src/headless/ListingComposer.tsx +56 -5
  93. package/src/i18n/es.ts +26 -4
  94. package/src/i18n/keys.ts +42 -5
  95. package/src/i18n/ru.ts +38 -4
  96. package/src/model/draft.ts +12 -7
  97. package/src/model/features.ts +48 -7
  98. package/dist/default/ErrorAlert.d.ts +0 -9
  99. package/dist/default/ErrorAlert.d.ts.map +0 -1
  100. package/dist/default/ErrorAlert.js +0 -27
  101. package/dist/default/ErrorAlert.js.map +0 -1
  102. package/dist/default/theme.d.ts +0 -25
  103. package/dist/default/theme.d.ts.map +0 -1
  104. package/dist/default/theme.js +0 -40
  105. package/dist/default/theme.js.map +0 -1
  106. package/src/default/ErrorAlert.tsx +0 -46
  107. package/src/default/theme.tsx +0 -67
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The two axes, drawn as two.
2
+ * The two axes, drawn as two — in ONE visual treatment.
3
3
  *
4
4
  * `<LifecycleTag>` is the state; `<ModerationNote>` is what the moderation
5
5
  * axis adds ON TOP of it, and it renders nothing when there is nothing to add
@@ -8,13 +8,24 @@
8
8
  * than one overwriting the other — the failure `model/status.ts` exists to
9
9
  * prevent, made visual.
10
10
  *
11
+ * ── Why the note is a line and not an Alert ────────────────────────────────
12
+ *
13
+ * It was an antd `Alert`, toned by the moderation status, and the visual pass
14
+ * measured the result: one concept drawn three ways down a single dashboard —
15
+ * a full-bleed olive bar for one row, a full-bleed grey bar for the next, bare
16
+ * red text for a third — and a phone screen where a 340px khaki box wrapped
17
+ * one sentence over six ragged lines. A status is a status: ONE tag carrying
18
+ * the tone, and the sentence beneath it as ordinary text. Loud is not the
19
+ * same as clear, and a row that shouts three different ways is neither.
20
+ *
11
21
  * A tone maps to an antd preset here and nowhere else. No hex leaves this
12
22
  * file, because none enters it: the presets resolve through the theme, which
13
23
  * resolves through `@stapel/tokens`.
14
24
  */
15
25
  import type { ReactElement } from "react";
16
- import { Alert, Tag } from "antd";
26
+ import { Flex, Tag, Typography, theme as antdTheme } from "antd";
17
27
  import { useT } from "@stapel/core";
28
+ import { spacing } from "@stapel/tokens";
18
29
  import type { ListingStatusTone, ListingStatusView } from "../model/status.js";
19
30
 
20
31
  const TONE_COLOR: Readonly<Record<ListingStatusTone, string>> = {
@@ -25,14 +36,17 @@ const TONE_COLOR: Readonly<Record<ListingStatusTone, string>> = {
25
36
  stopped: "error",
26
37
  };
27
38
 
28
- const TONE_ALERT: Readonly<
29
- Record<ListingStatusTone, "info" | "success" | "warning" | "error">
39
+ /** How urgent the sentence beneath the tag reads. `warning` is the only tone
40
+ * that asks the owner to DO something, so it is the only one that gets antd's
41
+ * danger colour; the rest are secondary text. */
42
+ const TONE_TEXT: Readonly<
43
+ Record<ListingStatusTone, "secondary" | "warning" | "success">
30
44
  > = {
31
- neutral: "info",
32
- waiting: "info",
45
+ neutral: "secondary",
46
+ waiting: "secondary",
33
47
  good: "success",
34
48
  warning: "warning",
35
- stopped: "error",
49
+ stopped: "warning",
36
50
  };
37
51
 
38
52
  export interface ListingStatusProps {
@@ -40,7 +54,7 @@ export interface ListingStatusProps {
40
54
  }
41
55
 
42
56
  /** The lifecycle state — the field that decides whether anyone else can see
43
- * this listing. */
57
+ * this listing. One tag, never full-bleed, never bare text. */
44
58
  export function LifecycleTag(props: ListingStatusProps): ReactElement {
45
59
  const t = useT();
46
60
  return (
@@ -48,6 +62,7 @@ export function LifecycleTag(props: ListingStatusProps): ReactElement {
48
62
  color={TONE_COLOR[props.status.lifecycle.tone]}
49
63
  data-testid="listings-status-tag"
50
64
  data-listing-status={props.status.lifecycle.status}
65
+ style={{ marginInlineEnd: 0 }}
51
66
  >
52
67
  {t(props.status.lifecycle.labelKey)}
53
68
  </Tag>
@@ -55,32 +70,35 @@ export function LifecycleTag(props: ListingStatusProps): ReactElement {
55
70
  }
56
71
 
57
72
  /**
58
- * What the moderation axis adds. Renders `null` when it adds nothing, so a
59
- * calm listing shows no banner at all.
73
+ * What the moderation axis adds, as one sentence. Renders `null` when it adds
74
+ * nothing, so a calm listing shows no note at all.
60
75
  */
61
76
  export function ModerationNote(props: ListingStatusProps): ReactElement | null {
62
77
  const t = useT();
78
+ const { token } = antdTheme.useToken();
63
79
  const notice = props.status.moderation;
64
80
  if (notice === undefined) return null;
65
81
  return (
66
- <Alert
67
- type={TONE_ALERT[notice.tone]}
68
- showIcon
82
+ <Typography.Text
83
+ type={TONE_TEXT[notice.tone]}
69
84
  data-testid="listings-moderation-note"
70
85
  data-listing-moderation={notice.moderationStatus}
71
86
  data-listing-live-under-review={String(notice.liveDuringReview)}
72
- message={t(notice.messageKey)}
73
- />
87
+ style={{ fontSize: token.fontSizeSM }}
88
+ >
89
+ {t(notice.messageKey)}
90
+ </Typography.Text>
74
91
  );
75
92
  }
76
93
 
77
94
  /** Both, in the order a person reads them: what it IS, then what is happening
78
- * to it. */
95
+ * to it. A column so the sentence can never be squeezed beside the tag on a
96
+ * 390px row — the shape that split "Draf/t" across two lines. */
79
97
  export function ListingStatusBlock(props: ListingStatusProps): ReactElement {
80
98
  return (
81
- <>
99
+ <Flex vertical align="flex-start" gap={spacing[1]} style={{ minWidth: 0 }}>
82
100
  <LifecycleTag status={props.status} />
83
101
  <ModerationNote status={props.status} />
84
- </>
102
+ </Flex>
85
103
  );
86
104
  }
@@ -13,23 +13,37 @@
13
13
  * // the search pair's card slot — the container is the seam, not an import
14
14
  * <SearchPage renderCard={(item) => <ListingCard listing={item.card} href={`/l/${item.id}`} />} />
15
15
  * ```
16
+ *
17
+ * ── What this barrel no longer exports, and where it went ──────────────────
18
+ *
19
+ * `ListingsSkinTheme` and `ErrorAlert` were this pair's copies of two
20
+ * components nine pairs each carried a copy of. They live in
21
+ * `@stapel/tokens-antd/skin` now, as `SkinTheme` and `ErrorAlert`: one place
22
+ * for the reactive-theme fix, one place for the message/detail split. A host
23
+ * that wrapped its own composition in `<ListingsSkinTheme>` imports
24
+ * `<SkinTheme>` from the substrate instead — same props, plus a `surface`.
16
25
  */
17
26
  export { ListingCard } from "./ListingCard.js";
18
27
  export type {
19
28
  ListingCardProps,
20
29
  ListingCardBaseProps,
21
30
  ListingCardOpenProps,
31
+ ListingCardBlockedReason,
22
32
  } from "./ListingCard.js";
23
- export { ListingDetailPane } from "./ListingDetailPane.js";
33
+ export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDetailPane.js";
24
34
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
25
- export { ListingComposerPage } from "./ListingComposerPage.js";
35
+ export { ListingComposerPage, COMPOSER_MEASURE } from "./ListingComposerPage.js";
26
36
  export type {
27
37
  ListingComposerPageProps,
28
38
  ComposerCategorySlot,
39
+ ComposerCurrencySlot,
40
+ ComposerLocationSlot,
41
+ ComposerLocationValue,
42
+ ComposerLocationPickerProps,
29
43
  } from "./ListingComposerPage.js";
30
44
  export { MyListingsPane } from "./MyListingsPane.js";
31
45
  export type { MyListingsPaneProps } from "./MyListingsPane.js";
32
- export { FavoritesPane } from "./FavoritesPane.js";
46
+ export { FavoritesPane, FAVORITES_CARD_MIN } from "./FavoritesPane.js";
33
47
  export type {
34
48
  FavoritesPaneProps,
35
49
  FavoritesPaneOpenProps,
@@ -37,11 +51,8 @@ export type {
37
51
 
38
52
  export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
39
53
  export type { ListingStatusProps } from "./StatusTags.js";
40
- export { ListingPhoto } from "./ListingPhoto.js";
54
+ export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
41
55
  export type { ListingPhotoProps } from "./ListingPhoto.js";
42
- export { ErrorAlert } from "./ErrorAlert.js";
43
56
  export { SignInLink } from "./SignInLink.js";
44
57
  export type { SignInLinkProps } from "./SignInLink.js";
45
- export { ListingsSkinTheme } from "./theme.js";
46
- export type { ListingsSkinThemeProps } from "./theme.js";
47
58
  export type { ThemeModeProp } from "./types.js";
@@ -71,6 +71,12 @@ export function useFavoriteToggle(
71
71
  export interface FavoritesBag {
72
72
  readonly rows: LoadState<readonly ListingCard[]>;
73
73
  readonly page: ListingPageParams;
74
+ /**
75
+ * Which page is on screen, 1-based. Keyset paging has no offset to read a
76
+ * number off, so it is counted: a pager with no indicator leaves a person
77
+ * unable to tell a second page from a reloaded first one.
78
+ */
79
+ readonly pageNumber: number;
74
80
  readonly nextPage: ActionAvailability;
75
81
  readonly prevPage: ActionAvailability;
76
82
  goNext(): void;
@@ -90,6 +96,7 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
90
96
  const [page, setPage] = useState<ListingPageParams>(
91
97
  options.limit !== undefined ? { limit: options.limit } : {}
92
98
  );
99
+ const [pageNumber, setPageNumber] = useState(1);
93
100
  const query = useMyFavorites(page, { enabled: gate.available });
94
101
  const envelope = query.data;
95
102
 
@@ -101,6 +108,7 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
101
108
  ? loadReady(envelope.items)
102
109
  : loadLoading(),
103
110
  page,
111
+ pageNumber,
104
112
  nextPage:
105
113
  envelope?.has_next === true && envelope.next_anchor != null
106
114
  ? actionAvailable()
@@ -113,11 +121,13 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
113
121
  const anchor = envelope?.next_anchor;
114
122
  if (anchor == null) return;
115
123
  setPage((current) => ({ ...current, anchor, direction: "next" }));
124
+ setPageNumber((current) => current + 1);
116
125
  },
117
126
  goPrev: () => {
118
127
  const anchor = envelope?.prev_anchor;
119
128
  if (anchor == null) return;
120
129
  setPage((current) => ({ ...current, anchor, direction: "prev" }));
130
+ setPageNumber((current) => Math.max(1, current - 1));
121
131
  },
122
132
  gate,
123
133
  refetch: () => {
@@ -33,6 +33,18 @@ export interface ListingActionsBag {
33
33
  readonly archive: ActionAvailability;
34
34
  readonly complete: ActionAvailability;
35
35
  readonly remove: ActionAvailability;
36
+ /**
37
+ * Whether "edit this listing" is offerable — and if not, WHY.
38
+ *
39
+ * Editing is not an endpoint this pair calls: the composer is a screen, and
40
+ * whether the app has one is the container's fact, which is why it arrives
41
+ * as an argument rather than being derived. It is a gate all the same,
42
+ * because the alternative shipped for two releases: `<GatedButton gate={{
43
+ * available: true }}>` beside an absent `onEdit`, i.e. an enabled button
44
+ * that did nothing at all in the scripted scaffold (§83, "a control that
45
+ * offers something meaningless in the current state").
46
+ */
47
+ editGate(hasEditor: boolean): ActionAvailability;
36
48
  doArchive(): void;
37
49
  doComplete(): void;
38
50
  doRemove(): void;
@@ -86,6 +98,13 @@ export function useListingActions(
86
98
  archive: archiveGate,
87
99
  complete: completeGate,
88
100
  remove: removeGate,
101
+ editGate: (hasEditor) =>
102
+ firstBlock(
103
+ mandate,
104
+ hasEditor
105
+ ? actionAvailable()
106
+ : actionBlocked(LISTINGS_I18N_KEYS.blockedNoEditor)
107
+ ),
89
108
  doArchive: () => {
90
109
  if (archiveGate.available) archive.mutate(id);
91
110
  },
@@ -122,6 +122,18 @@ export interface UseListingComposerOptions {
122
122
  /** Value types the rendering half can draw — pass
123
123
  * `BUILTIN_VALUE_EDITOR_TYPES` from `@stapel/attributes-react/default`. */
124
124
  readonly editorTypes?: readonly string[];
125
+ /**
126
+ * The language a NEW draft is written in — the composer's own UI locale, in
127
+ * practice (`useI18n().locale`).
128
+ *
129
+ * `Listing.language` is sent on every save and was never settable: a blank
130
+ * value let the server's deployment default decide, so a Spanish seller's
131
+ * listing was filed as Russian because that is what the storefront defaults
132
+ * to. Seeding it from the locale the form is being READ in is the only
133
+ * honest guess a library can make, and a host that knows better passes
134
+ * `initialValues.language`, which still wins.
135
+ */
136
+ readonly language?: string;
125
137
  /** The upload queue. Absent: no gallery, and `images` is set through
126
138
  * `setValue` by whatever the host uses instead. */
127
139
  readonly images?: ListingImagesBag;
@@ -186,6 +198,17 @@ export interface ListingComposerBag {
186
198
  readonly saveGate: ActionAvailability;
187
199
  readonly publishGate: ActionAvailability;
188
200
  save(): void;
201
+ /**
202
+ * Save once the state written in this same handler has landed.
203
+ *
204
+ * `save()` closes over the values of the render it was created in, so a
205
+ * picker doing `setLocation(place); save()` in one click saved the draft as
206
+ * it was BEFORE the place was chosen — the plain fields never hit this
207
+ * because a blur is always a separate tick from the keystroke. A picker has
208
+ * no blur: choosing a suggestion IS the commit, so it needs a save that runs
209
+ * after the write it belongs to.
210
+ */
211
+ saveSoon(): void;
189
212
  publish(): void;
190
213
  readonly saving: boolean;
191
214
  readonly publishing: boolean;
@@ -204,7 +227,10 @@ export function useListingComposer(
204
227
  options.listingId
205
228
  );
206
229
  const [values, setValues] = useState<ListingDraftValues>(() => ({
207
- ...emptyDraftValues({ currency: runtime.currency }),
230
+ ...emptyDraftValues({
231
+ currency: runtime.currency,
232
+ ...(options.language !== undefined ? { language: options.language } : {}),
233
+ }),
208
234
  ...options.initialValues,
209
235
  }));
210
236
  const [showErrors, setShowErrors] = useState(false);
@@ -212,6 +238,9 @@ export function useListingComposer(
212
238
  const [refusal, setRefusal] = useState<PublishRefusal | undefined>(undefined);
213
239
  const [outcome, setOutcome] = useState<PublishOutcome | undefined>(undefined);
214
240
  const [saved, setSaved] = useState(false);
241
+ // A counter rather than a boolean: two picks in a row are two saves, and a
242
+ // boolean that was already `true` would swallow the second.
243
+ const [saveRequest, setSaveRequest] = useState(0);
215
244
 
216
245
  const existing = useListing(options.listingId);
217
246
  const createDraft = useCreateDraft();
@@ -349,18 +378,26 @@ export function useListingComposer(
349
378
  mandate,
350
379
  categoryGate,
351
380
  schemaGate,
381
+ // The type names (`size_grid`) are this build's vocabulary, not copy: a
382
+ // seller can act on "finish it somewhere else", never on the identifier.
352
383
  unsupported.length > 0
353
- ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedUnsupportedType, {
354
- types: unsupported.join(", "),
355
- })
384
+ ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedUnsupportedType)
356
385
  : actionAvailable(),
357
386
  options.images?.settled ?? actionAvailable(),
358
387
  busyGate,
359
388
  publishListing.isPending
360
389
  ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedBusy)
361
390
  : actionAvailable(),
391
+ // "Fix the highlighted fields first" is only true once something IS
392
+ // highlighted — the mirror reaches the fields only after `showErrors`.
393
+ // Before that the same block has to say what is actually missing.
362
394
  Object.keys(mirror).length > 0
363
- ? actionBlocked(LISTINGS_I18N_KEYS.composeBlockedMirror)
395
+ ? actionBlocked(
396
+ showErrors
397
+ ? LISTINGS_I18N_KEYS.composeBlockedMirror
398
+ : LISTINGS_I18N_KEYS.composeBlockedIncomplete,
399
+ showErrors ? undefined : { count: Object.keys(mirror).length }
400
+ )
364
401
  : actionAvailable()
365
402
  );
366
403
 
@@ -401,6 +438,19 @@ export function useListingComposer(
401
438
  });
402
439
  }, [saveGate.available, persist]);
403
440
 
441
+ // `save` through a ref, so the effect below can run the CURRENT one without
442
+ // re-firing every time the form changes shape.
443
+ const saveRef = useRef(save);
444
+ saveRef.current = save;
445
+ useEffect(() => {
446
+ if (saveRequest === 0) return;
447
+ saveRef.current();
448
+ }, [saveRequest]);
449
+
450
+ const saveSoon = useCallback((): void => {
451
+ setSaveRequest((current) => current + 1);
452
+ }, []);
453
+
404
454
  const publish = useCallback((): void => {
405
455
  setShowErrors(true);
406
456
  if (!publishGate.available) return;
@@ -489,6 +539,7 @@ export function useListingComposer(
489
539
  saveGate,
490
540
  publishGate,
491
541
  save,
542
+ saveSoon,
492
543
  publish,
493
544
  saving: busy,
494
545
  publishing: publishListing.isPending,
package/src/i18n/es.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { I18nDictionary, I18nEngine } from "@stapel/core";
2
+ import { registerAttributesI18nEs } from "@stapel/attributes-react/i18n/es";
2
3
  import { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
3
4
 
4
5
  export { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
@@ -94,6 +95,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
94
95
  "listings.detail.published_at": "Publicado el {date}",
95
96
  "listings.detail.expires_at": "A la venta hasta el {date}",
96
97
  "listings.detail.stock": "Disponibles",
98
+ "listings.detail.edit": "Editar el anuncio",
99
+ "listings.detail.take_down": "Retirarlo",
97
100
 
98
101
  "listings.compose.new_title": "Anuncio nuevo",
99
102
  "listings.compose.edit_title": "Editar el anuncio",
@@ -112,8 +115,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
112
115
  "Escribe el precio como un número, con dos decimales como mucho",
113
116
  "listings.compose.currency_label": "Moneda",
114
117
  "listings.compose.location_label": "Dónde está",
115
- "listings.compose.lat_label": "Latitud",
116
- "listings.compose.lon_label": "Longitud",
118
+ "listings.compose.location_help":
119
+ "La gente busca por distancia — un anuncio sin lugar es un anuncio que no encontrarán",
117
120
  "listings.compose.geo_incomplete":
118
121
  "Una latitud necesita su longitud al lado — media coordenada no apunta a ningún sitio",
119
122
  "listings.compose.photos": "Fotos",
@@ -143,12 +146,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
143
146
  "listings.compose.blocked.no_category":
144
147
  "Elige una categoría — el resto del formulario depende de ella",
145
148
  "listings.compose.blocked.unsupported_type":
146
- "Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar ({types}), así que aquí no se puede rellenar",
149
+ "Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar, así que el anuncio no se puede completar aquí",
147
150
  "listings.compose.blocked.photos_pending":
148
151
  "Espera a que terminen de subirse las fotos",
149
152
  "listings.compose.blocked.no_draft": "El borrador todavía no está creado",
150
153
  "listings.compose.blocked.busy":
151
154
  "Un momento — el último cambio se está guardando",
155
+ "listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
152
156
  "listings.compose.blocked.mirror": "Corrige primero los campos marcados",
153
157
  "listings.compose.blocked.details_unavailable":
154
158
  "No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
@@ -167,6 +171,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
167
171
  "listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
168
172
  "listings.mine.blocked.title":
169
173
  "Moderación retiró {count} de tus anuncios",
174
+ "listings.mine.blocked.title.one": "Moderación retiró uno de tus anuncios",
175
+ "listings.mine.blocked.title.other": "Moderación retiró {count} de tus anuncios",
170
176
  "listings.mine.blocked.load_failed":
171
177
  "No pudimos comprobar si alguno de tus anuncios fue retirado",
172
178
  "listings.mine.live_under_review": "Publicado, cambios en revisión",
@@ -174,11 +180,18 @@ export const listingsI18nBundleEs: I18nDictionary = {
174
180
  "listings.mine.archive": "Archivar",
175
181
  "listings.mine.complete": "Marcar como vendido",
176
182
  "listings.mine.delete": "Borrar",
183
+ "listings.mine.delete_confirm_title": "¿Borrar este anuncio?",
184
+ "listings.mine.delete_confirm_body":
185
+ "Desaparece de tu panel y no se puede recuperar. Archivarlo lo conserva.",
177
186
 
178
187
  "listings.favorites.title": "Favoritos",
179
188
  "listings.favorites.loading": "Cargando tus favoritos…",
180
189
  "listings.favorites.load_failed": "No pudimos cargar tus favoritos",
181
190
  "listings.favorites.empty": "Todavía no has guardado nada",
191
+ "listings.favorites.empty_hint":
192
+ "Toca el corazón en cualquier anuncio y te estará esperando aquí.",
193
+ "listings.favorites.sign_in_hint":
194
+ "Los favoritos se guardan en tu cuenta, así que te siguen entre dispositivos.",
182
195
 
183
196
  "listings.blocked.sign_in": "Inicia sesión para hacer esto",
184
197
  "listings.blocked.guest":
@@ -190,9 +203,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
190
203
  "listings.blocked.delete_active":
191
204
  "Archívalo primero — un anuncio a la venta no se puede borrar",
192
205
  "listings.blocked.in_flight": "Un momento — eso ya está en marcha",
206
+ "listings.blocked.no_editor":
207
+ "Esta aplicación todavía no tiene una pantalla para editar un anuncio",
193
208
 
194
209
  "listings.page.prev": "Anterior",
195
210
  "listings.page.next": "Siguiente",
211
+ "listings.page.indicator": "Página {page}",
196
212
 
197
213
  "listings.nav.detail": "Anuncio",
198
214
  "listings.nav.compose": "Poner un anuncio",
@@ -200,7 +216,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
200
216
  "listings.nav.favorites": "Favoritos",
201
217
  };
202
218
 
203
- /** Register the Spanish bundle into a core i18n engine. */
219
+ /**
220
+ * Register the Spanish bundle — and, exactly as in `./ru.ts`, the twelve
221
+ * `stapel_attributes` sentences this pair does not author. The ownership split
222
+ * is a rule about who WRITES a string, not an instruction to ship two thirds
223
+ * of a locale and document the rest.
224
+ */
204
225
  export function registerListingsI18nEs(i18n: I18nEngine): void {
226
+ registerAttributesI18nEs(i18n);
205
227
  i18n.registerBundle("es", listingsI18nBundleEs);
206
228
  }
package/src/i18n/keys.ts CHANGED
@@ -88,6 +88,9 @@ export const LISTINGS_I18N_KEYS = {
88
88
  * quantity in the value cell, so this key carries no `{count}` — it used to,
89
89
  * and the live page printed the placeholder. */
90
90
  detailStock: "listings.detail.stock",
91
+ /** The owner's two primaries on their own listing page. */
92
+ detailEdit: "listings.detail.edit",
93
+ detailTakeDown: "listings.detail.take_down",
91
94
 
92
95
  // ── composer ─────────────────────────────────────────────────────────────
93
96
  composeNewTitle: "listings.compose.new_title",
@@ -103,8 +106,7 @@ export const LISTINGS_I18N_KEYS = {
103
106
  composePriceInvalid: "listings.compose.price_invalid",
104
107
  composeCurrencyLabel: "listings.compose.currency_label",
105
108
  composeLocationLabel: "listings.compose.location_label",
106
- composeLatLabel: "listings.compose.lat_label",
107
- composeLonLabel: "listings.compose.lon_label",
109
+ composeLocationHelp: "listings.compose.location_help",
108
110
  composeGeoIncomplete: "listings.compose.geo_incomplete",
109
111
  composePhotos: "listings.compose.photos",
110
112
  composeTooManyImages: "listings.compose.too_many_images",
@@ -132,6 +134,10 @@ export const LISTINGS_I18N_KEYS = {
132
134
  composeBlockedNoDraft: "listings.compose.blocked.no_draft",
133
135
  composeBlockedBusy: "listings.compose.blocked.busy",
134
136
  composeBlockedMirror: "listings.compose.blocked.mirror",
137
+ /** The same refusal BEFORE anything is marked: "fix the highlighted fields"
138
+ * pointed at nothing highlighted, because the mirror only becomes visible
139
+ * once the person has tried to publish. */
140
+ composeBlockedIncomplete: "listings.compose.blocked.incomplete",
135
141
  composeBlockedDetailsUnavailable: "listings.compose.blocked.details_unavailable",
136
142
 
137
143
  // ── the owner's dashboard ────────────────────────────────────────────────
@@ -158,12 +164,18 @@ export const LISTINGS_I18N_KEYS = {
158
164
  mineArchive: "listings.mine.archive",
159
165
  mineComplete: "listings.mine.complete",
160
166
  mineDelete: "listings.mine.delete",
167
+ /** Deleting is irreversible, so it asks — through the shared SkinConfirm,
168
+ * which is a bottom sheet on a phone. */
169
+ mineDeleteConfirmTitle: "listings.mine.delete_confirm_title",
170
+ mineDeleteConfirmBody: "listings.mine.delete_confirm_body",
161
171
 
162
172
  // ── favourites ───────────────────────────────────────────────────────────
163
173
  favoritesTitle: "listings.favorites.title",
164
174
  favoritesLoading: "listings.favorites.loading",
165
175
  favoritesLoadFailed: "listings.favorites.load_failed",
166
176
  favoritesEmpty: "listings.favorites.empty",
177
+ favoritesEmptyHint: "listings.favorites.empty_hint",
178
+ favoritesSignInHint: "listings.favorites.sign_in_hint",
167
179
 
168
180
  // ── gates shared by every action a visitor cannot take ───────────────────
169
181
  blockedSignIn: "listings.blocked.sign_in",
@@ -174,10 +186,15 @@ export const LISTINGS_I18N_KEYS = {
174
186
  blockedTransition: "listings.blocked.transition",
175
187
  blockedDeleteActive: "listings.blocked.delete_active",
176
188
  blockedInFlight: "listings.blocked.in_flight",
189
+ /** Editing is a SCREEN, not an endpoint: whether this app has one is the
190
+ * container's fact, and a host that wires no `onEdit` gets a switched-off
191
+ * button that says so instead of one that silently does nothing. */
192
+ blockedNoEditor: "listings.blocked.no_editor",
177
193
 
178
194
  // ── keyset pagination ────────────────────────────────────────────────────
179
195
  pagePrev: "listings.page.prev",
180
196
  pageNext: "listings.page.next",
197
+ pageIndicator: "listings.page.indicator",
181
198
 
182
199
  // ── nav labels ───────────────────────────────────────────────────────────
183
200
  navDetail: "listings.nav.detail",
@@ -252,6 +269,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
252
269
  "listings.detail.published_at": "Published {date}",
253
270
  "listings.detail.expires_at": "Listed until {date}",
254
271
  "listings.detail.stock": "In stock",
272
+ "listings.detail.edit": "Edit listing",
273
+ "listings.detail.take_down": "Take it down",
255
274
 
256
275
  "listings.compose.new_title": "New listing",
257
276
  "listings.compose.edit_title": "Edit listing",
@@ -270,8 +289,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
270
289
  "Enter a price as a number, with at most two decimals",
271
290
  "listings.compose.currency_label": "Currency",
272
291
  "listings.compose.location_label": "Where it is",
273
- "listings.compose.lat_label": "Latitude",
274
- "listings.compose.lon_label": "Longitude",
292
+ "listings.compose.location_help":
293
+ "Buyers filter by distance, so a listing with no place is a listing they will not find",
275
294
  "listings.compose.geo_incomplete":
276
295
  "A latitude needs a longitude beside it — half a coordinate points nowhere",
277
296
  "listings.compose.photos": "Photos",
@@ -300,14 +319,18 @@ export const listingsI18nBundleEn: Record<string, string> = {
300
319
 
301
320
  "listings.compose.blocked.no_category":
302
321
  "Choose a category — the rest of the form depends on it",
322
+ // No `{types}`: the editor type is this build's vocabulary (`size_grid`),
323
+ // and a seller can do nothing with it. The fact they CAN act on is that
324
+ // this listing has to be finished somewhere else.
303
325
  "listings.compose.blocked.unsupported_type":
304
- "This category asks for a kind of detail this app cannot show yet ({types}), so it cannot fill it in for you",
326
+ "This category asks for a kind of detail this app cannot show yet, so the listing cannot be completed here",
305
327
  "listings.compose.blocked.photos_pending":
306
328
  "Wait for the photos to finish uploading",
307
329
  "listings.compose.blocked.no_draft": "The draft has not been created yet",
308
330
  "listings.compose.blocked.busy": "One moment — the last change is still saving",
309
331
  "listings.compose.blocked.mirror":
310
332
  "Fix the highlighted fields first",
333
+ "listings.compose.blocked.incomplete": "{count} required details are still empty",
311
334
  "listings.compose.blocked.details_unavailable":
312
335
  "We could not load what this category asks for, so we cannot check the form",
313
336
 
@@ -325,6 +348,10 @@ export const listingsI18nBundleEn: Record<string, string> = {
325
348
  "listings.mine.empty.archived": "Nothing archived, paused, expired or sold yet",
326
349
  "listings.mine.blocked.title":
327
350
  "{count} of your listings were taken down by moderation",
351
+ "listings.mine.blocked.title.one":
352
+ "One of your listings was taken down by moderation",
353
+ "listings.mine.blocked.title.other":
354
+ "{count} of your listings were taken down by moderation",
328
355
  "listings.mine.blocked.load_failed":
329
356
  "We could not check whether any of your listings were taken down",
330
357
  "listings.mine.live_under_review":
@@ -333,11 +360,18 @@ export const listingsI18nBundleEn: Record<string, string> = {
333
360
  "listings.mine.archive": "Archive",
334
361
  "listings.mine.complete": "Mark sold",
335
362
  "listings.mine.delete": "Delete",
363
+ "listings.mine.delete_confirm_title": "Delete this listing?",
364
+ "listings.mine.delete_confirm_body":
365
+ "It disappears from your dashboard and cannot be brought back. Archiving keeps it.",
336
366
 
337
367
  "listings.favorites.title": "Favourites",
338
368
  "listings.favorites.loading": "Loading your favourites…",
339
369
  "listings.favorites.load_failed": "We could not load your favourites",
340
370
  "listings.favorites.empty": "You have not saved anything yet",
371
+ "listings.favorites.empty_hint":
372
+ "Tap the heart on any listing and it will be waiting here.",
373
+ "listings.favorites.sign_in_hint":
374
+ "Favourites are kept with your account, so they follow you between devices.",
341
375
 
342
376
  "listings.blocked.sign_in": "Sign in to do this",
343
377
  "listings.blocked.guest":
@@ -349,9 +383,12 @@ export const listingsI18nBundleEn: Record<string, string> = {
349
383
  "listings.blocked.delete_active":
350
384
  "Archive it first — a listing that is on sale cannot be deleted",
351
385
  "listings.blocked.in_flight": "One moment — that is already under way",
386
+ "listings.blocked.no_editor":
387
+ "This app has no screen for editing a listing yet",
352
388
 
353
389
  "listings.page.prev": "Previous",
354
390
  "listings.page.next": "Next",
391
+ "listings.page.indicator": "Page {page}",
355
392
 
356
393
  "listings.nav.detail": "Listing",
357
394
  "listings.nav.compose": "Post a listing",