@stapel/listings-react 0.3.1 → 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.
- package/CHANGELOG.md +122 -0
- package/MODULE.md +12 -6
- package/README.md +33 -17
- package/dist/api/generated/schema.d.ts +129 -3
- package/dist/api/generated/schema.d.ts.map +1 -1
- package/dist/api/listingsApi.d.ts +35 -22
- package/dist/api/listingsApi.d.ts.map +1 -1
- package/dist/api/listingsApi.js +17 -0
- package/dist/api/listingsApi.js.map +1 -1
- package/dist/api/types.d.ts +43 -2
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.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 +25 -4
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +19 -12
- 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 +9 -17
- package/dist/default/MyListingsPane.d.ts.map +1 -1
- package/dist/default/MyListingsPane.js +148 -63
- 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 +4 -0
- 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/MyListings.d.ts +27 -12
- package/dist/headless/MyListings.d.ts.map +1 -1
- package/dist/headless/MyListings.js +29 -7
- package/dist/headless/MyListings.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 +28 -6
- 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 +28 -4
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +49 -8
- 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 +40 -6
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +8 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- 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/dist/model/mine.d.ts +44 -0
- package/dist/model/mine.d.ts.map +1 -0
- package/dist/model/mine.js +52 -0
- package/dist/model/mine.js.map +1 -0
- package/dist/model/mineSource.d.ts +39 -34
- package/dist/model/mineSource.d.ts.map +1 -1
- package/dist/model/mineSource.js +10 -41
- package/dist/model/mineSource.js.map +1 -1
- package/dist/model/mutations.d.ts.map +1 -1
- package/dist/model/mutations.js +11 -4
- package/dist/model/mutations.js.map +1 -1
- package/dist/model/queryKeys.d.ts +7 -2
- package/dist/model/queryKeys.d.ts.map +1 -1
- package/dist/model/queryKeys.js +2 -0
- package/dist/model/queryKeys.js.map +1 -1
- package/dist/model/status.d.ts +12 -31
- package/dist/model/status.d.ts.map +1 -1
- package/dist/model/status.js +44 -0
- package/dist/model/status.js.map +1 -1
- package/llms.txt +11 -8
- package/manifest.json +88 -14
- package/nav-manifest.json +1 -1
- package/package.json +17 -12
- package/src/analytics/generated/events.json +1 -1
- package/src/api/generated/schema.ts +129 -3
- package/src/api/listingsApi.ts +60 -21
- package/src/api/types.ts +46 -2
- package/src/default/FavoritesPane.tsx +156 -82
- package/src/default/ListingCard.tsx +88 -46
- 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 +344 -222
- 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 +10 -0
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/headless/MyListings.tsx +62 -22
- package/src/i18n/es.ts +36 -7
- 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 +9 -0
- package/src/i18n/generated/errors.ru.gen.ts +1 -1
- package/src/i18n/keys.ts +58 -9
- package/src/i18n/ru.ts +48 -7
- package/src/index.ts +16 -5
- package/src/model/draft.ts +12 -7
- package/src/model/features.ts +48 -7
- package/src/model/mine.ts +70 -0
- package/src/model/mineSource.ts +46 -41
- package/src/model/mutations.ts +11 -4
- package/src/model/queryKeys.ts +9 -2
- package/src/model/status.ts +15 -0
- 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
|
@@ -33,12 +33,12 @@ export function SignInLink(props: SignInLinkProps): ReactElement | null {
|
|
|
33
33
|
const t = useT();
|
|
34
34
|
const { cta } = props;
|
|
35
35
|
if (cta === undefined) return null;
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
36
|
+
// No separating space: since the shared `<GatedControl>` prints the reason
|
|
37
|
+
// as its own paragraph, the door is a standalone element rather than the
|
|
38
|
+
// tail of a sentence, and a leading `{" "}` would be a stray space in every
|
|
39
|
+
// caller's assertion.
|
|
39
40
|
return (
|
|
40
41
|
<>
|
|
41
|
-
{" "}
|
|
42
42
|
<Typography.Link
|
|
43
43
|
data-testid={props.testId}
|
|
44
44
|
data-analytics="none"
|
|
@@ -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";
|
|
@@ -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({
|
|
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,
|
|
@@ -11,28 +11,36 @@ import {
|
|
|
11
11
|
} from "@stapel/core";
|
|
12
12
|
import type { ActionAvailability, LoadState } from "@stapel/core";
|
|
13
13
|
import type {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
MyListingCard,
|
|
15
|
+
MyListingsParams,
|
|
16
16
|
MyCounters,
|
|
17
17
|
} from "../api/types.js";
|
|
18
|
-
import {
|
|
18
|
+
import { defaultMyListingsSource } from "../model/mineSource.js";
|
|
19
19
|
import type { MyListingsSource } from "../model/mineSource.js";
|
|
20
|
+
import { useListingsApi } from "../model/context.js";
|
|
20
21
|
import { useMyCounters } from "../model/queries.js";
|
|
21
22
|
import { listingsQueryKeys, pageKey } from "../model/queryKeys.js";
|
|
22
|
-
import { MY_LISTINGS_TABS } from "../model/status.js";
|
|
23
|
+
import { MY_LISTINGS_TABS, MY_LISTINGS_UNTABBED_STATUSES } from "../model/status.js";
|
|
23
24
|
import type { MyListingsTab } from "../model/status.js";
|
|
24
25
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
25
26
|
import { useMandateGate } from "./useMandateGate.js";
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
|
-
* The owner's dashboard
|
|
29
|
-
* backend cannot supply.
|
|
29
|
+
* The owner's dashboard.
|
|
30
30
|
*
|
|
31
|
-
*
|
|
32
|
-
* `
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
* it.
|
|
31
|
+
* Three counts and the rows behind them, both owner-scoped reads of
|
|
32
|
+
* stapel-listings: `my/counters` and — since 0.7.0 — `my/listings`. Until
|
|
33
|
+
* that release the rows had no route at all and this hook failed them with a
|
|
34
|
+
* named reason rather than rendering an empty grid; `model/mineSource.ts`
|
|
35
|
+
* keeps the argument and the seam that came out of it.
|
|
36
|
+
*
|
|
37
|
+
* TWO reads, not one, and the second one is the point of this file's shape:
|
|
38
|
+
* the three tabs are the SERVER's status groupings, and `blocked` — a
|
|
39
|
+
* moderation takedown — is in none of them, because `my/counters` counts it
|
|
40
|
+
* in none of them. A dashboard that only ever asked for a tab's statuses
|
|
41
|
+
* would hide exactly the listing whose owner most needs to know. So
|
|
42
|
+
* `blockedRows` is fetched beside them, off the same route, narrowed to
|
|
43
|
+
* whatever `MY_LISTINGS_UNTABBED_STATUSES` derives.
|
|
36
44
|
*/
|
|
37
45
|
|
|
38
46
|
export interface MyListingsBag {
|
|
@@ -41,10 +49,16 @@ export interface MyListingsBag {
|
|
|
41
49
|
setTab(tab: MyListingsTab): void;
|
|
42
50
|
/** The three real counts. */
|
|
43
51
|
readonly counters: LoadState<MyCounters>;
|
|
44
|
-
/** The rows for the current tab.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
/** The rows for the current tab. */
|
|
53
|
+
readonly rows: LoadState<readonly MyListingCard[]>;
|
|
54
|
+
/**
|
|
55
|
+
* The rows no tab folds in — a moderation takedown, today. Empty for
|
|
56
|
+
* almost every seller; when it is not, it is the most important thing on
|
|
57
|
+
* the screen. Never `failed` in a way that hides the tabs: this read is
|
|
58
|
+
* independent of `rows` and a skin renders it beside them.
|
|
59
|
+
*/
|
|
60
|
+
readonly blockedRows: LoadState<readonly MyListingCard[]>;
|
|
61
|
+
readonly page: MyListingsParams;
|
|
48
62
|
readonly nextPage: ActionAvailability;
|
|
49
63
|
readonly prevPage: ActionAvailability;
|
|
50
64
|
goNext(): void;
|
|
@@ -55,6 +69,8 @@ export interface MyListingsBag {
|
|
|
55
69
|
}
|
|
56
70
|
|
|
57
71
|
export interface UseMyListingsOptions {
|
|
72
|
+
/** Replace the contract's own `my/listings` read — a deployment that keeps
|
|
73
|
+
* its sellers' rows somewhere else. Absent: {@link defaultMyListingsSource}. */
|
|
58
74
|
readonly source?: MyListingsSource;
|
|
59
75
|
readonly initialTab?: MyListingsTab;
|
|
60
76
|
readonly limit?: number;
|
|
@@ -68,27 +84,49 @@ export function useMyListings(
|
|
|
68
84
|
const [tab, setTabState] = useState<MyListingsTab>(
|
|
69
85
|
options.initialTab ?? "active"
|
|
70
86
|
);
|
|
71
|
-
const [page, setPage] = useState<
|
|
87
|
+
const [page, setPage] = useState<MyListingsParams>(
|
|
72
88
|
options.limit !== undefined ? { limit: options.limit } : {}
|
|
73
89
|
);
|
|
74
90
|
|
|
75
91
|
const counters = useMyCounters();
|
|
76
|
-
const
|
|
92
|
+
const api = useListingsApi();
|
|
93
|
+
const injected = options.source;
|
|
94
|
+
const source = useMemo(
|
|
95
|
+
() => injected ?? defaultMyListingsSource(api),
|
|
96
|
+
[injected, api]
|
|
97
|
+
);
|
|
98
|
+
const ready = sessionReady && gate.available;
|
|
77
99
|
|
|
78
100
|
const rowsQuery = useQuery({
|
|
79
101
|
queryKey: listingsQueryKeys.mine(tab, pageKey(page)),
|
|
102
|
+
queryFn: ({ signal }) => source({ tab, page, signal }),
|
|
103
|
+
enabled: ready,
|
|
104
|
+
retry: false,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The takedowns, off the same route and deliberately NOT paged: a seller
|
|
108
|
+
// with a page of blocked listings has a problem no "next" button improves,
|
|
109
|
+
// and this sits above a dashboard rather than being one.
|
|
110
|
+
const blockedQuery = useQuery({
|
|
111
|
+
queryKey: listingsQueryKeys.mineUntabbed(),
|
|
80
112
|
queryFn: ({ signal }) =>
|
|
81
|
-
(
|
|
82
|
-
enabled:
|
|
113
|
+
api.myListings({ status: MY_LISTINGS_UNTABBED_STATUSES }, { signal }),
|
|
114
|
+
enabled: ready && MY_LISTINGS_UNTABBED_STATUSES.length > 0,
|
|
83
115
|
retry: false,
|
|
84
116
|
});
|
|
85
117
|
|
|
86
|
-
const rows: LoadState<readonly
|
|
87
|
-
if (source === undefined) return loadFailed(MY_LISTINGS_SOURCE_MISSING);
|
|
118
|
+
const rows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
88
119
|
if (rowsQuery.status === "error") return loadFailed(rowsQuery.error);
|
|
89
120
|
if (rowsQuery.data !== undefined) return loadReady(rowsQuery.data.items);
|
|
90
121
|
return loadLoading();
|
|
91
|
-
}, [
|
|
122
|
+
}, [rowsQuery.status, rowsQuery.error, rowsQuery.data]);
|
|
123
|
+
|
|
124
|
+
const blockedRows: LoadState<readonly MyListingCard[]> = useMemo(() => {
|
|
125
|
+
if (MY_LISTINGS_UNTABBED_STATUSES.length === 0) return loadReady([]);
|
|
126
|
+
if (blockedQuery.status === "error") return loadFailed(blockedQuery.error);
|
|
127
|
+
if (blockedQuery.data !== undefined) return loadReady(blockedQuery.data.items);
|
|
128
|
+
return loadLoading();
|
|
129
|
+
}, [blockedQuery.status, blockedQuery.error, blockedQuery.data]);
|
|
92
130
|
|
|
93
131
|
const envelope = rowsQuery.data;
|
|
94
132
|
|
|
@@ -110,6 +148,7 @@ export function useMyListings(
|
|
|
110
148
|
? loadReady(counters.data)
|
|
111
149
|
: loadLoading(),
|
|
112
150
|
rows,
|
|
151
|
+
blockedRows,
|
|
113
152
|
page,
|
|
114
153
|
nextPage:
|
|
115
154
|
envelope?.has_next === true && envelope.next_anchor != null
|
|
@@ -133,6 +172,7 @@ export function useMyListings(
|
|
|
133
172
|
refetch: () => {
|
|
134
173
|
void counters.refetch();
|
|
135
174
|
void rowsQuery.refetch();
|
|
175
|
+
void blockedQuery.refetch();
|
|
136
176
|
},
|
|
137
177
|
};
|
|
138
178
|
}
|
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";
|
|
@@ -21,12 +22,14 @@ export { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
|
21
22
|
export const listingsI18nBundleEs: I18nDictionary = {
|
|
22
23
|
...listingsErrorBundleEs,
|
|
23
24
|
|
|
24
|
-
// ── the
|
|
25
|
+
// ── the 10 stapel_listings-owned codes, pair-authored ────────────────────
|
|
25
26
|
"error.400.category_required": "Hay que elegir una categoría",
|
|
26
27
|
"error.400.image_required":
|
|
27
28
|
"Para publicar el anuncio hace falta al menos una foto",
|
|
28
29
|
"error.400.listing_feature_not_allowed":
|
|
29
30
|
"El detalle «{feature}» no pertenece a esta categoría",
|
|
31
|
+
"error.400.listing_invalid_status_filter":
|
|
32
|
+
"Estado de anuncio desconocido: «{status}»",
|
|
30
33
|
"error.400.publish_validation_failed":
|
|
31
34
|
"El anuncio no pasó la revisión y no se publicó",
|
|
32
35
|
"error.403.listing_not_owner": "Este anuncio no es tuyo",
|
|
@@ -92,6 +95,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
92
95
|
"listings.detail.published_at": "Publicado el {date}",
|
|
93
96
|
"listings.detail.expires_at": "A la venta hasta el {date}",
|
|
94
97
|
"listings.detail.stock": "Disponibles",
|
|
98
|
+
"listings.detail.edit": "Editar el anuncio",
|
|
99
|
+
"listings.detail.take_down": "Retirarlo",
|
|
95
100
|
|
|
96
101
|
"listings.compose.new_title": "Anuncio nuevo",
|
|
97
102
|
"listings.compose.edit_title": "Editar el anuncio",
|
|
@@ -110,8 +115,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
110
115
|
"Escribe el precio como un número, con dos decimales como mucho",
|
|
111
116
|
"listings.compose.currency_label": "Moneda",
|
|
112
117
|
"listings.compose.location_label": "Dónde está",
|
|
113
|
-
"listings.compose.
|
|
114
|
-
|
|
118
|
+
"listings.compose.location_help":
|
|
119
|
+
"La gente busca por distancia — un anuncio sin lugar es un anuncio que no encontrarán",
|
|
115
120
|
"listings.compose.geo_incomplete":
|
|
116
121
|
"Una latitud necesita su longitud al lado — media coordenada no apunta a ningún sitio",
|
|
117
122
|
"listings.compose.photos": "Fotos",
|
|
@@ -141,12 +146,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
141
146
|
"listings.compose.blocked.no_category":
|
|
142
147
|
"Elige una categoría — el resto del formulario depende de ella",
|
|
143
148
|
"listings.compose.blocked.unsupported_type":
|
|
144
|
-
"Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar
|
|
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í",
|
|
145
150
|
"listings.compose.blocked.photos_pending":
|
|
146
151
|
"Espera a que terminen de subirse las fotos",
|
|
147
152
|
"listings.compose.blocked.no_draft": "El borrador todavía no está creado",
|
|
148
153
|
"listings.compose.blocked.busy":
|
|
149
154
|
"Un momento — el último cambio se está guardando",
|
|
155
|
+
"listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
|
|
150
156
|
"listings.compose.blocked.mirror": "Corrige primero los campos marcados",
|
|
151
157
|
"listings.compose.blocked.details_unavailable":
|
|
152
158
|
"No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
|
|
@@ -160,18 +166,32 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
160
166
|
"listings.mine.empty": "Aquí todavía no hay nada",
|
|
161
167
|
"listings.mine.retry": "Reintentar",
|
|
162
168
|
"listings.mine.counters_failed": "No pudimos contar tus anuncios",
|
|
163
|
-
"listings.mine.
|
|
164
|
-
|
|
169
|
+
"listings.mine.empty.active": "No tienes nada publicado ni en revisión",
|
|
170
|
+
"listings.mine.empty.drafts": "Sin borradores: lo que empieces aparecerá aquí",
|
|
171
|
+
"listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
|
|
172
|
+
"listings.mine.blocked.title":
|
|
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",
|
|
176
|
+
"listings.mine.blocked.load_failed":
|
|
177
|
+
"No pudimos comprobar si alguno de tus anuncios fue retirado",
|
|
165
178
|
"listings.mine.live_under_review": "Publicado, cambios en revisión",
|
|
166
179
|
"listings.mine.edit": "Editar",
|
|
167
180
|
"listings.mine.archive": "Archivar",
|
|
168
181
|
"listings.mine.complete": "Marcar como vendido",
|
|
169
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.",
|
|
170
186
|
|
|
171
187
|
"listings.favorites.title": "Favoritos",
|
|
172
188
|
"listings.favorites.loading": "Cargando tus favoritos…",
|
|
173
189
|
"listings.favorites.load_failed": "No pudimos cargar tus favoritos",
|
|
174
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.",
|
|
175
195
|
|
|
176
196
|
"listings.blocked.sign_in": "Inicia sesión para hacer esto",
|
|
177
197
|
"listings.blocked.guest":
|
|
@@ -183,9 +203,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
183
203
|
"listings.blocked.delete_active":
|
|
184
204
|
"Archívalo primero — un anuncio a la venta no se puede borrar",
|
|
185
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",
|
|
186
208
|
|
|
187
209
|
"listings.page.prev": "Anterior",
|
|
188
210
|
"listings.page.next": "Siguiente",
|
|
211
|
+
"listings.page.indicator": "Página {page}",
|
|
189
212
|
|
|
190
213
|
"listings.nav.detail": "Anuncio",
|
|
191
214
|
"listings.nav.compose": "Poner un anuncio",
|
|
@@ -193,7 +216,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
193
216
|
"listings.nav.favorites": "Favoritos",
|
|
194
217
|
};
|
|
195
218
|
|
|
196
|
-
/**
|
|
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
|
+
*/
|
|
197
225
|
export function registerListingsI18nEs(i18n: I18nEngine): void {
|
|
226
|
+
registerAttributesI18nEs(i18n);
|
|
198
227
|
i18n.registerBundle("es", listingsI18nBundleEs);
|
|
199
228
|
}
|
|
@@ -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: 22 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`
|