@stapel/listings-react 0.1.0 → 0.3.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 (55) hide show
  1. package/CHANGELOG.md +194 -0
  2. package/LICENSE +21 -0
  3. package/MODULE.md +97 -3
  4. package/README.md +67 -2
  5. package/dist/api/types.d.ts +1 -1
  6. package/dist/api/types.js +1 -1
  7. package/dist/default/FavoritesPane.d.ts +19 -4
  8. package/dist/default/FavoritesPane.d.ts.map +1 -1
  9. package/dist/default/FavoritesPane.js +13 -3
  10. package/dist/default/FavoritesPane.js.map +1 -1
  11. package/dist/default/ListingCard.d.ts +54 -11
  12. package/dist/default/ListingCard.d.ts.map +1 -1
  13. package/dist/default/ListingCard.js +32 -5
  14. package/dist/default/ListingCard.js.map +1 -1
  15. package/dist/default/ListingComposerPage.d.ts +59 -4
  16. package/dist/default/ListingComposerPage.d.ts.map +1 -1
  17. package/dist/default/ListingComposerPage.js +12 -3
  18. package/dist/default/ListingComposerPage.js.map +1 -1
  19. package/dist/default/SignInLink.d.ts +28 -0
  20. package/dist/default/SignInLink.d.ts.map +1 -0
  21. package/dist/default/SignInLink.js +17 -0
  22. package/dist/default/SignInLink.js.map +1 -0
  23. package/dist/default/index.d.ts +5 -3
  24. package/dist/default/index.d.ts.map +1 -1
  25. package/dist/default/index.js +1 -0
  26. package/dist/default/index.js.map +1 -1
  27. package/dist/headless/ListingComposer.d.ts +17 -0
  28. package/dist/headless/ListingComposer.d.ts.map +1 -1
  29. package/dist/headless/ListingComposer.js +26 -10
  30. package/dist/headless/ListingComposer.js.map +1 -1
  31. package/dist/i18n/es.d.ts.map +1 -1
  32. package/dist/i18n/es.js +1 -0
  33. package/dist/i18n/es.js.map +1 -1
  34. package/dist/i18n/keys.d.ts +2 -0
  35. package/dist/i18n/keys.d.ts.map +1 -1
  36. package/dist/i18n/keys.js +3 -0
  37. package/dist/i18n/keys.js.map +1 -1
  38. package/dist/i18n/ru.d.ts +1 -1
  39. package/dist/i18n/ru.d.ts.map +1 -1
  40. package/dist/i18n/ru.js +2 -1
  41. package/dist/i18n/ru.js.map +1 -1
  42. package/llms.txt +1 -1
  43. package/manifest.json +2 -1
  44. package/nav-manifest.json +1 -1
  45. package/package.json +16 -16
  46. package/src/analytics/generated/events.json +1 -1
  47. package/src/default/FavoritesPane.tsx +40 -11
  48. package/src/default/ListingCard.tsx +144 -27
  49. package/src/default/ListingComposerPage.tsx +79 -14
  50. package/src/default/SignInLink.tsx +54 -0
  51. package/src/default/index.ts +15 -3
  52. package/src/headless/ListingComposer.tsx +43 -10
  53. package/src/i18n/es.ts +1 -0
  54. package/src/i18n/keys.ts +3 -0
  55. package/src/i18n/ru.ts +1 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The door beside a blocked control.
