@stapel/listings-react 0.4.0 → 0.8.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.
- package/CHANGELOG.md +180 -0
- package/dist/api/generated/schema.d.ts +94 -4
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/default/FavoritesPane.d.ts +40 -3
- package/dist/default/FavoritesPane.d.ts.map +1 -1
- package/dist/default/FavoritesPane.js +30 -18
- package/dist/default/FavoritesPane.js.map +1 -1
- package/dist/default/ListingCard.d.ts +81 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +97 -27
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +163 -23
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +85 -43
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +41 -4
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +38 -25
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingPhoto.d.ts +13 -1
- package/dist/default/ListingPhoto.d.ts.map +1 -1
- package/dist/default/ListingPhoto.js +47 -9
- package/dist/default/ListingPhoto.js.map +1 -1
- package/dist/default/ListingPrice.d.ts +13 -0
- package/dist/default/ListingPrice.d.ts.map +1 -0
- package/dist/default/ListingPrice.js +60 -0
- package/dist/default/ListingPrice.js.map +1 -0
- package/dist/default/MyListingsPane.d.ts +7 -28
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +136 -67
- package/dist/default/MyListingsPane.js.map +1 -1
- package/dist/default/SignInLink.js +8 -7
- package/dist/default/SignInLink.js.map +1 -1
- package/dist/default/StatusTags.d.ts +16 -5
- package/dist/default/StatusTags.d.ts.map +1 -1
- package/dist/default/StatusTags.js +19 -13
- package/dist/default/StatusTags.js.map +1 -1
- package/dist/default/index.d.ts +15 -9
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +13 -6
- package/dist/default/index.js.map +1 -1
- package/dist/headless/Favorites.d.ts +6 -0
- package/dist/headless/Favorites.d.ts.map +1 -1
- package/dist/headless/Favorites.js +23 -7
- package/dist/headless/Favorites.js.map +1 -1
- package/dist/headless/ListingActions.d.ts +12 -0
- package/dist/headless/ListingActions.d.ts.map +1 -1
- package/dist/headless/ListingActions.js +3 -0
- package/dist/headless/ListingActions.js.map +1 -1
- package/dist/headless/ListingComposer.d.ts +23 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +33 -7
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +7 -5
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/headless/useMandateGate.d.ts +33 -2
- package/dist/headless/useMandateGate.d.ts.map +1 -1
- package/dist/headless/useMandateGate.js +42 -4
- package/dist/headless/useMandateGate.js.map +1 -1
- package/dist/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +23 -5
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/generated/errors.es.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.es.gen.js +1 -1
- package/dist/i18n/generated/errors.gen.d.ts +6 -0
- package/dist/i18n/generated/errors.gen.d.ts.map +1 -1
- package/dist/i18n/generated/errors.gen.js +3 -0
- package/dist/i18n/generated/errors.gen.js.map +1 -1
- package/dist/i18n/generated/errors.ru.gen.d.ts +1 -1
- package/dist/i18n/generated/errors.ru.gen.js +1 -1
- package/dist/i18n/keys.d.ts +30 -3
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +47 -7
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts +16 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +35 -5
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/model/draft.d.ts +9 -6
- package/dist/model/draft.d.ts.map +1 -1
- package/dist/model/draft.js +3 -1
- package/dist/model/draft.js.map +1 -1
- package/dist/model/features.d.ts +21 -7
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +26 -0
- package/dist/model/features.js.map +1 -1
- package/llms.txt +9 -8
- package/manifest.json +82 -31
- package/nav-manifest.json +1 -1
- package/package.json +19 -14
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +94 -4
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +283 -116
- package/src/default/ListingComposerPage.tsx +403 -187
- package/src/default/ListingDetailPane.tsx +200 -98
- package/src/default/ListingPhoto.tsx +89 -16
- package/src/default/ListingPrice.tsx +77 -0
- package/src/default/MyListingsPane.tsx +286 -228
- package/src/default/SignInLink.tsx +4 -4
- package/src/default/StatusTags.tsx +36 -18
- package/src/default/index.ts +18 -7
- package/src/headless/Favorites.tsx +36 -7
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/ListingDetail.tsx +12 -5
- package/src/headless/useMandateGate.ts +49 -5
- package/src/i18n/es.ts +29 -5
- package/src/i18n/generated/errors.es.gen.ts +1 -1
- package/src/i18n/generated/errors.gen.ts +3 -0
- package/src/i18n/generated/errors.json +7 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +54 -7
- package/src/i18n/ru.ts +41 -5
- package/src/index.ts +5 -1
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/dist/default/ErrorAlert.d.ts +0 -9
- package/dist/default/ErrorAlert.d.ts.map +0 -1
- package/dist/default/ErrorAlert.js +0 -27
- package/dist/default/ErrorAlert.js.map +0 -1
- package/dist/default/theme.d.ts +0 -25
- package/dist/default/theme.d.ts.map +0 -1
- package/dist/default/theme.js +0 -40
- package/dist/default/theme.js.map +0 -1
- package/src/default/ErrorAlert.tsx +0 -46
- 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 {
|
|
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
|
-
|
|
29
|
-
|
|
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: "
|
|
32
|
-
waiting: "
|
|
45
|
+
neutral: "secondary",
|
|
46
|
+
waiting: "secondary",
|
|
33
47
|
good: "success",
|
|
34
48
|
warning: "warning",
|
|
35
|
-
stopped: "
|
|
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
|
|
59
|
-
* calm listing shows no
|
|
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
|
-
<
|
|
67
|
-
type={
|
|
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
|
-
|
|
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
|
}
|
package/src/default/index.ts
CHANGED
|
@@ -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";
|
|
@@ -13,7 +13,10 @@ import type { ListingCard, ListingPageParams } from "../api/types.js";
|
|
|
13
13
|
import { useMyFavorites } from "../model/queries.js";
|
|
14
14
|
import { useFavoriteListing } from "../model/mutations.js";
|
|
15
15
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
LISTINGS_ELEVATION_ACTIONS,
|
|
18
|
+
useElevatableMandateGate,
|
|
19
|
+
} from "./useMandateGate.js";
|
|
17
20
|
|
|
18
21
|
/**
|
|
19
22
|
* Saving something for later — first-class in stapel-listings (a `Favorite`
|
|
@@ -48,29 +51,40 @@ export function useFavoriteToggle(
|
|
|
48
51
|
id: number,
|
|
49
52
|
favorited: boolean | null | undefined
|
|
50
53
|
): FavoriteToggleBag {
|
|
51
|
-
const mandate =
|
|
54
|
+
const { gate: mandate, elevation } = useElevatableMandateGate(
|
|
55
|
+
LISTINGS_ELEVATION_ACTIONS.favorite
|
|
56
|
+
);
|
|
52
57
|
const mutation = useFavoriteListing();
|
|
53
58
|
const gate = firstBlock(
|
|
54
59
|
mandate,
|
|
55
|
-
mutation.isPending
|
|
60
|
+
mutation.isPending || elevation.pending
|
|
56
61
|
? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
|
|
57
62
|
: actionAvailable()
|
|
58
63
|
);
|
|
59
64
|
return {
|
|
60
65
|
favorited: favorited === true,
|
|
61
66
|
gate,
|
|
67
|
+
// On a host with auto-anonymous wired, the first heart an anonymous
|
|
68
|
+
// visitor presses mints their account and then saves — one press, no
|
|
69
|
+
// form, nothing said about it. Everywhere else `run` performs directly.
|
|
62
70
|
toggle: () => {
|
|
63
71
|
if (!gate.available) return;
|
|
64
|
-
mutation.mutate({ id, favorited: favorited !== true });
|
|
72
|
+
elevation.run(() => mutation.mutate({ id, favorited: favorited !== true }));
|
|
65
73
|
},
|
|
66
|
-
inFlight: mutation.isPending,
|
|
67
|
-
error: mutation.error,
|
|
74
|
+
inFlight: mutation.isPending || elevation.pending,
|
|
75
|
+
error: mutation.error ?? elevation.error,
|
|
68
76
|
};
|
|
69
77
|
}
|
|
70
78
|
|
|
71
79
|
export interface FavoritesBag {
|
|
72
80
|
readonly rows: LoadState<readonly ListingCard[]>;
|
|
73
81
|
readonly page: ListingPageParams;
|
|
82
|
+
/**
|
|
83
|
+
* Which page is on screen, 1-based. Keyset paging has no offset to read a
|
|
84
|
+
* number off, so it is counted: a pager with no indicator leaves a person
|
|
85
|
+
* unable to tell a second page from a reloaded first one.
|
|
86
|
+
*/
|
|
87
|
+
readonly pageNumber: number;
|
|
74
88
|
readonly nextPage: ActionAvailability;
|
|
75
89
|
readonly prevPage: ActionAvailability;
|
|
76
90
|
goNext(): void;
|
|
@@ -86,10 +100,22 @@ export interface UseFavoritesOptions {
|
|
|
86
100
|
/** The favourites page: a real keyset list, unlike the owner's own listings
|
|
87
101
|
* (see `MyListings.tsx` for why those are different). */
|
|
88
102
|
export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
|
|
89
|
-
|
|
103
|
+
// The READ side of elevation, and the one place `identified` is the right
|
|
104
|
+
// question. A guest who saved listings must be able to come back and see
|
|
105
|
+
// them — an account that can save and cannot re-read is worse than the
|
|
106
|
+
// refusal it replaced — but a visitor who has never elevated has nothing
|
|
107
|
+
// here, so this page must not mint just to render. `covers` alone would
|
|
108
|
+
// open it for them and buy a 401.
|
|
109
|
+
const { gate: mandateGate, elevation } = useElevatableMandateGate(
|
|
110
|
+
LISTINGS_ELEVATION_ACTIONS.favorite
|
|
111
|
+
);
|
|
112
|
+
const gate = elevation.covers && !elevation.identified
|
|
113
|
+
? actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn)
|
|
114
|
+
: mandateGate;
|
|
90
115
|
const [page, setPage] = useState<ListingPageParams>(
|
|
91
116
|
options.limit !== undefined ? { limit: options.limit } : {}
|
|
92
117
|
);
|
|
118
|
+
const [pageNumber, setPageNumber] = useState(1);
|
|
93
119
|
const query = useMyFavorites(page, { enabled: gate.available });
|
|
94
120
|
const envelope = query.data;
|
|
95
121
|
|
|
@@ -101,6 +127,7 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
|
|
|
101
127
|
? loadReady(envelope.items)
|
|
102
128
|
: loadLoading(),
|
|
103
129
|
page,
|
|
130
|
+
pageNumber,
|
|
104
131
|
nextPage:
|
|
105
132
|
envelope?.has_next === true && envelope.next_anchor != null
|
|
106
133
|
? actionAvailable()
|
|
@@ -113,11 +140,13 @@ export function useFavorites(options: UseFavoritesOptions = {}): FavoritesBag {
|
|
|
113
140
|
const anchor = envelope?.next_anchor;
|
|
114
141
|
if (anchor == null) return;
|
|
115
142
|
setPage((current) => ({ ...current, anchor, direction: "next" }));
|
|
143
|
+
setPageNumber((current) => current + 1);
|
|
116
144
|
},
|
|
117
145
|
goPrev: () => {
|
|
118
146
|
const anchor = envelope?.prev_anchor;
|
|
119
147
|
if (anchor == null) return;
|
|
120
148
|
setPage((current) => ({ ...current, anchor, direction: "prev" }));
|
|
149
|
+
setPageNumber((current) => Math.max(1, current - 1));
|
|
121
150
|
},
|
|
122
151
|
gate,
|
|
123
152
|
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({
|
|
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(
|
|
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,
|
|
@@ -21,7 +21,10 @@ import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from ".
|
|
|
21
21
|
import { listingStatusView } from "../model/status.js";
|
|
22
22
|
import type { ListingStatusView } from "../model/status.js";
|
|
23
23
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
24
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
LISTINGS_ELEVATION_ACTIONS,
|
|
26
|
+
useElevatableMandateGate,
|
|
27
|
+
} from "./useMandateGate.js";
|
|
25
28
|
|
|
26
29
|
/**
|
|
27
30
|
* `images` is `string[] | NULL` on the wire, and the null is the SERVER
|
|
@@ -110,7 +113,9 @@ export function useListingDetail(
|
|
|
110
113
|
const detail = useListing(id);
|
|
111
114
|
const probe = useListingStatus(id);
|
|
112
115
|
const favorite = useFavoriteListing();
|
|
113
|
-
const mandate =
|
|
116
|
+
const { gate: mandate, elevation } = useElevatableMandateGate(
|
|
117
|
+
LISTINGS_ELEVATION_ACTIONS.favorite
|
|
118
|
+
);
|
|
114
119
|
|
|
115
120
|
const state: LoadState<ListingDetailData> =
|
|
116
121
|
detail.status === "error"
|
|
@@ -178,7 +183,7 @@ export function useListingDetail(
|
|
|
178
183
|
|
|
179
184
|
const favoriteGate = firstBlock(
|
|
180
185
|
mandate,
|
|
181
|
-
favorite.isPending
|
|
186
|
+
favorite.isPending || elevation.pending
|
|
182
187
|
? actionBlocked(LISTINGS_I18N_KEYS.blockedInFlight)
|
|
183
188
|
: actionAvailable(),
|
|
184
189
|
detail.data === undefined
|
|
@@ -206,9 +211,11 @@ export function useListingDetail(
|
|
|
206
211
|
favoriteGate,
|
|
207
212
|
toggleFavorite: () => {
|
|
208
213
|
if (!favoriteGate.available) return;
|
|
209
|
-
|
|
214
|
+
// Mints the anonymous account first where the host permits it for this
|
|
215
|
+
// action; a direct call everywhere else.
|
|
216
|
+
elevation.run(() => favorite.mutate({ id, favorited: isFavorited !== true }));
|
|
210
217
|
},
|
|
211
|
-
favoriteInFlight: favorite.isPending,
|
|
218
|
+
favoriteInFlight: favorite.isPending || elevation.pending,
|
|
212
219
|
refetch: () => {
|
|
213
220
|
void detail.refetch();
|
|
214
221
|
void probe.refetch();
|
|
@@ -1,8 +1,24 @@
|
|
|
1
|
-
import { matchMandate, useMandate } from "@stapel/core";
|
|
2
|
-
import type { ActionAvailability } from "@stapel/core";
|
|
1
|
+
import { matchMandate, useElevation, useMandate } from "@stapel/core";
|
|
2
|
+
import type { ActionAvailability, Elevation } from "@stapel/core";
|
|
3
3
|
import { actionAvailable, actionBlocked } from "@stapel/core";
|
|
4
4
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The names this pair's gated writes use when asking core's elevation seam
|
|
8
|
+
* whether an anonymous visitor may be given an identity instead of a
|
|
9
|
+
* refusal. A host lists the ones it permits (see `ElevationSource.actions`);
|
|
10
|
+
* anything it leaves out keeps its wall.
|
|
11
|
+
*
|
|
12
|
+
* Only `favorite` is named. Publishing a listing is deliberately absent and
|
|
13
|
+
* stays absent: a seller who cannot be reached again is not a seller, and an
|
|
14
|
+
* automatically-minted account is exactly that. Saving something for later
|
|
15
|
+
* is the opposite case — losing a shortlist because a stranger had not
|
|
16
|
+
* registered is friction with nothing behind it.
|
|
17
|
+
*/
|
|
18
|
+
export const LISTINGS_ELEVATION_ACTIONS = {
|
|
19
|
+
favorite: "listings.favorite",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
6
22
|
/**
|
|
7
23
|
* "May this person act at all?" — the first gate on every write in the pair.
|
|
8
24
|
*
|
|
@@ -28,13 +44,41 @@ import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
|
28
44
|
* than throwing, so a pair rendered in a host that never wired the axis
|
|
29
45
|
* degrades to "we could not check" — visible, fixable, and not a blank page.
|
|
30
46
|
*/
|
|
31
|
-
export function useMandateGate(): ActionAvailability {
|
|
47
|
+
export function useMandateGate(action?: string): ActionAvailability {
|
|
48
|
+
return useElevatableMandateGate(action).gate;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The same gate, with the elevation handle the caller needs to ACT on it.
|
|
53
|
+
*
|
|
54
|
+
* A control that passes an `action` is saying "for this one act, an
|
|
55
|
+
* anonymous visitor may be given an identity rather than a refusal" — and
|
|
56
|
+
* then it must run its write through `elevation.run`, or the write goes out
|
|
57
|
+
* before the account exists and buys a 401. The two halves are returned
|
|
58
|
+
* together so a caller cannot take the unblocked gate and forget the mint.
|
|
59
|
+
*
|
|
60
|
+
* Passing no `action` is the default and the majority: publishing, saving a
|
|
61
|
+
* draft, marking something sold. Those keep the wall.
|
|
62
|
+
*/
|
|
63
|
+
export function useElevatableMandateGate(action?: string): {
|
|
64
|
+
readonly gate: ActionAvailability;
|
|
65
|
+
readonly elevation: Elevation;
|
|
66
|
+
} {
|
|
32
67
|
const mandate = useMandate();
|
|
33
|
-
|
|
68
|
+
const elevation = useElevation(action);
|
|
69
|
+
const gate = matchMandate<ActionAvailability>(mandate, {
|
|
34
70
|
member: () => actionAvailable(),
|
|
35
71
|
guest: () => actionBlocked(LISTINGS_I18N_KEYS.blockedGuest),
|
|
36
|
-
|
|
72
|
+
// The one arm elevation changes. An identity can be minted at the moment
|
|
73
|
+
// of the act for the actions the host listed, so the control is offered
|
|
74
|
+
// rather than refused — and the mandate axis itself is untouched, which
|
|
75
|
+
// is what keeps the composer's wall standing for the same visitor.
|
|
76
|
+
anonymous: () =>
|
|
77
|
+
elevation.covers
|
|
78
|
+
? actionAvailable()
|
|
79
|
+
: actionBlocked(LISTINGS_I18N_KEYS.blockedSignIn),
|
|
37
80
|
asking: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
|
|
38
81
|
unavailable: () => actionBlocked(LISTINGS_I18N_KEYS.blockedMandateUnknown),
|
|
39
82
|
});
|
|
83
|
+
return { gate, elevation };
|
|
40
84
|
}
|
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";
|
|
@@ -31,6 +32,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
31
32
|
"Estado de anuncio desconocido: «{status}»",
|
|
32
33
|
"error.400.publish_validation_failed":
|
|
33
34
|
"El anuncio no pasó la revisión y no se publicó",
|
|
35
|
+
"error.403.listing_anonymous_not_allowed":
|
|
36
|
+
"Para publicar un anuncio, inicia sesión o crea una cuenta",
|
|
34
37
|
"error.403.listing_not_owner": "Este anuncio no es tuyo",
|
|
35
38
|
"error.404.listing_not_found": "No se encontró el anuncio",
|
|
36
39
|
"error.409.already_favorited": "El anuncio ya está en favoritos",
|
|
@@ -71,7 +74,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
71
74
|
"listings.card.price_absent": "Precio a consultar",
|
|
72
75
|
"listings.card.favorite_add": "Guardar en favoritos",
|
|
73
76
|
"listings.card.favorite_remove": "Quitar de favoritos",
|
|
74
|
-
"listings.card.
|
|
77
|
+
"listings.card.untitled": "Anuncio sin título",
|
|
75
78
|
"listings.card.sign_in": "Iniciar sesión",
|
|
76
79
|
|
|
77
80
|
"listings.detail.loading": "Cargando el anuncio…",
|
|
@@ -94,6 +97,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
94
97
|
"listings.detail.published_at": "Publicado el {date}",
|
|
95
98
|
"listings.detail.expires_at": "A la venta hasta el {date}",
|
|
96
99
|
"listings.detail.stock": "Disponibles",
|
|
100
|
+
"listings.detail.edit": "Editar el anuncio",
|
|
101
|
+
"listings.detail.take_down": "Retirarlo",
|
|
97
102
|
|
|
98
103
|
"listings.compose.new_title": "Anuncio nuevo",
|
|
99
104
|
"listings.compose.edit_title": "Editar el anuncio",
|
|
@@ -112,8 +117,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
112
117
|
"Escribe el precio como un número, con dos decimales como mucho",
|
|
113
118
|
"listings.compose.currency_label": "Moneda",
|
|
114
119
|
"listings.compose.location_label": "Dónde está",
|
|
115
|
-
"listings.compose.
|
|
116
|
-
|
|
120
|
+
"listings.compose.location_help":
|
|
121
|
+
"La gente busca por distancia — un anuncio sin lugar es un anuncio que no encontrarán",
|
|
117
122
|
"listings.compose.geo_incomplete":
|
|
118
123
|
"Una latitud necesita su longitud al lado — media coordenada no apunta a ningún sitio",
|
|
119
124
|
"listings.compose.photos": "Fotos",
|
|
@@ -143,12 +148,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
143
148
|
"listings.compose.blocked.no_category":
|
|
144
149
|
"Elige una categoría — el resto del formulario depende de ella",
|
|
145
150
|
"listings.compose.blocked.unsupported_type":
|
|
146
|
-
"Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar
|
|
151
|
+
"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
152
|
"listings.compose.blocked.photos_pending":
|
|
148
153
|
"Espera a que terminen de subirse las fotos",
|
|
149
154
|
"listings.compose.blocked.no_draft": "El borrador todavía no está creado",
|
|
150
155
|
"listings.compose.blocked.busy":
|
|
151
156
|
"Un momento — el último cambio se está guardando",
|
|
157
|
+
"listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
|
|
152
158
|
"listings.compose.blocked.mirror": "Corrige primero los campos marcados",
|
|
153
159
|
"listings.compose.blocked.details_unavailable":
|
|
154
160
|
"No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
|
|
@@ -167,6 +173,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
167
173
|
"listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
|
|
168
174
|
"listings.mine.blocked.title":
|
|
169
175
|
"Moderación retiró {count} de tus anuncios",
|
|
176
|
+
"listings.mine.blocked.title.one": "Moderación retiró uno de tus anuncios",
|
|
177
|
+
"listings.mine.blocked.title.other": "Moderación retiró {count} de tus anuncios",
|
|
170
178
|
"listings.mine.blocked.load_failed":
|
|
171
179
|
"No pudimos comprobar si alguno de tus anuncios fue retirado",
|
|
172
180
|
"listings.mine.live_under_review": "Publicado, cambios en revisión",
|
|
@@ -174,11 +182,18 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
174
182
|
"listings.mine.archive": "Archivar",
|
|
175
183
|
"listings.mine.complete": "Marcar como vendido",
|
|
176
184
|
"listings.mine.delete": "Borrar",
|
|
185
|
+
"listings.mine.delete_confirm_title": "¿Borrar este anuncio?",
|
|
186
|
+
"listings.mine.delete_confirm_body":
|
|
187
|
+
"Desaparece de tu panel y no se puede recuperar. Archivarlo lo conserva.",
|
|
177
188
|
|
|
178
189
|
"listings.favorites.title": "Favoritos",
|
|
179
190
|
"listings.favorites.loading": "Cargando tus favoritos…",
|
|
180
191
|
"listings.favorites.load_failed": "No pudimos cargar tus favoritos",
|
|
181
192
|
"listings.favorites.empty": "Todavía no has guardado nada",
|
|
193
|
+
"listings.favorites.empty_hint":
|
|
194
|
+
"Toca el corazón en cualquier anuncio y te estará esperando aquí.",
|
|
195
|
+
"listings.favorites.sign_in_hint":
|
|
196
|
+
"Los favoritos se guardan en tu cuenta, así que te siguen entre dispositivos.",
|
|
182
197
|
|
|
183
198
|
"listings.blocked.sign_in": "Inicia sesión para hacer esto",
|
|
184
199
|
"listings.blocked.guest":
|
|
@@ -190,9 +205,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
190
205
|
"listings.blocked.delete_active":
|
|
191
206
|
"Archívalo primero — un anuncio a la venta no se puede borrar",
|
|
192
207
|
"listings.blocked.in_flight": "Un momento — eso ya está en marcha",
|
|
208
|
+
"listings.blocked.no_editor":
|
|
209
|
+
"Esta aplicación todavía no tiene una pantalla para editar un anuncio",
|
|
193
210
|
|
|
194
211
|
"listings.page.prev": "Anterior",
|
|
195
212
|
"listings.page.next": "Siguiente",
|
|
213
|
+
"listings.page.indicator": "Página {page}",
|
|
196
214
|
|
|
197
215
|
"listings.nav.detail": "Anuncio",
|
|
198
216
|
"listings.nav.compose": "Poner un anuncio",
|
|
@@ -200,7 +218,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
200
218
|
"listings.nav.favorites": "Favoritos",
|
|
201
219
|
};
|
|
202
220
|
|
|
203
|
-
/**
|
|
221
|
+
/**
|
|
222
|
+
* Register the Spanish bundle — and, exactly as in `./ru.ts`, the twelve
|
|
223
|
+
* `stapel_attributes` sentences this pair does not author. The ownership split
|
|
224
|
+
* is a rule about who WRITES a string, not an instruction to ship two thirds
|
|
225
|
+
* of a locale and document the rest.
|
|
226
|
+
*/
|
|
204
227
|
export function registerListingsI18nEs(i18n: I18nEngine): void {
|
|
228
|
+
registerAttributesI18nEs(i18n);
|
|
205
229
|
i18n.registerBundle("es", listingsI18nBundleEs);
|
|
206
230
|
}
|
|
@@ -8,7 +8,7 @@ import type { ListingsErrorCode } from "./errors.gen.js";
|
|
|
8
8
|
/**
|
|
9
9
|
* `es` texts for the backend error codes this catalog carries.
|
|
10
10
|
*
|
|
11
|
-
* PARTIAL, and deliberately typed to say so:
|
|
11
|
+
* PARTIAL, and deliberately typed to say so: 23 key(s) owned by
|
|
12
12
|
* stapel_attributes, stapel_listings are absent, because that owner ships no locale catalog
|
|
13
13
|
* (ERRORS_LOCALE_EXEMPT_OWNERS). English for them still comes from the registry
|
|
14
14
|
* artifact via the en bundle; the pair layers its own authored `es`
|