@stapel/listings-react 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/dist/api/generated/schema.d.ts +125 -2
  3. package/dist/api/generated/schema.d.ts.map +1 -1
  4. package/dist/api/listingsApi.d.ts +20 -1
  5. package/dist/api/listingsApi.d.ts.map +1 -1
  6. package/dist/api/listingsApi.js +1 -0
  7. package/dist/api/listingsApi.js.map +1 -1
  8. package/dist/api/types.d.ts +32 -2
  9. package/dist/api/types.d.ts.map +1 -1
  10. package/dist/api/types.js +7 -0
  11. package/dist/api/types.js.map +1 -1
  12. package/dist/default/ListingCard.d.ts +11 -0
  13. package/dist/default/ListingCard.d.ts.map +1 -1
  14. package/dist/default/ListingCard.js +86 -7
  15. package/dist/default/ListingCard.js.map +1 -1
  16. package/dist/default/ListingPhoto.d.ts +40 -0
  17. package/dist/default/ListingPhoto.d.ts.map +1 -1
  18. package/dist/default/ListingPhoto.js +53 -6
  19. package/dist/default/ListingPhoto.js.map +1 -1
  20. package/dist/default/ListingSerpCard.d.ts.map +1 -1
  21. package/dist/default/ListingSerpCard.js +14 -4
  22. package/dist/default/ListingSerpCard.js.map +1 -1
  23. package/dist/default/MyListingsPane.d.ts +19 -1
  24. package/dist/default/MyListingsPane.d.ts.map +1 -1
  25. package/dist/default/MyListingsPane.js +99 -12
  26. package/dist/default/MyListingsPane.js.map +1 -1
  27. package/dist/headless/ListingActions.d.ts +64 -13
  28. package/dist/headless/ListingActions.d.ts.map +1 -1
  29. package/dist/headless/ListingActions.js +60 -5
  30. package/dist/headless/ListingActions.js.map +1 -1
  31. package/dist/i18n/es.d.ts.map +1 -1
  32. package/dist/i18n/es.js +8 -2
  33. package/dist/i18n/es.js.map +1 -1
  34. package/dist/i18n/keys.d.ts +20 -0
  35. package/dist/i18n/keys.d.ts.map +1 -1
  36. package/dist/i18n/keys.js +52 -1
  37. package/dist/i18n/keys.js.map +1 -1
  38. package/dist/i18n/ru.d.ts.map +1 -1
  39. package/dist/i18n/ru.js +12 -2
  40. package/dist/i18n/ru.js.map +1 -1
  41. package/dist/index.d.ts +5 -5
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -2
  44. package/dist/index.js.map +1 -1
  45. package/dist/model/mine.d.ts +20 -0
  46. package/dist/model/mine.d.ts.map +1 -1
  47. package/dist/model/mine.js +22 -0
  48. package/dist/model/mine.js.map +1 -1
  49. package/dist/model/mutations.d.ts +24 -1
  50. package/dist/model/mutations.d.ts.map +1 -1
  51. package/dist/model/mutations.js +28 -0
  52. package/dist/model/mutations.js.map +1 -1
  53. package/dist/model/status.d.ts.map +1 -1
  54. package/dist/model/status.js +26 -9
  55. package/dist/model/status.js.map +1 -1
  56. package/dist/model/transitions.d.ts +38 -1
  57. package/dist/model/transitions.d.ts.map +1 -1
  58. package/dist/model/transitions.js +73 -1
  59. package/dist/model/transitions.js.map +1 -1
  60. package/llms.txt +4 -2
  61. package/manifest.json +26 -2
  62. package/nav-manifest.json +1 -1
  63. package/package.json +5 -5
  64. package/src/analytics/generated/events.json +1 -1
  65. package/src/api/generated/schema.ts +125 -2
  66. package/src/api/listingsApi.ts +27 -0
  67. package/src/api/types.ts +47 -2
  68. package/src/default/ListingCard.tsx +115 -12
  69. package/src/default/ListingPhoto.tsx +102 -18
  70. package/src/default/ListingSerpCard.tsx +17 -4
  71. package/src/default/MyListingsPane.tsx +205 -39
  72. package/src/headless/ListingActions.tsx +137 -16
  73. package/src/i18n/es.ts +8 -2
  74. package/src/i18n/keys.ts +52 -1
  75. package/src/i18n/ru.ts +12 -2
  76. package/src/index.ts +19 -3
  77. package/src/model/mine.ts +23 -0
  78. package/src/model/mutations.ts +41 -0
  79. package/src/model/status.ts +27 -9
  80. package/src/model/transitions.ts +80 -1
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
- # @stapel/listings-react 0.20.0
1
+ # @stapel/listings-react 0.22.0
2
2
 