3
+ *
4
+ * `actionBlocked` ended the grey-rectangle incident by making every
5
+ * switched-off control state its reason. It did not end the next problem:
6
+ * "sign in to add this to favourites" is a reason whose next action is a LINK,
7
+ * and this pair rendered the sentence and stopped there — leaving the visitor
8
+ * to find the header themselves (storefront Wave D, G-3).
9
+ *
10
+ * WHERE that link goes is the container's business, never the pair's: the
11
+ * storefront's is `/login?next=<current>`, a tenant app's may be a modal. So
12
+ * the shape is core's `SignInCta` — `{href}` or `{onSignIn}`, never both — and
13
+ * the copy is this pair's, because core floors `en` and `ru` while this pair
14
+ * also ships `es`.
15
+ *
16
+ * A host that routes internally passes `onSignIn`; the `href` arm renders a
17
+ * plain anchor on purpose, because arriving at a sign-in page is one of the
18
+ * few navigations a full load costs nothing.
19
+ */
20
+ import type { ReactElement } from "react";
21
+ import { Typography } from "antd";
22
+ import { useT } from "@stapel/core";
23
+ import type { SignInCta } from "@stapel/core";
24
+ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
25
+
26
+ export interface SignInLinkProps {
27
+ /** Absent: no link — a host with no sign-in route shows the reason alone. */
28
+ readonly cta: SignInCta | undefined;
29
+ readonly testId: string;
30
+ }
31
+
32
+ export function SignInLink(props: SignInLinkProps): ReactElement | null {
33
+ const t = useT();
34
+ const { cta } = props;
35
+ if (cta === undefined) return null;
36
+ // The separating space belongs HERE, not at the call site: a reason with no
37
+ // door must render as exactly its own sentence, and a `{" "}` left behind by
38
+ // an absent link is a trailing space in every caller's assertion.
39
+ return (
40
+ <>
41
+ {" "}
42
+ <Typography.Link
43
+ data-testid={props.testId}
44
+ data-analytics="none"
45
+ data-analytics-reason="business action — host app wraps with its own tracked()"
46
+ {...(cta.href !== undefined
47
+ ? { href: cta.href }
48
+ : { onClick: cta.onSignIn })}
49
+ >
50
+ {t(LISTINGS_I18N_KEYS.cardSignIn)}
51
+ </Typography.Link>
52
+ </>
53
+ );
54
+ }
@@ -15,21 +15,33 @@
15
15
  * ```
16
16
  */
17
17
  export { ListingCard } from "./ListingCard.js";
18
- export type { ListingCardProps } from "./ListingCard.js";
18
+ export type {
19
+ ListingCardProps,
20
+ ListingCardBaseProps,
21
+ ListingCardOpenProps,
22
+ } from "./ListingCard.js";
19
23
  export { ListingDetailPane } from "./ListingDetailPane.js";
20
24
  export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
21
25
  export { ListingComposerPage } from "./ListingComposerPage.js";
22
- export type { ListingComposerPageProps } from "./ListingComposerPage.js";
26
+ export type {
27
+ ListingComposerPageProps,
28
+ ComposerCategorySlot,
29
+ } from "./ListingComposerPage.js";
23
30
  export { MyListingsPane } from "./MyListingsPane.js";
24
31
  export type { MyListingsPaneProps } from "./MyListingsPane.js";
25
32
  export { FavoritesPane } from "./FavoritesPane.js";
26
- export type { FavoritesPaneProps } from "./FavoritesPane.js";
33
+ export type {
34
+ FavoritesPaneProps,
35
+ FavoritesPaneOpenProps,
36
+ } from "./FavoritesPane.js";
27
37
 
28
38
  export { LifecycleTag, ListingStatusBlock, ModerationNote } from "./StatusTags.js";
29
39
  export type { ListingStatusProps } from "./StatusTags.js";
30
40
  export { ListingPhoto } from "./ListingPhoto.js";
31
41
  export type { ListingPhotoProps } from "./ListingPhoto.js";
32
42
  export { ErrorAlert } from "./ErrorAlert.js";
43
+ export { SignInLink } from "./SignInLink.js";
44
+ export type { SignInLinkProps } from "./SignInLink.js";
33
45
  export { ListingsSkinTheme } from "./theme.js";
34
46
  export type { ListingsSkinThemeProps } from "./theme.js";
35
47
  export type { ThemeModeProp } from "./types.js";
@@ -125,6 +125,23 @@ export interface UseListingComposerOptions {
125
125
  /** The upload queue. Absent: no gallery, and `images` is set through
126
126
  * `setValue` by whatever the host uses instead. */
127
127
  readonly images?: ListingImagesBag;
128
+ /**
129
+ * The chosen category, when the CONTAINER owns that state.
130
+ *
131
+ * The category is the one value the composer cannot own alone: the picker
132
+ * lives in `@stapel/categories-react` and the schema read
133
+ * (`useCategoryFeatures(id)`) that fills `features` is keyed by it, so the
134
+ * container holds it either way. Passing it here makes this hook controlled
135
+ * on that field — `values.categoryId` mirrors it, and `setCategory` reports
136
+ * upwards instead of writing local state that would then disagree with the
137
+ * schema on screen.
138
+ *
139
+ * Absent: uncontrolled, exactly as before.
140
+ */
141
+ readonly category?: string;
142
+ /** Called by `setCategory`, whether or not `category` is controlled. This is
143
+ * how a container learns which category to read the schema for. */
144
+ readonly onCategoryChange?: (categoryId: string) => void;
128
145
  /** Seed for a brand-new draft (a category preselected from the URL, say). */
129
146
  readonly initialValues?: Partial<ListingDraftValues>;
130
147
  onDraftCreated?: (draft: ListingDraft) => void;
@@ -245,9 +262,19 @@ export function useListingComposer(
245
262
  // The gallery is the upload bag's, whenever there is one: two sources of
246
263
  // truth for the same list is how a publish sends photos the person removed.
247
264
  const images = options.images?.refs ?? values.images;
265
+ // Same rule for the category when the container holds it: the schema on
266
+ // screen is read for the container's id, so a local copy that drifted from
267
+ // it would ask one category's questions and file the listing under another.
268
+ const controlledCategory = options.category;
248
269
  const effectiveValues: ListingDraftValues = useMemo(
249
- () => ({ ...values, images }),
250
- [values, images]
270
+ () => ({
271
+ ...values,
272
+ images,
273
+ ...(controlledCategory !== undefined
274
+ ? { categoryId: controlledCategory }
275
+ : {}),
276
+ }),
277
+ [values, images, controlledCategory]
251
278
  );
252
279
 
253
280
  const featuresDto = useMemo(
@@ -435,14 +462,20 @@ export function useListingComposer(
435
462
  },
436
463
  setCategory: (categoryId) => {
437
464
  setSaved(false);
438
- setValues((current) => {
439
- if (current.categoryId === categoryId) return current;
440
- // The features of the NEW category are not known yet (the container's
441
- // schema read has not run), so nothing is pruned here — pruning
442
- // happens in the effect below, once they arrive. Recording the
443
- // intent, not guessing the outcome.
444
- return { ...current, categoryId };
445
- });
465
+ // Told upwards FIRST and unconditionally: the container's schema read is
466
+ // keyed by this id, and it must be asked for even when the composer is
467
+ // uncontrolled that is the wire `features` arrives on.
468
+ options.onCategoryChange?.(categoryId);
469
+ if (controlledCategory === undefined) {
470
+ setValues((current) => {
471
+ if (current.categoryId === categoryId) return current;
472
+ // The features of the NEW category are not known yet (the
473
+ // container's schema read has not run), so nothing is pruned here —
474
+ // pruning happens in the effect below, once they arrive. Recording
475
+ // the intent, not guessing the outcome.
476
+ return { ...current, categoryId };
477
+ });
478
+ }
446
479
  setDropped([]);
447
480
  setRefusal(undefined);
448
481
  },
package/src/i18n/es.ts CHANGED
@@ -70,6 +70,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
70
70
  "listings.card.favorite_add": "Guardar en favoritos",
71
71
  "listings.card.favorite_remove": "Quitar de favoritos",
72
72
  "listings.card.open": "Abrir",
73
+ "listings.card.sign_in": "Iniciar sesión",
73
74
 
74
75
  "listings.detail.loading": "Cargando el anuncio…",
75
76
  "listings.detail.load_failed": "No pudimos cargar este anuncio",
package/src/i18n/keys.ts CHANGED
@@ -63,6 +63,8 @@ export const LISTINGS_I18N_KEYS = {
63
63
  cardFavoriteAdd: "listings.card.favorite_add",
64
64
  cardFavoriteRemove: "listings.card.favorite_remove",
65
65
  cardOpen: "listings.card.open",
66
+ /** The door beside a blocked favourite: the container supplies WHERE. */
67
+ cardSignIn: "listings.card.sign_in",
66
68
 
67
69
  // ── detail ───────────────────────────────────────────────────────────────
68
70
  detailLoading: "listings.detail.loading",
@@ -218,6 +220,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
218
220
  "listings.card.favorite_add": "Save to favourites",
219
221
  "listings.card.favorite_remove": "Remove from favourites",
220
222
  "listings.card.open": "Open",
223
+ "listings.card.sign_in": "Sign in",
221
224
 
222
225
  "listings.detail.loading": "Loading the listing…",
223
226
  "listings.detail.load_failed": "We could not load this listing",
package/src/i18n/ru.ts CHANGED
@@ -86,6 +86,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
86
86
  "listings.card.favorite_add": "В избранное",
87
87
  "listings.card.favorite_remove": "Убрать из избранного",
88
88
  "listings.card.open": "Открыть",
89
+ "listings.card.sign_in": "Войти",
89
90
 
90
91
  "listings.detail.loading": "Загружаем объявление…",
91
92
  "listings.detail.load_failed": "Не удалось загрузить объявление",