@stapel/listings-react 0.4.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +102 -0
- package/dist/api/generated/schema.d.ts +2 -2
- 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 +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 +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 +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/i18n/es.d.ts +6 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +21 -4
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +19 -2
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +35 -5
- 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 +33 -4
- package/dist/i18n/ru.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 +7 -6
- package/manifest.json +53 -11
- 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 +2 -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 +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 +10 -0
- package/src/headless/ListingActions.tsx +19 -0
- package/src/headless/ListingComposer.tsx +56 -5
- package/src/i18n/es.ts +26 -4
- package/src/i18n/keys.ts +42 -5
- package/src/i18n/ru.ts +38 -4
- 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";
|
|
@@ -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,
|
package/src/i18n/es.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { I18nDictionary, I18nEngine } from "@stapel/core";
|
|
2
|
+
import { registerAttributesI18nEs } from "@stapel/attributes-react/i18n/es";
|
|
2
3
|
import { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
3
4
|
|
|
4
5
|
export { listingsErrorBundleEs } from "./generated/errors.es.gen.js";
|
|
@@ -94,6 +95,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
94
95
|
"listings.detail.published_at": "Publicado el {date}",
|
|
95
96
|
"listings.detail.expires_at": "A la venta hasta el {date}",
|
|
96
97
|
"listings.detail.stock": "Disponibles",
|
|
98
|
+
"listings.detail.edit": "Editar el anuncio",
|
|
99
|
+
"listings.detail.take_down": "Retirarlo",
|
|
97
100
|
|
|
98
101
|
"listings.compose.new_title": "Anuncio nuevo",
|
|
99
102
|
"listings.compose.edit_title": "Editar el anuncio",
|
|
@@ -112,8 +115,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
112
115
|
"Escribe el precio como un número, con dos decimales como mucho",
|
|
113
116
|
"listings.compose.currency_label": "Moneda",
|
|
114
117
|
"listings.compose.location_label": "Dónde está",
|
|
115
|
-
"listings.compose.
|
|
116
|
-
|
|
118
|
+
"listings.compose.location_help":
|
|
119
|
+
"La gente busca por distancia — un anuncio sin lugar es un anuncio que no encontrarán",
|
|
117
120
|
"listings.compose.geo_incomplete":
|
|
118
121
|
"Una latitud necesita su longitud al lado — media coordenada no apunta a ningún sitio",
|
|
119
122
|
"listings.compose.photos": "Fotos",
|
|
@@ -143,12 +146,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
143
146
|
"listings.compose.blocked.no_category":
|
|
144
147
|
"Elige una categoría — el resto del formulario depende de ella",
|
|
145
148
|
"listings.compose.blocked.unsupported_type":
|
|
146
|
-
"Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar
|
|
149
|
+
"Esta categoría pide un tipo de detalle que la aplicación todavía no sabe mostrar, así que el anuncio no se puede completar aquí",
|
|
147
150
|
"listings.compose.blocked.photos_pending":
|
|
148
151
|
"Espera a que terminen de subirse las fotos",
|
|
149
152
|
"listings.compose.blocked.no_draft": "El borrador todavía no está creado",
|
|
150
153
|
"listings.compose.blocked.busy":
|
|
151
154
|
"Un momento — el último cambio se está guardando",
|
|
155
|
+
"listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
|
|
152
156
|
"listings.compose.blocked.mirror": "Corrige primero los campos marcados",
|
|
153
157
|
"listings.compose.blocked.details_unavailable":
|
|
154
158
|
"No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
|
|
@@ -167,6 +171,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
167
171
|
"listings.mine.empty.archived": "Nada archivado, pausado, caducado ni vendido todavía",
|
|
168
172
|
"listings.mine.blocked.title":
|
|
169
173
|
"Moderación retiró {count} de tus anuncios",
|
|
174
|
+
"listings.mine.blocked.title.one": "Moderación retiró uno de tus anuncios",
|
|
175
|
+
"listings.mine.blocked.title.other": "Moderación retiró {count} de tus anuncios",
|
|
170
176
|
"listings.mine.blocked.load_failed":
|
|
171
177
|
"No pudimos comprobar si alguno de tus anuncios fue retirado",
|
|
172
178
|
"listings.mine.live_under_review": "Publicado, cambios en revisión",
|
|
@@ -174,11 +180,18 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
174
180
|
"listings.mine.archive": "Archivar",
|
|
175
181
|
"listings.mine.complete": "Marcar como vendido",
|
|
176
182
|
"listings.mine.delete": "Borrar",
|
|
183
|
+
"listings.mine.delete_confirm_title": "¿Borrar este anuncio?",
|
|
184
|
+
"listings.mine.delete_confirm_body":
|
|
185
|
+
"Desaparece de tu panel y no se puede recuperar. Archivarlo lo conserva.",
|
|
177
186
|
|
|
178
187
|
"listings.favorites.title": "Favoritos",
|
|
179
188
|
"listings.favorites.loading": "Cargando tus favoritos…",
|
|
180
189
|
"listings.favorites.load_failed": "No pudimos cargar tus favoritos",
|
|
181
190
|
"listings.favorites.empty": "Todavía no has guardado nada",
|
|
191
|
+
"listings.favorites.empty_hint":
|
|
192
|
+
"Toca el corazón en cualquier anuncio y te estará esperando aquí.",
|
|
193
|
+
"listings.favorites.sign_in_hint":
|
|
194
|
+
"Los favoritos se guardan en tu cuenta, así que te siguen entre dispositivos.",
|
|
182
195
|
|
|
183
196
|
"listings.blocked.sign_in": "Inicia sesión para hacer esto",
|
|
184
197
|
"listings.blocked.guest":
|
|
@@ -190,9 +203,12 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
190
203
|
"listings.blocked.delete_active":
|
|
191
204
|
"Archívalo primero — un anuncio a la venta no se puede borrar",
|
|
192
205
|
"listings.blocked.in_flight": "Un momento — eso ya está en marcha",
|
|
206
|
+
"listings.blocked.no_editor":
|
|
207
|
+
"Esta aplicación todavía no tiene una pantalla para editar un anuncio",
|
|
193
208
|
|
|
194
209
|
"listings.page.prev": "Anterior",
|
|
195
210
|
"listings.page.next": "Siguiente",
|
|
211
|
+
"listings.page.indicator": "Página {page}",
|
|
196
212
|
|
|
197
213
|
"listings.nav.detail": "Anuncio",
|
|
198
214
|
"listings.nav.compose": "Poner un anuncio",
|
|
@@ -200,7 +216,13 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
200
216
|
"listings.nav.favorites": "Favoritos",
|
|
201
217
|
};
|
|
202
218
|
|
|
203
|
-
/**
|
|
219
|
+
/**
|
|
220
|
+
* Register the Spanish bundle — and, exactly as in `./ru.ts`, the twelve
|
|
221
|
+
* `stapel_attributes` sentences this pair does not author. The ownership split
|
|
222
|
+
* is a rule about who WRITES a string, not an instruction to ship two thirds
|
|
223
|
+
* of a locale and document the rest.
|
|
224
|
+
*/
|
|
204
225
|
export function registerListingsI18nEs(i18n: I18nEngine): void {
|
|
226
|
+
registerAttributesI18nEs(i18n);
|
|
205
227
|
i18n.registerBundle("es", listingsI18nBundleEs);
|
|
206
228
|
}
|
package/src/i18n/keys.ts
CHANGED
|
@@ -88,6 +88,9 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
88
88
|
* quantity in the value cell, so this key carries no `{count}` — it used to,
|
|
89
89
|
* and the live page printed the placeholder. */
|
|
90
90
|
detailStock: "listings.detail.stock",
|
|
91
|
+
/** The owner's two primaries on their own listing page. */
|
|
92
|
+
detailEdit: "listings.detail.edit",
|
|
93
|
+
detailTakeDown: "listings.detail.take_down",
|
|
91
94
|
|
|
92
95
|
// ── composer ─────────────────────────────────────────────────────────────
|
|
93
96
|
composeNewTitle: "listings.compose.new_title",
|
|
@@ -103,8 +106,7 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
103
106
|
composePriceInvalid: "listings.compose.price_invalid",
|
|
104
107
|
composeCurrencyLabel: "listings.compose.currency_label",
|
|
105
108
|
composeLocationLabel: "listings.compose.location_label",
|
|
106
|
-
|
|
107
|
-
composeLonLabel: "listings.compose.lon_label",
|
|
109
|
+
composeLocationHelp: "listings.compose.location_help",
|
|
108
110
|
composeGeoIncomplete: "listings.compose.geo_incomplete",
|
|
109
111
|
composePhotos: "listings.compose.photos",
|
|
110
112
|
composeTooManyImages: "listings.compose.too_many_images",
|
|
@@ -132,6 +134,10 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
132
134
|
composeBlockedNoDraft: "listings.compose.blocked.no_draft",
|
|
133
135
|
composeBlockedBusy: "listings.compose.blocked.busy",
|
|
134
136
|
composeBlockedMirror: "listings.compose.blocked.mirror",
|
|
137
|
+
/** The same refusal BEFORE anything is marked: "fix the highlighted fields"
|
|
138
|
+
* pointed at nothing highlighted, because the mirror only becomes visible
|
|
139
|
+
* once the person has tried to publish. */
|
|
140
|
+
composeBlockedIncomplete: "listings.compose.blocked.incomplete",
|
|
135
141
|
composeBlockedDetailsUnavailable: "listings.compose.blocked.details_unavailable",
|
|
136
142
|
|
|
137
143
|
// ── the owner's dashboard ────────────────────────────────────────────────
|
|
@@ -158,12 +164,18 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
158
164
|
mineArchive: "listings.mine.archive",
|
|
159
165
|
mineComplete: "listings.mine.complete",
|
|
160
166
|
mineDelete: "listings.mine.delete",
|
|
167
|
+
/** Deleting is irreversible, so it asks — through the shared SkinConfirm,
|
|
168
|
+
* which is a bottom sheet on a phone. */
|
|
169
|
+
mineDeleteConfirmTitle: "listings.mine.delete_confirm_title",
|
|
170
|
+
mineDeleteConfirmBody: "listings.mine.delete_confirm_body",
|
|
161
171
|
|
|
162
172
|
// ── favourites ───────────────────────────────────────────────────────────
|
|
163
173
|
favoritesTitle: "listings.favorites.title",
|
|
164
174
|
favoritesLoading: "listings.favorites.loading",
|
|
165
175
|
favoritesLoadFailed: "listings.favorites.load_failed",
|
|
166
176
|
favoritesEmpty: "listings.favorites.empty",
|
|
177
|
+
favoritesEmptyHint: "listings.favorites.empty_hint",
|
|
178
|
+
favoritesSignInHint: "listings.favorites.sign_in_hint",
|
|
167
179
|
|
|
168
180
|
// ── gates shared by every action a visitor cannot take ───────────────────
|
|
169
181
|
blockedSignIn: "listings.blocked.sign_in",
|
|
@@ -174,10 +186,15 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
174
186
|
blockedTransition: "listings.blocked.transition",
|
|
175
187
|
blockedDeleteActive: "listings.blocked.delete_active",
|
|
176
188
|
blockedInFlight: "listings.blocked.in_flight",
|
|
189
|
+
/** Editing is a SCREEN, not an endpoint: whether this app has one is the
|
|
190
|
+
* container's fact, and a host that wires no `onEdit` gets a switched-off
|
|
191
|
+
* button that says so instead of one that silently does nothing. */
|
|
192
|
+
blockedNoEditor: "listings.blocked.no_editor",
|
|
177
193
|
|
|
178
194
|
// ── keyset pagination ────────────────────────────────────────────────────
|
|
179
195
|
pagePrev: "listings.page.prev",
|
|
180
196
|
pageNext: "listings.page.next",
|
|
197
|
+
pageIndicator: "listings.page.indicator",
|
|
181
198
|
|
|
182
199
|
// ── nav labels ───────────────────────────────────────────────────────────
|
|
183
200
|
navDetail: "listings.nav.detail",
|
|
@@ -252,6 +269,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
252
269
|
"listings.detail.published_at": "Published {date}",
|
|
253
270
|
"listings.detail.expires_at": "Listed until {date}",
|
|
254
271
|
"listings.detail.stock": "In stock",
|
|
272
|
+
"listings.detail.edit": "Edit listing",
|
|
273
|
+
"listings.detail.take_down": "Take it down",
|
|
255
274
|
|
|
256
275
|
"listings.compose.new_title": "New listing",
|
|
257
276
|
"listings.compose.edit_title": "Edit listing",
|
|
@@ -270,8 +289,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
270
289
|
"Enter a price as a number, with at most two decimals",
|
|
271
290
|
"listings.compose.currency_label": "Currency",
|
|
272
291
|
"listings.compose.location_label": "Where it is",
|
|
273
|
-
"listings.compose.
|
|
274
|
-
|
|
292
|
+
"listings.compose.location_help":
|
|
293
|
+
"Buyers filter by distance, so a listing with no place is a listing they will not find",
|
|
275
294
|
"listings.compose.geo_incomplete":
|
|
276
295
|
"A latitude needs a longitude beside it — half a coordinate points nowhere",
|
|
277
296
|
"listings.compose.photos": "Photos",
|
|
@@ -300,14 +319,18 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
300
319
|
|
|
301
320
|
"listings.compose.blocked.no_category":
|
|
302
321
|
"Choose a category — the rest of the form depends on it",
|
|
322
|
+
// No `{types}`: the editor type is this build's vocabulary (`size_grid`),
|
|
323
|
+
// and a seller can do nothing with it. The fact they CAN act on is that
|
|
324
|
+
// this listing has to be finished somewhere else.
|
|
303
325
|
"listings.compose.blocked.unsupported_type":
|
|
304
|
-
"This category asks for a kind of detail this app cannot show yet
|
|
326
|
+
"This category asks for a kind of detail this app cannot show yet, so the listing cannot be completed here",
|
|
305
327
|
"listings.compose.blocked.photos_pending":
|
|
306
328
|
"Wait for the photos to finish uploading",
|
|
307
329
|
"listings.compose.blocked.no_draft": "The draft has not been created yet",
|
|
308
330
|
"listings.compose.blocked.busy": "One moment — the last change is still saving",
|
|
309
331
|
"listings.compose.blocked.mirror":
|
|
310
332
|
"Fix the highlighted fields first",
|
|
333
|
+
"listings.compose.blocked.incomplete": "{count} required details are still empty",
|
|
311
334
|
"listings.compose.blocked.details_unavailable":
|
|
312
335
|
"We could not load what this category asks for, so we cannot check the form",
|
|
313
336
|
|
|
@@ -325,6 +348,10 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
325
348
|
"listings.mine.empty.archived": "Nothing archived, paused, expired or sold yet",
|
|
326
349
|
"listings.mine.blocked.title":
|
|
327
350
|
"{count} of your listings were taken down by moderation",
|
|
351
|
+
"listings.mine.blocked.title.one":
|
|
352
|
+
"One of your listings was taken down by moderation",
|
|
353
|
+
"listings.mine.blocked.title.other":
|
|
354
|
+
"{count} of your listings were taken down by moderation",
|
|
328
355
|
"listings.mine.blocked.load_failed":
|
|
329
356
|
"We could not check whether any of your listings were taken down",
|
|
330
357
|
"listings.mine.live_under_review":
|
|
@@ -333,11 +360,18 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
333
360
|
"listings.mine.archive": "Archive",
|
|
334
361
|
"listings.mine.complete": "Mark sold",
|
|
335
362
|
"listings.mine.delete": "Delete",
|
|
363
|
+
"listings.mine.delete_confirm_title": "Delete this listing?",
|
|
364
|
+
"listings.mine.delete_confirm_body":
|
|
365
|
+
"It disappears from your dashboard and cannot be brought back. Archiving keeps it.",
|
|
336
366
|
|
|
337
367
|
"listings.favorites.title": "Favourites",
|
|
338
368
|
"listings.favorites.loading": "Loading your favourites…",
|
|
339
369
|
"listings.favorites.load_failed": "We could not load your favourites",
|
|
340
370
|
"listings.favorites.empty": "You have not saved anything yet",
|
|
371
|
+
"listings.favorites.empty_hint":
|
|
372
|
+
"Tap the heart on any listing and it will be waiting here.",
|
|
373
|
+
"listings.favorites.sign_in_hint":
|
|
374
|
+
"Favourites are kept with your account, so they follow you between devices.",
|
|
341
375
|
|
|
342
376
|
"listings.blocked.sign_in": "Sign in to do this",
|
|
343
377
|
"listings.blocked.guest":
|
|
@@ -349,9 +383,12 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
349
383
|
"listings.blocked.delete_active":
|
|
350
384
|
"Archive it first — a listing that is on sale cannot be deleted",
|
|
351
385
|
"listings.blocked.in_flight": "One moment — that is already under way",
|
|
386
|
+
"listings.blocked.no_editor":
|
|
387
|
+
"This app has no screen for editing a listing yet",
|
|
352
388
|
|
|
353
389
|
"listings.page.prev": "Previous",
|
|
354
390
|
"listings.page.next": "Next",
|
|
391
|
+
"listings.page.indicator": "Page {page}",
|
|
355
392
|
|
|
356
393
|
"listings.nav.detail": "Listing",
|
|
357
394
|
"listings.nav.compose": "Post a listing",
|