@stapel/listings-react 0.10.0 → 0.12.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 +197 -0
- package/dist/api/types.d.ts +10 -0
- package/dist/api/types.d.ts.map +1 -1
- package/dist/api/types.js.map +1 -1
- package/dist/default/ListingCard.d.ts +2 -2
- package/dist/default/ListingCard.d.ts.map +1 -1
- package/dist/default/ListingCard.js +8 -2
- package/dist/default/ListingCard.js.map +1 -1
- package/dist/default/ListingComposerPage.d.ts +22 -62
- package/dist/default/ListingComposerPage.d.ts.map +1 -1
- package/dist/default/ListingComposerPage.js +228 -56
- package/dist/default/ListingComposerPage.js.map +1 -1
- package/dist/default/ListingDetailPane.d.ts +2 -2
- package/dist/default/ListingDetailPane.d.ts.map +1 -1
- package/dist/default/ListingDetailPane.js +6 -1
- package/dist/default/ListingDetailPane.js.map +1 -1
- package/dist/default/ListingSerpCard.d.ts +2 -2
- package/dist/default/ListingSerpCard.d.ts.map +1 -1
- package/dist/default/ListingSerpCard.js +7 -2
- package/dist/default/ListingSerpCard.js.map +1 -1
- package/dist/default/index.d.ts +2 -2
- package/dist/default/index.d.ts.map +1 -1
- package/dist/default/index.js +1 -1
- package/dist/default/index.js.map +1 -1
- package/dist/default/types.d.ts +21 -0
- package/dist/default/types.d.ts.map +1 -1
- package/dist/headless/ListingComposer.d.ts +11 -0
- package/dist/headless/ListingComposer.d.ts.map +1 -1
- package/dist/headless/ListingComposer.js +31 -6
- package/dist/headless/ListingComposer.js.map +1 -1
- package/dist/headless/ListingDetail.d.ts +16 -1
- package/dist/headless/ListingDetail.d.ts.map +1 -1
- package/dist/headless/ListingDetail.js +13 -4
- package/dist/headless/ListingDetail.js.map +1 -1
- package/dist/i18n/es.d.ts.map +1 -1
- package/dist/i18n/es.js +4 -0
- package/dist/i18n/es.js.map +1 -1
- package/dist/i18n/keys.d.ts +10 -0
- package/dist/i18n/keys.d.ts.map +1 -1
- package/dist/i18n/keys.js +16 -0
- package/dist/i18n/keys.js.map +1 -1
- package/dist/i18n/ru.d.ts.map +1 -1
- package/dist/i18n/ru.js +4 -0
- package/dist/i18n/ru.js.map +1 -1
- package/dist/index.d.ts +2 -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/features.d.ts +90 -20
- package/dist/model/features.d.ts.map +1 -1
- package/dist/model/features.js +148 -17
- package/dist/model/features.js.map +1 -1
- package/dist/model/validation.d.ts +32 -0
- package/dist/model/validation.d.ts.map +1 -1
- package/dist/model/validation.js +82 -1
- package/dist/model/validation.js.map +1 -1
- package/dist/nav/manifest.d.ts.map +1 -1
- package/dist/nav/manifest.js +2 -0
- package/dist/nav/manifest.js.map +1 -1
- package/llms.txt +1 -1
- package/manifest.json +8 -1
- package/nav-manifest.json +3 -1
- package/package.json +7 -7
- package/src/analytics/generated/events.json +1 -1
- package/src/api/types.ts +10 -0
- package/src/default/ListingCard.tsx +17 -4
- package/src/default/ListingComposerPage.tsx +221 -36
- package/src/default/ListingDetailPane.tsx +10 -6
- package/src/default/ListingSerpCard.tsx +13 -4
- package/src/default/index.ts +7 -2
- package/src/default/types.ts +22 -0
- package/src/headless/ListingComposer.tsx +47 -6
- package/src/headless/ListingDetail.tsx +37 -10
- package/src/i18n/es.ts +5 -0
- package/src/i18n/keys.ts +17 -0
- package/src/i18n/ru.ts +5 -0
- package/src/index.ts +3 -0
- package/src/model/features.ts +253 -35
- package/src/model/validation.ts +84 -1
- package/src/nav/manifest.ts +2 -0
|
@@ -59,7 +59,7 @@ import { HeartIcon } from "./icons.js";
|
|
|
59
59
|
import { ListingPhoto } from "./ListingPhoto.js";
|
|
60
60
|
import { ListingPrice } from "./ListingPrice.js";
|
|
61
61
|
import { ListingStatusBlock } from "./StatusTags.js";
|
|
62
|
-
import type { ThemeModeProp } from "./types.js";
|
|
62
|
+
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
63
63
|
|
|
64
64
|
/** The reading measure of the page body. A detail page is prose plus a spec
|
|
65
65
|
* table; past this it stops being one column and starts being a stripe across
|
|
@@ -73,7 +73,9 @@ export const DETAIL_MEASURE = "60rem";
|
|
|
73
73
|
* postage stamp on the other. */
|
|
74
74
|
export const DETAIL_PHOTO_MIN = "14rem";
|
|
75
75
|
|
|
76
|
-
export interface ListingDetailPaneProps
|
|
76
|
+
export interface ListingDetailPaneProps
|
|
77
|
+
extends ThemeModeProp,
|
|
78
|
+
CategoryFeaturesProp {
|
|
77
79
|
readonly id: number;
|
|
78
80
|
/** The reader's own uuid, when the host knows it. Enables the owner view —
|
|
79
81
|
* the only place the moderation axis is shown, because it is the only
|
|
@@ -98,10 +100,12 @@ export interface ListingDetailPaneProps extends ThemeModeProp {
|
|
|
98
100
|
export function ListingDetailPane(props: ListingDetailPaneProps): ReactElement {
|
|
99
101
|
const t = useT();
|
|
100
102
|
const { locale } = useI18n();
|
|
101
|
-
const bag = useListingDetail(
|
|
102
|
-
props.
|
|
103
|
-
props.
|
|
104
|
-
|
|
103
|
+
const bag = useListingDetail(props.id, {
|
|
104
|
+
...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
|
|
105
|
+
...(props.categoryFeatures !== undefined
|
|
106
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
107
|
+
: {}),
|
|
108
|
+
});
|
|
105
109
|
const owner = bag.viewerIsOwner === true;
|
|
106
110
|
const actions = useListingActions(props.id, bag.status?.lifecycle.status);
|
|
107
111
|
const editGate = actions.editGate(props.onEdit !== undefined);
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
featuresDtoFromDaoList,
|
|
75
75
|
featuresFromDaoList,
|
|
76
76
|
} from "../model/features.js";
|
|
77
|
+
import type { FeatureCopySource } from "../model/features.js";
|
|
77
78
|
import { lifecycleCaption } from "../model/status.js";
|
|
78
79
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
79
80
|
import { PriceTrendIcon } from "./icons.js";
|
|
@@ -86,7 +87,7 @@ import {
|
|
|
86
87
|
import type { ListingCardOpenProps } from "./ListingCard.js";
|
|
87
88
|
import { LISTING_PHOTO_ASPECT, ListingPhoto } from "./ListingPhoto.js";
|
|
88
89
|
import { ListingPrice } from "./ListingPrice.js";
|
|
89
|
-
import type { ThemeModeProp } from "./types.js";
|
|
90
|
+
import type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
93
|
* A price that moved, as the ref draws it: the old figure struck through and
|
|
@@ -103,7 +104,9 @@ export interface ListingPriceTrend {
|
|
|
103
104
|
readonly direction: "down" | "up";
|
|
104
105
|
}
|
|
105
106
|
|
|
106
|
-
export interface ListingSerpCardBaseProps
|
|
107
|
+
export interface ListingSerpCardBaseProps
|
|
108
|
+
extends ThemeModeProp,
|
|
109
|
+
CategoryFeaturesProp {
|
|
107
110
|
readonly listing: ListingCardData;
|
|
108
111
|
/**
|
|
109
112
|
* The seller's own one-line summary — "Petrol 1.5 (147 hp), robot, front".
|
|
@@ -149,7 +152,13 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
149
152
|
const { token } = antdTheme.useToken();
|
|
150
153
|
|
|
151
154
|
const badgeDaos = asFeatureDaoList(listing.features_badges);
|
|
152
|
-
|
|
155
|
+
// See `CategoryFeaturesProp`: the option table a stored `select` does not
|
|
156
|
+
// carry, when this surface knows which category it is drawing.
|
|
157
|
+
const copy: FeatureCopySource =
|
|
158
|
+
props.categoryFeatures !== undefined
|
|
159
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
160
|
+
: {};
|
|
161
|
+
const badgeFeatures = featuresFromDaoList(badgeDaos, copy);
|
|
153
162
|
const badgeValues = featuresDtoFromDaoList(badgeDaos);
|
|
154
163
|
// The fallback spec line when the host derived none: the row's own
|
|
155
164
|
// `features_title` projection, exactly the line `ListingCard` draws.
|
|
@@ -315,7 +324,7 @@ export function ListingSerpCard(props: ListingSerpCardProps): ReactElement {
|
|
|
315
324
|
data-testid="listings-serp-specs"
|
|
316
325
|
>
|
|
317
326
|
<FeatureBadges
|
|
318
|
-
features={featuresFromDaoList(titleDaos).map(
|
|
327
|
+
features={featuresFromDaoList(titleDaos, copy).map(
|
|
319
328
|
(view) => view.feature,
|
|
320
329
|
)}
|
|
321
330
|
values={featuresDtoFromDaoList(titleDaos)}
|
package/src/default/index.ts
CHANGED
|
@@ -55,7 +55,12 @@ export { FeedGrid, FEED_GRID_COLUMNS } from "./FeedGrid.js";
|
|
|
55
55
|
export type { FeedGridProps } from "./FeedGrid.js";
|
|
56
56
|
export { ListingDetailPane, DETAIL_MEASURE, DETAIL_PHOTO_MIN } from "./ListingDetailPane.js";
|
|
57
57
|
export type { ListingDetailPaneProps } from "./ListingDetailPane.js";
|
|
58
|
-
export {
|
|
58
|
+
export {
|
|
59
|
+
ListingComposerPage,
|
|
60
|
+
COMPOSER_MEASURE,
|
|
61
|
+
COMPOSER_STACKED_BELOW,
|
|
62
|
+
composerFieldId,
|
|
63
|
+
} from "./ListingComposerPage.js";
|
|
59
64
|
export type {
|
|
60
65
|
ListingComposerPageProps,
|
|
61
66
|
ComposerCategorySlot,
|
|
@@ -78,4 +83,4 @@ export { ListingPhoto, LISTING_PHOTO_ASPECT } from "./ListingPhoto.js";
|
|
|
78
83
|
export type { ListingPhotoProps } from "./ListingPhoto.js";
|
|
79
84
|
export { SignInLink } from "./SignInLink.js";
|
|
80
85
|
export type { SignInLinkProps } from "./SignInLink.js";
|
|
81
|
-
export type { ThemeModeProp } from "./types.js";
|
|
86
|
+
export type { CategoryFeaturesProp, ThemeModeProp } from "./types.js";
|
package/src/default/types.ts
CHANGED
|
@@ -4,9 +4,31 @@
|
|
|
4
4
|
*/
|
|
5
5
|
export type { FlowError } from "@stapel/core";
|
|
6
6
|
import type { ThemeMode } from "@stapel/tokens-antd";
|
|
7
|
+
import type { FeatureDef } from "@stapel/attributes-react";
|
|
7
8
|
|
|
8
9
|
/** Every `/default` surface accepts a theme mode; absent means "whatever the
|
|
9
10
|
* host document declares" (`resolveThemeMode()`), never a hardcoded side. */
|
|
10
11
|
export interface ThemeModeProp {
|
|
11
12
|
readonly mode?: ThemeMode;
|
|
12
13
|
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Every surface that DISPLAYS stored feature values takes this, and it is
|
|
17
|
+
* optional on all of them.
|
|
18
|
+
*
|
|
19
|
+
* A stored `select` carries its chosen values and no option table (the table
|
|
20
|
+
* lives on the category), so a row written before the label snapshot existed
|
|
21
|
+
* prints its storage slug — "b-u" where the catalogue holds the copy. A
|
|
22
|
+
* surface that knows which category it is drawing can hand the category's own
|
|
23
|
+
* defs over and the copy is repaired; a mixed grid of forty categories knows
|
|
24
|
+
* no such thing, passes nothing, and renders exactly what it renders today.
|
|
25
|
+
*
|
|
26
|
+
* The precedence between these defs and what the row itself stored is
|
|
27
|
+
* `model/features.ts`' business — see {@link FeatureCopySource}.
|
|
28
|
+
*/
|
|
29
|
+
export interface CategoryFeaturesProp {
|
|
30
|
+
/** The category's features, as
|
|
31
|
+
* `GET /categories/api/v1/categories/{id}/features/` answers — the same
|
|
32
|
+
* array `<ListingComposerPage>` takes. */
|
|
33
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
34
|
+
}
|
|
@@ -39,7 +39,12 @@ import {
|
|
|
39
39
|
import type { ListingDraftValues, ListingLocation } from "../model/draft.js";
|
|
40
40
|
import { asFeatureDaoList } from "../model/features.js";
|
|
41
41
|
import { featuresDtoFromDaoList } from "../model/features.js";
|
|
42
|
-
import {
|
|
42
|
+
import {
|
|
43
|
+
LISTING_FIELD_ORDER,
|
|
44
|
+
envelopeFieldErrors,
|
|
45
|
+
mirrorDraft,
|
|
46
|
+
publishRefusal,
|
|
47
|
+
} from "../model/validation.js";
|
|
43
48
|
import type { PublishRefusal } from "../model/validation.js";
|
|
44
49
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
45
50
|
import { useMandateGate } from "./useMandateGate.js";
|
|
@@ -197,6 +202,17 @@ export interface ListingComposerBag {
|
|
|
197
202
|
readonly fieldErrors: Readonly<Record<string, FlowError>>;
|
|
198
203
|
/** The server's last publish refusal, unrouted, for a summary line. */
|
|
199
204
|
readonly refusal: PublishRefusal | undefined;
|
|
205
|
+
/**
|
|
206
|
+
* The first field the mirror is refusing, in the order the form asks for it
|
|
207
|
+
* (`LISTING_FIELD_ORDER`, then the category's features in schema order), or
|
|
208
|
+
* `undefined` when nothing is refused.
|
|
209
|
+
*
|
|
210
|
+
* The publish gate can say "10 required details are still empty" while not
|
|
211
|
+
* one of them is on screen — on a phone the attribute region starts nearly
|
|
212
|
+
* two viewports below the fold — and a count with nowhere to go is a dead
|
|
213
|
+
* end. This is what a skin's "take me there" control aims at.
|
|
214
|
+
*/
|
|
215
|
+
readonly firstUnsatisfied: string | undefined;
|
|
200
216
|
/** Value types this build cannot draw — the fact, from attributes-react. */
|
|
201
217
|
readonly unsupported: readonly string[];
|
|
202
218
|
|
|
@@ -358,12 +374,36 @@ export function useListingComposer(
|
|
|
358
374
|
(existing.data.title ?? "").length === 0 &&
|
|
359
375
|
(existing.data.description ?? "").length === 0;
|
|
360
376
|
|
|
377
|
+
// A save-draft/create 400 names its field in the envelope rather than in a
|
|
378
|
+
// batch, so it takes the other door onto the same routing table. Without it
|
|
379
|
+
// the composer painted a banner and left every control clean — the person
|
|
380
|
+
// was told something was wrong and not what (blocker C2).
|
|
381
|
+
const saveThrown: unknown = saveDraft.error ?? createDraft.error;
|
|
361
382
|
const fieldErrors: Readonly<Record<string, FlowError>> = useMemo(() => {
|
|
362
383
|
const shown = showErrors ? mirror : {};
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
384
|
+
const routed =
|
|
385
|
+
refusal?.kind === "invalid_draft"
|
|
386
|
+
? refusal.fieldErrors
|
|
387
|
+
: refusal?.kind === "error"
|
|
388
|
+
? envelopeFieldErrors(refusal.error)
|
|
389
|
+
: {};
|
|
390
|
+
return { ...shown, ...envelopeFieldErrors(saveThrown), ...routed };
|
|
391
|
+
}, [showErrors, mirror, refusal, saveThrown]);
|
|
392
|
+
|
|
393
|
+
// The order is the FORM's, not the record's — see `LISTING_FIELD_ORDER`.
|
|
394
|
+
// A key in neither list (a server field with no control of its own) is still
|
|
395
|
+
// named rather than dropped: something is refusing, and saying which is
|
|
396
|
+
// better than saying nothing.
|
|
397
|
+
const firstUnsatisfied: string | undefined = useMemo(() => {
|
|
398
|
+
const refused = mirror;
|
|
399
|
+
for (const field of LISTING_FIELD_ORDER) {
|
|
400
|
+
if (refused[field] !== undefined) return field;
|
|
401
|
+
}
|
|
402
|
+
for (const feature of options.features) {
|
|
403
|
+
if (refused[feature.slug] !== undefined) return feature.slug;
|
|
404
|
+
}
|
|
405
|
+
return Object.keys(refused)[0];
|
|
406
|
+
}, [mirror, options.features]);
|
|
367
407
|
|
|
368
408
|
const listingState: LoadState<ListingDetailData> | undefined =
|
|
369
409
|
options.listingId === undefined
|
|
@@ -558,6 +598,7 @@ export function useListingComposer(
|
|
|
558
598
|
mirror,
|
|
559
599
|
fieldErrors,
|
|
560
600
|
refusal,
|
|
601
|
+
firstUnsatisfied,
|
|
561
602
|
unsupported,
|
|
562
603
|
|
|
563
604
|
saveGate,
|
|
@@ -568,7 +609,7 @@ export function useListingComposer(
|
|
|
568
609
|
saving: busy,
|
|
569
610
|
publishing: publishListing.isPending,
|
|
570
611
|
saved,
|
|
571
|
-
saveError:
|
|
612
|
+
saveError: saveThrown,
|
|
572
613
|
outcome,
|
|
573
614
|
};
|
|
574
615
|
}
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
} from "@stapel/core";
|
|
12
12
|
import type { ActionAvailability, LoadState } from "@stapel/core";
|
|
13
13
|
import type {
|
|
14
|
+
FeatureDef,
|
|
14
15
|
ListingDetail as ListingDetailData,
|
|
15
16
|
ListingFeatureView,
|
|
16
17
|
ListingStatusInfo,
|
|
@@ -18,6 +19,7 @@ import type {
|
|
|
18
19
|
import { useListing, useListingStatus } from "../model/queries.js";
|
|
19
20
|
import { useFavoriteListing } from "../model/mutations.js";
|
|
20
21
|
import { asFeatureDaoList, featuresFromDaoList, unreadableFeatureCount } from "../model/features.js";
|
|
22
|
+
import type { FeatureCopySource } from "../model/features.js";
|
|
21
23
|
import { listingStatusView } from "../model/status.js";
|
|
22
24
|
import type { ListingStatusView } from "../model/status.js";
|
|
23
25
|
import { LISTINGS_I18N_KEYS } from "../i18n/keys.js";
|
|
@@ -104,6 +106,19 @@ export interface UseListingDetailOptions {
|
|
|
104
106
|
/** The reader's own id, when the host knows it — enables `viewerIsOwner`.
|
|
105
107
|
* A uuid string, matching `ListingDetail.owner` / `ListingStatus.owner_id`. */
|
|
106
108
|
readonly viewerId?: string;
|
|
109
|
+
/**
|
|
110
|
+
* The listing's category features, when the container has them — the same
|
|
111
|
+
* `readonly FeatureDef[]` the composer takes, from
|
|
112
|
+
* `@stapel/categories-react`'s `useCategoryFeatures`.
|
|
113
|
+
*
|
|
114
|
+
* A stored `select` carries its chosen VALUES and no option table, so a row
|
|
115
|
+
* written before labels were snapshotted prints its storage slug (`b-u`)
|
|
116
|
+
* where the category holds the copy. Handing the category's defs in repairs
|
|
117
|
+
* that; handing nothing in leaves every rendered value exactly as it is
|
|
118
|
+
* today. Which definition wins over which is `model/features.ts`' business —
|
|
119
|
+
* this is only the wire it travels on.
|
|
120
|
+
*/
|
|
121
|
+
readonly categoryFeatures?: readonly FeatureDef[];
|
|
107
122
|
}
|
|
108
123
|
|
|
109
124
|
export function useListingDetail(
|
|
@@ -166,17 +181,25 @@ export function useListingDetail(
|
|
|
166
181
|
? undefined
|
|
167
182
|
: owner === options.viewerId;
|
|
168
183
|
|
|
184
|
+
// One object, rebuilt only when the defs themselves change, so the three
|
|
185
|
+
// projections below keep their memo across renders that touched neither.
|
|
186
|
+
const categoryFeatures = options.categoryFeatures;
|
|
187
|
+
const copy: FeatureCopySource = useMemo(
|
|
188
|
+
() => (categoryFeatures !== undefined ? { categoryFeatures } : {}),
|
|
189
|
+
[categoryFeatures]
|
|
190
|
+
);
|
|
191
|
+
|
|
169
192
|
const features = useMemo(
|
|
170
|
-
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features)),
|
|
171
|
-
[detail.data]
|
|
193
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features), copy),
|
|
194
|
+
[detail.data, copy]
|
|
172
195
|
);
|
|
173
196
|
const titleFeatures = useMemo(
|
|
174
|
-
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title)),
|
|
175
|
-
[detail.data]
|
|
197
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_title), copy),
|
|
198
|
+
[detail.data, copy]
|
|
176
199
|
);
|
|
177
200
|
const badgeFeatures = useMemo(
|
|
178
|
-
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges)),
|
|
179
|
-
[detail.data]
|
|
201
|
+
() => featuresFromDaoList(asFeatureDaoList(detail.data?.features_badges), copy),
|
|
202
|
+
[detail.data, copy]
|
|
180
203
|
);
|
|
181
204
|
|
|
182
205
|
const isFavorited = detail.data?.is_favorited ?? undefined;
|
|
@@ -228,11 +251,15 @@ export function useListingDetail(
|
|
|
228
251
|
export function ListingDetail(props: {
|
|
229
252
|
id: number;
|
|
230
253
|
viewerId?: string;
|
|
254
|
+
/** See {@link UseListingDetailOptions.categoryFeatures}. */
|
|
255
|
+
categoryFeatures?: readonly FeatureDef[];
|
|
231
256
|
children: (bag: ListingDetailBag) => ReactNode;
|
|
232
257
|
}): ReactElement {
|
|
233
|
-
const bag = useListingDetail(
|
|
234
|
-
props.
|
|
235
|
-
props.
|
|
236
|
-
|
|
258
|
+
const bag = useListingDetail(props.id, {
|
|
259
|
+
...(props.viewerId !== undefined ? { viewerId: props.viewerId } : {}),
|
|
260
|
+
...(props.categoryFeatures !== undefined
|
|
261
|
+
? { categoryFeatures: props.categoryFeatures }
|
|
262
|
+
: {}),
|
|
263
|
+
});
|
|
237
264
|
return <>{props.children(bag)}</>;
|
|
238
265
|
}
|
package/src/i18n/es.ts
CHANGED
|
@@ -130,6 +130,8 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
130
130
|
"Un anuncio admite como mucho {max} fotos",
|
|
131
131
|
"listings.compose.details": "Detalles",
|
|
132
132
|
"listings.compose.details_loading": "Cargando lo que pide esta categoría…",
|
|
133
|
+
"listings.compose.details_no_category":
|
|
134
|
+
"Elige primero una categoría: aquí aparecerá lo que pide.",
|
|
133
135
|
"listings.compose.details_failed":
|
|
134
136
|
"No pudimos cargar lo que pide esta categoría",
|
|
135
137
|
"listings.compose.details_empty": "Esta categoría no pide más detalles",
|
|
@@ -159,6 +161,7 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
159
161
|
"listings.compose.blocked.busy":
|
|
160
162
|
"Un momento — el último cambio se está guardando",
|
|
161
163
|
"listings.compose.blocked.incomplete": "Faltan {count} datos obligatorios",
|
|
164
|
+
"listings.compose.show_first_missing": "Ir al primer campo sin completar",
|
|
162
165
|
"listings.compose.blocked.mirror": "Corrige primero los campos marcados",
|
|
163
166
|
"listings.compose.blocked.details_unavailable":
|
|
164
167
|
"No pudimos cargar lo que pide esta categoría, así que no podemos revisar el formulario",
|
|
@@ -218,7 +221,9 @@ export const listingsI18nBundleEs: I18nDictionary = {
|
|
|
218
221
|
|
|
219
222
|
"listings.nav.detail": "Anuncio",
|
|
220
223
|
"listings.nav.compose": "Poner un anuncio",
|
|
224
|
+
"listings.nav.compose.short": "Poner",
|
|
221
225
|
"listings.nav.mine": "Mis anuncios",
|
|
226
|
+
"listings.nav.mine.short": "Míos",
|
|
222
227
|
"listings.nav.favorites": "Favoritos",
|
|
223
228
|
};
|
|
224
229
|
|
package/src/i18n/keys.ts
CHANGED
|
@@ -133,6 +133,10 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
133
133
|
composeTooManyImages: "listings.compose.too_many_images",
|
|
134
134
|
composeDetails: "listings.compose.details",
|
|
135
135
|
composeDetailsLoading: "listings.compose.details_loading",
|
|
136
|
+
/** No category has been chosen yet, so nothing is in flight and nothing will
|
|
137
|
+
* be until one is. The section used to print the LOADING sentence in this
|
|
138
|
+
* state — a spinner-shaped claim about a request that was never made. */
|
|
139
|
+
composeDetailsNoCategory: "listings.compose.details_no_category",
|
|
136
140
|
composeDetailsFailed: "listings.compose.details_failed",
|
|
137
141
|
composeDetailsEmpty: "listings.compose.details_empty",
|
|
138
142
|
composeCountable: "listings.compose.countable",
|
|
@@ -159,6 +163,10 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
159
163
|
* pointed at nothing highlighted, because the mirror only becomes visible
|
|
160
164
|
* once the person has tried to publish. */
|
|
161
165
|
composeBlockedIncomplete: "listings.compose.blocked.incomplete",
|
|
166
|
+
/** The way from the blocked-submit COUNT to the first field it is counting.
|
|
167
|
+
* The count is printed at the foot of a 7000px form whose required fields
|
|
168
|
+
* are two viewports up; a number nobody can act on is not help. */
|
|
169
|
+
composeShowFirstMissing: "listings.compose.show_first_missing",
|
|
162
170
|
composeBlockedDetailsUnavailable: "listings.compose.blocked.details_unavailable",
|
|
163
171
|
|
|
164
172
|
// ── the owner's dashboard ────────────────────────────────────────────────
|
|
@@ -220,7 +228,11 @@ export const LISTINGS_I18N_KEYS = {
|
|
|
220
228
|
// ── nav labels ───────────────────────────────────────────────────────────
|
|
221
229
|
navDetail: "listings.nav.detail",
|
|
222
230
|
navCompose: "listings.nav.compose",
|
|
231
|
+
// The compact wording a phone dock uses — `NavEntry.shortLabelKey`. The
|
|
232
|
+
// verb, not the sentence: a dock cell is ten characters wide.
|
|
233
|
+
navComposeShort: "listings.nav.compose.short",
|
|
223
234
|
navMine: "listings.nav.mine",
|
|
235
|
+
navMineShort: "listings.nav.mine.short",
|
|
224
236
|
navFavorites: "listings.nav.favorites",
|
|
225
237
|
} as const;
|
|
226
238
|
|
|
@@ -323,6 +335,8 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
323
335
|
"A listing can carry at most {max} photos",
|
|
324
336
|
"listings.compose.details": "Details",
|
|
325
337
|
"listings.compose.details_loading": "Loading what this category asks for…",
|
|
338
|
+
"listings.compose.details_no_category":
|
|
339
|
+
"Choose a category first — what it asks for appears here.",
|
|
326
340
|
"listings.compose.details_failed":
|
|
327
341
|
"We could not load what this category asks for",
|
|
328
342
|
"listings.compose.details_empty": "This category asks for no extra details",
|
|
@@ -356,6 +370,7 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
356
370
|
"listings.compose.blocked.mirror":
|
|
357
371
|
"Fix the highlighted fields first",
|
|
358
372
|
"listings.compose.blocked.incomplete": "{count} required details are still empty",
|
|
373
|
+
"listings.compose.show_first_missing": "Take me to the first empty field",
|
|
359
374
|
"listings.compose.blocked.details_unavailable":
|
|
360
375
|
"We could not load what this category asks for, so we cannot check the form",
|
|
361
376
|
|
|
@@ -417,7 +432,9 @@ export const listingsI18nBundleEn: Record<string, string> = {
|
|
|
417
432
|
|
|
418
433
|
"listings.nav.detail": "Listing",
|
|
419
434
|
"listings.nav.compose": "Post a listing",
|
|
435
|
+
"listings.nav.compose.short": "Post",
|
|
420
436
|
"listings.nav.mine": "My listings",
|
|
437
|
+
"listings.nav.mine.short": "Listings",
|
|
421
438
|
"listings.nav.favorites": "Favourites",
|
|
422
439
|
};
|
|
423
440
|
|
package/src/i18n/ru.ts
CHANGED
|
@@ -146,6 +146,8 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
146
146
|
"К объявлению можно приложить не больше {max} фото",
|
|
147
147
|
"listings.compose.details": "Характеристики",
|
|
148
148
|
"listings.compose.details_loading": "Загружаем характеристики категории…",
|
|
149
|
+
"listings.compose.details_no_category":
|
|
150
|
+
"Сначала выберите категорию — здесь появится то, что она спрашивает.",
|
|
149
151
|
"listings.compose.details_failed":
|
|
150
152
|
"Не удалось загрузить характеристики категории",
|
|
151
153
|
"listings.compose.details_empty":
|
|
@@ -176,6 +178,7 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
176
178
|
"listings.compose.blocked.busy":
|
|
177
179
|
"Секунду — предыдущее изменение ещё сохраняется",
|
|
178
180
|
"listings.compose.blocked.incomplete": "Не заполнено обязательных деталей: {count}",
|
|
181
|
+
"listings.compose.show_first_missing": "Перейти к первому незаполненному полю",
|
|
179
182
|
"listings.compose.blocked.mirror": "Сначала поправьте отмеченные поля",
|
|
180
183
|
"listings.compose.blocked.details_unavailable":
|
|
181
184
|
"Не удалось загрузить характеристики категории, поэтому проверить форму нельзя",
|
|
@@ -237,7 +240,9 @@ export const listingsI18nBundleRu: I18nDictionary = {
|
|
|
237
240
|
|
|
238
241
|
"listings.nav.detail": "Объявление",
|
|
239
242
|
"listings.nav.compose": "Подать объявление",
|
|
243
|
+
"listings.nav.compose.short": "Подать",
|
|
240
244
|
"listings.nav.mine": "Мои объявления",
|
|
245
|
+
"listings.nav.mine.short": "Мои",
|
|
241
246
|
"listings.nav.favorites": "Избранное",
|
|
242
247
|
};
|
|
243
248
|
|
package/src/index.ts
CHANGED
|
@@ -152,6 +152,7 @@ export {
|
|
|
152
152
|
featuresFromDaoList,
|
|
153
153
|
unreadableFeatureCount,
|
|
154
154
|
} from "./model/features.js";
|
|
155
|
+
export type { FeatureCopySource } from "./model/features.js";
|
|
155
156
|
|
|
156
157
|
// ── model: validation, the mirror and the publish-400 split ──────────────────
|
|
157
158
|
export {
|
|
@@ -159,8 +160,10 @@ export {
|
|
|
159
160
|
DEFAULT_DRAFT_LIMITS,
|
|
160
161
|
DESCRIPTION_FIELD,
|
|
161
162
|
IMAGES_FIELD,
|
|
163
|
+
LOCATION_FIELD,
|
|
162
164
|
PRICE_FIELD,
|
|
163
165
|
TITLE_FIELD,
|
|
166
|
+
envelopeFieldErrors,
|
|
164
167
|
failedResults,
|
|
165
168
|
isBatchValid,
|
|
166
169
|
listingFieldErrors,
|