3
- Headless React flow pair for stapel-listings (contract >=0.17 <0.18) — business + state, zero visual opinion.
3
+ Headless React flow pair for stapel-listings (contract >=0.21 <0.22) — business + state, zero visual opinion.
4
4
  Built on @stapel/core: typed client + StapelApiError envelope, auth token refresh,
5
5
  verification-403 interception, i18n engine, analytics facade, TanStack Query layer.
6
6
 
@@ -36,6 +36,7 @@ Paths are relative to `/listings/api/v1/`.
36
36
  - listings_api_v1_listings_retrieve: GET /listings/{id}/
37
37
  - listings_api_v1_listings_save_draft_create: POST /listings/{id}/save-draft/
38
38
  - listings_api_v1_listings_status_retrieve: GET /listings/{id}/status/
39
+ - listings_api_v1_listings_transition_create: POST /listings/{id}/transition/
39
40
  - listings_api_v1_listings_unfavorite_create: POST /listings/{id}/unfavorite/
40
41
  - listings_api_v1_listings_update: PUT /listings/{id}/
41
42
  - listings_api_v1_listings_validate_draft_retrieve: GET /listings/{id}/validate-draft/
@@ -54,6 +55,7 @@ Paths are relative to `/listings/api/v1/`.
54
55
  - useMyFavorites (query) → myFavorites
55
56
  - usePublishListing (mutation) → publish
56
57
  - useSaveDraft (mutation) → saveDraft
58
+ - useTransitionListing (mutation) → transition
57
59
  - useValidateDraft (query) → validateDraft
58
60
 
59
61
  ## Errors (render t(code, params); UX from remediation)
package/manifest.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-manifest.mjs — do not edit; drift-gated (pnpm gen:manifest:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.20.0",
4
+ "version": "0.22.0",
5
5
  "backend": {
6
6
  "module": "stapel-listings",
7
- "contract": ">=0.17 <0.18"
7
+ "contract": ">=0.21 <0.22"
8
8
  },
9
9
  "layers": [
10
10
  "api",
@@ -121,6 +121,13 @@
121
121
  "request": null,
122
122
  "response": "ListingStatus"
123
123
  },
124
+ "listings_api_v1_listings_transition_create": {
125
+ "method": "POST",
126
+ "path": "/listings/api/v1/listings/{id}/transition/",
127
+ "tag": "listings",
128
+ "request": "ListingTransitionRequest",
129
+ "response": "ListingActionResponse"
130
+ },
124
131
  "listings_api_v1_listings_unfavorite_create": {
125
132
  "method": "POST",
126
133
  "path": "/listings/api/v1/listings/{id}/unfavorite/",
@@ -263,6 +270,10 @@
263
270
  "kind": "mutation",
264
271
  "operation": "saveDraft"
265
272
  },
273
+ "useTransitionListing": {
274
+ "kind": "mutation",
275
+ "operation": "transition"
276
+ },
266
277
  "useValidateDraft": {
267
278
  "kind": "query",
268
279
  "operation": "validateDraft",
@@ -1058,11 +1069,17 @@
1058
1069
  "listings.mine.live_under_review",
1059
1070
  "listings.mine.load_failed",
1060
1071
  "listings.mine.loading",
1072
+ "listings.mine.move.draft",
1073
+ "listings.mine.move.paused",
1074
+ "listings.mine.move.pending",
1075
+ "listings.mine.move.published",
1076
+ "listings.mine.move.renew",
1061
1077
  "listings.mine.retry",
1062
1078
  "listings.mine.tab.active",
1063
1079
  "listings.mine.tab.archived",
1064
1080
  "listings.mine.tab.drafts",
1065
1081
  "listings.mine.title",
1082
+ "listings.mine.view",
1066
1083
  "listings.moderation.first_review",
1067
1084
  "listings.moderation.live_edit_pending",
1068
1085
  "listings.moderation.live_needs_review",
@@ -1123,6 +1140,7 @@
1123
1140
  "MY_LISTINGS_TAB_STATUSES",
1124
1141
  "MY_LISTINGS_UNTABBED_STATUSES",
1125
1142
  "MyListings",
1143
+ "OWNER_TRANSITIONS",
1126
1144
  "PRICE_FIELD",
1127
1145
  "TITLE_FIELD",
1128
1146
  "asFeatureDaoList",
@@ -1164,6 +1182,7 @@
1164
1182
  "myListingPrice",
1165
1183
  "myListingTitle",
1166
1184
  "navEntries",
1185
+ "ownerMoves",
1167
1186
  "pageKey",
1168
1187
  "publishRefusal",
1169
1188
  "registerListingsI18n",
@@ -1199,6 +1218,7 @@
1199
1218
  "useMyListings",
1200
1219
  "usePublishListing",
1201
1220
  "useSaveDraft",
1221
+ "useTransitionListing",
1202
1222
  "useValidateDraft",
1203
1223
  "withEngagement"
1204
1224
  ],
@@ -1241,11 +1261,14 @@
1241
1261
  "ListingLifecycleStatus",
1242
1262
  "ListingLocation",
1243
1263
  "ListingModerationStatus",
1264
+ "ListingMove",
1265
+ "ListingOwnerTransition",
1244
1266
  "ListingPageKey",
1245
1267
  "ListingPageParams",
1246
1268
  "ListingStatusInfo",
1247
1269
  "ListingStatusTone",
1248
1270
  "ListingStatusView",
1271
+ "ListingTransitionInput",
1249
1272
  "ListingsApi",
1250
1273
  "ListingsErrorCode",
1251
1274
  "ListingsErrorSpec",
@@ -1269,6 +1292,7 @@
1269
1292
  "SaveDraftInput",
1270
1293
  "Schemas",
1271
1294
  "UseFavoritesOptions",
1295
+ "UseListingActionsOptions",
1272
1296
  "UseListingComposerOptions",
1273
1297
  "UseListingDetailOptions",
1274
1298
  "UseListingEngagementOverlayOptions",
package/nav-manifest.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@stapel/listings-react",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "entries": [
5
5
  {
6
6
  "id": "listings.detail",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stapel/listings-react",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Headless React pair for stapel-listings: a typed client over the draft twin, the composer that turns a category schema into a submittable listing, and the owner's dashboard. Two independent axes are rendered as two — lifecycle `status` decides visibility and `moderation_status` decides nothing about it, so an edit to a live listing stays live and says 'under review' instead of vanishing. Feature values are drawn and mirrored by @stapel/attributes-react; photos arrive as an injected upload bag whose `refs` ARE `images_draft` and whose `settled` gates the submit; a publish refusal is routed onto the control that caused it by slug. Zero visual opinion in the main entry; an opt-in /default subpath ships the antd skin.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -53,9 +53,9 @@
53
53
  "limit": "14 KB"
54
54
  },
55
55
  {
56
- "name": "default — the antd skin (three cards, detail, composer, dashboard, the named slots and the delete confirmation) must stay out of the main bundle. Raised 17 -> 19 KB for the mobile wave: <ListingSerpCard> (photo strip + price-first block + action rail), <ListingFeedCard> and <FeedGrid> are two more card surfaces on the same shared target/favourite/photo parts, not three copies of one Raised 19 -> 20 KB for the desktop wave of the detail page: layout=\"split\" is a second ASSEMBLY of the money page (the sticky buy column, the two-column spec table, the widened measure) over the same parts, and GateReasonPopover is the blocked heart's third volume — the reason and the door disclosed on interaction, with the visually-hidden copy that keeps the refusal in the accessibility tree. Both are geometry and wiring, not new widgets. Raised 20 -> 21 KB (measured 20.22) for the engagement wave, which is four small things and no new component: the disclosure became a controlled open/close so a pointer that hovers and then clicks does not shut the only explanation the control has; the heart draws a filled accent when saved and states a failed save through the shared ErrorAlert; an already-seen card dims through one rule in the sheet all three cards already hoist; and the listing page gained a view-count row. 216 bytes is what a control that reports its own outcome costs",
56
+ "name": "default — the antd skin (three cards, detail, composer, dashboard, the named slots and the delete confirmation) must stay out of the main bundle. Raised 17 -> 19 KB for the mobile wave: <ListingSerpCard> (photo strip + price-first block + action rail), <ListingFeedCard> and <FeedGrid> are two more card surfaces on the same shared target/favourite/photo parts, not three copies of one Raised 19 -> 20 KB for the desktop wave of the detail page: layout=\"split\" is a second ASSEMBLY of the money page (the sticky buy column, the two-column spec table, the widened measure) over the same parts, and GateReasonPopover is the blocked heart's third volume — the reason and the door disclosed on interaction, with the visually-hidden copy that keeps the refusal in the accessibility tree. Both are geometry and wiring, not new widgets. Raised 20 -> 21 KB (measured 20.22) for the engagement wave, which is four small things and no new component: the disclosure became a controlled open/close so a pointer that hovers and then clicks does not shut the only explanation the control has; the heart draws a filled accent when saved and states a failed save through the shared ErrorAlert; an already-seen card dims through one rule in the sheet all three cards already hoist; and the listing page gained a view-count row. 216 bytes is what a control that reports its own outcome costs. Raised 21 -> 22 KB for the seller's board becoming a door in both directions: the transition route and useTransitionListing, OWNER_TRANSITIONS/ownerMoves as the fallback mirror, ListingActionsBag.moves drawn in a stable order, listingHref/linkComponent on the cabinet pane, the refusal that stops quoting the wire, and the row that fits a phone. Measured on a clean tree, this package's src reverted to the previous commit with dependencies held constant: 20.82 -> 21.60 KB. The old ceiling had 180 B of room and the work needs 780; none of it is copy, and a fourth axis of owner state is not a rounding error",
57
57
  "path": "dist/default/index.js",
58
- "limit": "21 KB"
58
+ "limit": "22 KB"
59
59
  },
60
60
  {
61
61
  "name": "i18n/ru — the Russian bundle is opt-in, not carried by the main entry",
@@ -103,12 +103,12 @@
103
103
  "typescript": "^5.8.3",
104
104
  "vitest": "^3.2.4",
105
105
  "@stapel/attributes-react": "^0.14.1",
106
- "@stapel/core": "^0.23.1",
106
+ "@stapel/core": "^0.24.0",
107
107
  "@stapel/currencies-react": "^0.3.0",
108
108
  "@stapel/image": "^0.4.2",
109
109
  "@stapel/showcase": "^0.3.0",
110
110
  "@stapel/tokens": "^0.7.0",
111
- "@stapel/tokens-antd": "^0.16.0"
111
+ "@stapel/tokens-antd": "^0.16.1"
112
112
  },
113
113
  "engines": {
114
114
  "node": ">=22"
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$generated": "by scripts/gen-events.mjs — do not edit; drift-gated (pnpm gen:events:check)",
3
3
  "package": "@stapel/listings-react",
4
- "version": "0.20.0",
4
+ "version": "0.22.0",
5
5
  "defined": [],
6
6
  "flows": []
7
7
  }
@@ -260,6 +260,40 @@ export interface paths {
260
260
  patch?: never;
261
261
  trace?: never;
262
262
  };
263
+ "/listings/api/v1/listings/{id}/transition/": {
264
+ parameters: {
265
+ query?: never;
266
+ header?: never;
267
+ path?: never;
268
+ cookie?: never;
269
+ };
270
+ get?: never;
271
+ put?: never;
272
+ /**
273
+ * @description Move the listing to ``to``, if that is a move its OWNER may make.
274
+ *
275
+ * The seller's way forward, as one route instead of one endpoint per
276
+ * edge. ``archive`` and ``complete`` were the only two the API ever
277
+ * offered, and both of them are exits — so a listing that reached
278
+ * ARCHIVED, PAUSED, EXPIRED, SOLD, REJECTED or BLOCKED had no call left
279
+ * that would move it, and the cabinet correctly showed its owner
280
+ * nothing but «удалить».
281
+ *
282
+ * Which moves exist is ``models.OWNER_TRANSITIONS``, and it is the same
283
+ * list ``available_transitions`` puts on the card — the point of routing
284
+ * every edge through one allowlist is that the set a client is offered
285
+ * and the set the server accepts are one object, not two that agree
286
+ * today.
287
+ *
288
+ * **Permissions:** `IsAuthenticated`
289
+ */
290
+ post: operations["listings_api_v1_listings_transition_create"];
291
+ delete?: never;
292
+ options?: never;
293
+ head?: never;
294
+ patch?: never;
295
+ trace?: never;
296
+ };
263
297
  "/listings/api/v1/listings/{id}/unfavorite/": {
264
298
  parameters: {
265
299
  query?: never;
@@ -437,6 +471,8 @@ export interface paths {
437
471
  export type webhooks = Record<string, never>;
438
472
  export interface components {
439
473
  schemas: {
474
+ /** @enum {string} */
475
+ AvailableTransitionsEnum: "draft" | "pending" | "published" | "paused" | "expired" | "sold" | "rejected" | "blocked" | "archived";
440
476
  /** @description Serializer for boolean feature DAO. */
441
477
  BoolDao: {
442
478
  name?: string | null;
@@ -815,6 +851,11 @@ export interface components {
815
851
  lat?: string | null;
816
852
  /** Format: decimal */
817
853
  lon?: string | null;
854
+ /**
855
+ * Format: double
856
+ * @description How wide the area `lat`/`lon` describe, in kilometres. On a PUBLIC read this is ~1.113 (the pair is rounded to two decimals and `geohash` comes back empty): draw a CIRCLE, never a marker — the listing is somewhere in it, and for a private seller the true point is a home address. `0` means the exact point, which only the listing's own owner, staff and the service transport get. Proximity itself is unaffected: `distance_km` on a search hit is computed server-side from the true coordinates.
857
+ */
858
+ readonly geo_precision_km: number;
818
859
  countable?: boolean;
819
860
  /** Format: int64 */
820
861
  stock_quantity?: number | null;
@@ -845,6 +886,11 @@ export interface components {
845
886
  lat?: string | null;
846
887
  /** Format: decimal */
847
888
  lon?: string | null;
889
+ /**
890
+ * Format: double
891
+ * @description How wide the area `lat`/`lon` describe, in kilometres. On a PUBLIC read this is ~1.113 (the pair is rounded to two decimals and `geohash` comes back empty): draw a CIRCLE, never a marker — the listing is somewhere in it, and for a private seller the true point is a home address. `0` means the exact point, which only the listing's own owner, staff and the service transport get. Proximity itself is unaffected: `distance_km` on a search hit is computed server-side from the true coordinates.
892
+ */
893
+ readonly geo_precision_km: number;
848
894
  readonly features: (components["schemas"]["FeatureDao"] | {
849
895
  slug?: string;
850
896
  type?: string;
@@ -981,13 +1027,29 @@ export interface components {
981
1027
  owner_id: string;
982
1028
  };
983
1029
  /**
984
- * @description * `pending` - Pending Review
1030
+ * @description The body of ``POST listings/{id}/transition/``: where to move it.
1031
+ *
1032
+ * A ``ChoiceField`` over the whole lifecycle rather than over the moves this
1033
+ * particular listing has, because the two refusals are different sentences
1034
+ * and a client should be able to tell them apart: a status that does not
1035
+ * exist is a 400 (the caller is confused about the vocabulary), a status
1036
+ * that exists but is not this listing's to reach is a 409 with
1037
+ * ``from_status`` (the caller is confused about the row). Narrowing the
1038
+ * field to the per-row set would collapse both into 400 and lose the
1039
+ * ``from_status`` that tells a storefront what to re-render.
1040
+ */
1041
+ ListingTransitionRequest: {
1042
+ to: components["schemas"]["ToEnum"];
1043
+ };
1044
+ /**
1045
+ * @description * `not_submitted` - Not submitted for review
1046
+ * * `pending` - Pending Review
985
1047
  * * `approved` - Approved
986
1048
  * * `rejected` - Rejected
987
1049
  * * `needs_review` - Needs Manual Review
988
1050
  * @enum {string}
989
1051
  */
990
- ModerationStatusEnum: "pending" | "approved" | "rejected" | "needs_review";
1052
+ ModerationStatusEnum: "not_submitted" | "pending" | "approved" | "rejected" | "needs_review";
991
1053
  /** @description Listing counts by tab for the current user. */
992
1054
  MyCountersResponse: {
993
1055
  active: number;
@@ -1059,6 +1121,11 @@ export interface components {
1059
1121
  lat?: string | null;
1060
1122
  /** Format: decimal */
1061
1123
  lon?: string | null;
1124
+ /**
1125
+ * Format: double
1126
+ * @description How wide the area `lat`/`lon` describe, in kilometres. On a PUBLIC read this is ~1.113 (the pair is rounded to two decimals and `geohash` comes back empty): draw a CIRCLE, never a marker — the listing is somewhere in it, and for a private seller the true point is a home address. `0` means the exact point, which only the listing's own owner, staff and the service transport get. Proximity itself is unaffected: `distance_km` on a search hit is computed server-side from the true coordinates.
1127
+ */
1128
+ readonly geo_precision_km: number;
1062
1129
  countable?: boolean;
1063
1130
  /** Format: int64 */
1064
1131
  stock_quantity?: number | null;
@@ -1067,6 +1134,7 @@ export interface components {
1067
1134
  readonly viewed: boolean | null;
1068
1135
  readonly view_count: number;
1069
1136
  moderation_status?: components["schemas"]["ModerationStatusEnum"];
1137
+ readonly available_transitions: components["schemas"]["AvailableTransitionsEnum"][];
1070
1138
  title_draft?: string;
1071
1139
  /** Format: decimal */
1072
1140
  price_draft?: string | null;
@@ -1286,6 +1354,19 @@ export interface components {
1286
1354
  * @enum {string}
1287
1355
  */
1288
1356
  StyleEnum: "l" | "m";
1357
+ /**
1358
+ * @description * `draft` - Draft
1359
+ * * `pending` - Pending Moderation
1360
+ * * `published` - Published
1361
+ * * `paused` - Paused
1362
+ * * `expired` - Expired
1363
+ * * `sold` - Sold
1364
+ * * `rejected` - Rejected
1365
+ * * `blocked` - Blocked (moderation takedown)
1366
+ * * `archived` - Archived
1367
+ * @enum {string}
1368
+ */
1369
+ ToEnum: "draft" | "pending" | "published" | "paused" | "expired" | "sold" | "rejected" | "blocked" | "archived";
1289
1370
  /**
1290
1371
  * @description * `convertible_unit` - convertible_unit
1291
1372
  * @enum {string}
@@ -1663,6 +1744,48 @@ export interface operations {
1663
1744
  };
1664
1745
  };
1665
1746
  };
1747
+ listings_api_v1_listings_transition_create: {
1748
+ parameters: {
1749
+ query?: never;
1750
+ header?: never;
1751
+ path: {
1752
+ /** @description A unique integer value identifying this listing. */
1753
+ id: number;
1754
+ };
1755
+ cookie?: never;
1756
+ };
1757
+ requestBody: {
1758
+ content: {
1759
+ "application/json": components["schemas"]["ListingTransitionRequest"];
1760
+ "application/x-www-form-urlencoded": components["schemas"]["ListingTransitionRequest"];
1761
+ "multipart/form-data": components["schemas"]["ListingTransitionRequest"];
1762
+ };
1763
+ };
1764
+ responses: {
1765
+ 200: {
1766
+ headers: {
1767
+ [name: string]: unknown;
1768
+ };
1769
+ content: {
1770
+ "application/json": components["schemas"]["ListingActionResponse"];
1771
+ };
1772
+ };
1773
+ /** @description No response body */
1774
+ 400: {
1775
+ headers: {
1776
+ [name: string]: unknown;
1777
+ };
1778
+ content?: never;
1779
+ };
1780
+ /** @description No response body */
1781
+ 409: {
1782
+ headers: {
1783
+ [name: string]: unknown;
1784
+ };
1785
+ content?: never;
1786
+ };
1787
+ };
1788
+ };
1666
1789
  listings_api_v1_listings_unfavorite_create: {
1667
1790
  parameters: {
1668
1791
  query?: never;
@@ -7,6 +7,7 @@ import type {
7
7
  ListingEngagementBatch,
8
8
  ListingDraft,
9
9
  ListingDraftPatch,
10
+ ListingOwnerTransition,
10
11
  ListingPageParams,
11
12
  ListingStatusInfo,
12
13
  MyCounters,
@@ -204,6 +205,29 @@ export interface ListingsApi {
204
205
  /** Mark SOLD. Same 409 contract as {@link archive}. */
205
206
  complete(id: number): Promise<ListingActionResponse>;
206
207
 
208
+ /**
209
+ * Move the listing to `to` — ONE route for every edge a seller owns
210
+ * (`POST listings/{id}/transition/`, stapel-listings 0.20.0).
211
+ *
212
+ * {@link archive} and {@link complete} are still here and still work; they
213
+ * are two named exits, and for two releases they were the ONLY two the
214
+ * owner API had. That is what made a cabinet a one-way door: every status a
215
+ * seller could put a listing INTO — SOLD, ARCHIVED, PAUSED, EXPIRED — was
216
+ * one no call could get it out of, and `DELETE` was the only answer left.
217
+ *
218
+ * The set this accepts is `models.OWNER_TRANSITIONS`, which is the same set
219
+ * `MyListingCard.available_transitions` reports, deliberately: the moves a
220
+ * client is offered and the moves the server takes are one object rather
221
+ * than two that agree today. Two refusals, and they are different
222
+ * sentences — a status that does not exist is a 400, a status that exists
223
+ * but is not this row's to reach is `error.409.invalid_listing_transition`
224
+ * with `params.from_status`.
225
+ */
226
+ transition(
227
+ id: number,
228
+ to: ListingOwnerTransition
229
+ ): Promise<ListingActionResponse>;
230
+
207
231
  /** Favourite a listing. Idempotent server-side (`get_or_create`). */
208
232
  favorite(id: number): Promise<FavoriteToggleResponse>;
209
233
 
@@ -306,6 +330,9 @@ export function createListingsApi(client: StapelClient): ListingsApi {
306
330
 
307
331
  complete: (id) => client.post(`${listingPath(id)}complete/`),
308
332
 
333
+ transition: (id, to) =>
334
+ client.post(`${listingPath(id)}transition/`, { to }),
335
+
309
336
  favorite: (id) => client.post(`${listingPath(id)}favorite/`),
310
337
 
311
338
  unfavorite: (id) => client.post(`${listingPath(id)}unfavorite/`),
package/src/api/types.ts CHANGED
@@ -140,8 +140,27 @@ export type ListingDetail = WithOptionalEngagement<Schemas["ListingDetail"]>;
140
140
  * type, for data no one can supply; it is precisely the case
141
141
  * `<ListingEngagementScope>` exists to answer. A deployment running a server
142
142
  * older than 0.16 is the same shape of fact.
143
+ *
144
+ * `geo_precision_km` is relaxed for the same reason and by the same argument.
145
+ * stapel-listings 0.21.0 puts it on every card this module serves — how wide
146
+ * an area `lat`/`lon` describe, so a reader draws a circle instead of pinning
147
+ * a private seller's front door with a marker — and the SEARCH document does
148
+ * not carry it, because stapel-search coarsens its own card independently
149
+ * (`CARD_COORD_PRECISION`, the same ~1.1km). Requiring it on the prop would
150
+ * make the pair's primary consumer unable to satisfy its own type for a
151
+ * number nobody in that path holds. A reader that needs the precision must
152
+ * treat its absence as UNSTATED and never as `0`: `0` means the exact point,
153
+ * which is the one reading that could publish an address.
143
154
  */
144
- export type ListingCard = WithOptionalEngagement<Schemas["ListingCard"]>;
155
+ type WithOptionalPrecision<Row extends { geo_precision_km?: number }> = Omit<
156
+ Row,
157
+ "geo_precision_km"
158
+ > &
159
+ Partial<Pick<Schemas["ListingCard"], "geo_precision_km">>;
160
+
161
+ export type ListingCard = WithOptionalPrecision<
162
+ WithOptionalEngagement<Schemas["ListingCard"]>
163
+ >;
145
164
 
146
165
  /** `POST /listings/` request+response and `POST /{pk}/save-draft/` response —
147
166
  * the draft twin. Every user-editable field is a `*_draft` one, promoted onto
@@ -197,7 +216,9 @@ export type PaginatedListingCards = Omit<
197
216
  * tab keyed off them is a column of blank rows. `myListingTitle` /
198
217
  * `myListingPrice` (`model/mine.ts`) are the one place the fallback lives.
199
218
  */
200
- export type MyListingCard = WithOptionalEngagement<Schemas["MyListingCard"]>;
219
+ export type MyListingCard = WithOptionalPrecision<
220
+ WithOptionalEngagement<Schemas["MyListingCard"]>
221
+ >;
201
222
 
202
223
  /** The keyset envelope `GET /listings/my/listings/` comes back in — the same
203
224
  * `IDAnchorPagination` shape as {@link PaginatedListingCards}, over the owner
@@ -232,9 +253,33 @@ export const LISTING_STATUSES: readonly ListingLifecycleStatus[] = [
232
253
  * only one (`models.py`: "no visibility-reads-moderation_status coupling"). */
233
254
  export type ListingLifecycleStatus = Schemas["StatusD41Enum"];
234
255
 
256
+ /**
257
+ * One move the OWNER of a listing may make from where it is now — the third
258
+ * axis, and the one stapel-listings 0.20.0 added because the first two cannot
259
+ * answer it.
260
+ *
261
+ * `status` says where the listing IS and `moderation_status` says what is
262
+ * being waited on. Neither says *what can I do about it*, and a dashboard
263
+ * that works it out for itself is re-implementing `models.OWNER_TRANSITIONS`
264
+ * from the outside — which is how a cabinet ends up drawing "Mark sold" on a
265
+ * listing that is already sold, and drawing no way back at all.
266
+ *
267
+ * The values are lifecycle states (the DESTINATION of a move), so this is
268
+ * `ListingLifecycleStatus` narrowed by the server rather than a second
269
+ * vocabulary.
270
+ */
271
+ export type ListingOwnerTransition = Schemas["AvailableTransitionsEnum"];
272
+
235
273
  /** The four content-moderation states, as `models.ModerationStatus` declares
236
274
  * them. Independent of the lifecycle: see `model/status.ts`. */
237
275
  export const MODERATION_STATUSES: readonly ListingModerationStatus[] = [
276
+ // NOBODY HAS ASKED YET, and it is the DEFAULT (stapel-listings 0.20.0).
277
+ // Before it, a draft was born `pending` and every dashboard row announced a
278
+ // moderation decision that no case existed behind — one live stand held 167
279
+ // of them. The distinction lives in the data because every reader asks the
280
+ // same question and would each otherwise re-derive "…unless it was never
281
+ // submitted" from a second column.
282
+ "not_submitted",
238
283
  "pending",
239
284
  "approved",
240
285
  "rejected",