robodev 0.25.0 → 0.26.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "CLI for Robodev Starbase — create, auth, link, and deploy hosted apps",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.6.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.25.0",
14
+ "robodev": "^0.26.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }
@@ -10,7 +10,7 @@
10
10
  "@robodev-ai/sdk": "^0.6.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.25.0",
13
+ "robodev": "^0.26.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -10,7 +10,7 @@
10
10
  "@robodev-ai/sdk": "^0.6.0"
11
11
  },
12
12
  "devDependencies": {
13
- "robodev": "^0.25.0",
13
+ "robodev": "^0.26.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -1,8 +1,8 @@
1
1
  # {{NAME}}
2
2
 
3
- Starter from `robodev create --starter marketplace`: Tiny C2C marketplace schema (`categories`, `listings`, `cart_items`, `orders`, `watches`, `reviews`, `media`), file-based APIs in `api/`, and a Povio UI in `apps/fe`.
3
+ Starter from `robodev create --starter marketplace`: Marketplace C2C marketplace schema (`categories`, `listings`, `cart_items`, `orders`, `watches`, `reviews`, `media`), file-based APIs in `api/`, and a Povio UI in `apps/fe`.
4
4
 
5
- After a CLI create or deploy with `.robodev` `frontend` set (the default for this starter), the project host serves the built Tiny SPA at `GET /`. APIs and Swagger still win over static files.
5
+ After a CLI create or deploy with `.robodev` `frontend` set (the default for this starter), the project host serves the built Marketplace SPA at `GET /`. APIs and Swagger still win over static files.
6
6
 
7
7
  Local Starbase occupies FE **:3000** and BE **:4000**. Point the CLI at it, then run starter Vite on **3001** so it does not steal the dashboard port:
8
8
 
@@ -27,7 +27,7 @@ npx robodev deploy
27
27
 
28
28
  `robodev create` / `robodev link` write `VITE_API_URL`, `VITE_PUBLIC_API_URL`, and `APP_PUBLIC_API_URL` in `.env`, rewrite `.config/local.spa.yml` so the FE talks to the project host, and (when `apps/fe/package.json` exists) add a `frontend` block to `.robodev`. `robodev deploy` then runs the local Vite build and uploads the static output.
29
29
 
30
- Dashboard create deploys schema + APIs only. The Tiny FE tree is copied by `robodev create`; a later CLI deploy hosts the SPA.
30
+ Dashboard create deploys schema + APIs only. The Marketplace FE tree is copied by `robodev create`; a later CLI deploy hosts the SPA.
31
31
 
32
32
  Auth stays reserved at `/api/user/*`. Do not add `api/user/**`.
33
33
 
@@ -54,8 +54,8 @@
54
54
  "auth": {
55
55
  "wrapper": {
56
56
  "title": "Administration portal",
57
- "description": "Tiny",
58
- "watermark": "© {{year}} Tiny • Developed and maintained by Povio"
57
+ "description": "Marketplace",
58
+ "watermark": "© {{year}} Marketplace • Developed and maintained by Povio"
59
59
  },
60
60
  "shared": {
61
61
  "errors": {
@@ -143,7 +143,7 @@
143
143
  },
144
144
  "layout": {
145
145
  "header": {
146
- "logo": "Tiny",
146
+ "logo": "Marketplace",
147
147
  "openMenu": "Open menu",
148
148
  "closeMenu": "Close menu",
149
149
  "nav": {
@@ -151,7 +151,8 @@
151
151
  "sell": "Sell",
152
152
  "watchlist": "Watchlist",
153
153
  "orders": "Orders",
154
- "cart": "Cart"
154
+ "cart": "Cart",
155
+ "cartCount": "Cart ({{count}})"
155
156
  },
156
157
  "profile": "Profile",
157
158
  "logout": "Logout"
@@ -166,7 +167,8 @@
166
167
  "search": "Search",
167
168
  "searchPlaceholder": "Search listings",
168
169
  "allCategories": "All categories",
169
- "empty": "No listings match your search."
170
+ "empty": "No listings match your search.",
171
+ "clearFilters": "Clear filters"
170
172
  },
171
173
  "listing": {
172
174
  "notFound": "Listing not found.",
@@ -190,6 +192,8 @@
190
192
  "images": "Images",
191
193
  "description": "Description",
192
194
  "price": "Price",
195
+ "aboutThisItem": "About this item",
196
+ "noDescription": "No description.",
193
197
  "reviews": "Reviews",
194
198
  "noReviews": "No reviews yet."
195
199
  },
@@ -202,6 +206,7 @@
202
206
  "create": "New listing",
203
207
  "empty": "You have no listings yet.",
204
208
  "soldOut": "Sold out",
209
+ "active": "Active",
205
210
  "inactive": "Inactive",
206
211
  "edit": "Edit",
207
212
  "deactivate": "Deactivate",
@@ -54,8 +54,8 @@
54
54
  "auth": {
55
55
  "wrapper": {
56
56
  "title": "Administrativni portal",
57
- "description": "Tiny",
58
- "watermark": "© {{year}} Tiny • Razvija in vzdržuje Povio"
57
+ "description": "Marketplace",
58
+ "watermark": "© {{year}} Marketplace • Razvija in vzdržuje Povio"
59
59
  },
60
60
  "shared": {
61
61
  "errors": {
@@ -143,7 +143,7 @@
143
143
  },
144
144
  "layout": {
145
145
  "header": {
146
- "logo": "Tiny",
146
+ "logo": "Marketplace",
147
147
  "openMenu": "Odpri meni",
148
148
  "closeMenu": "Zapri meni",
149
149
  "nav": {
@@ -151,7 +151,8 @@
151
151
  "sell": "Prodaj",
152
152
  "watchlist": "Seznam spremljanja",
153
153
  "orders": "Naročila",
154
- "cart": "Košarica"
154
+ "cart": "Košarica",
155
+ "cartCount": "Košarica ({{count}})"
155
156
  },
156
157
  "profile": "Profil",
157
158
  "logout": "Odjava"
@@ -166,7 +167,8 @@
166
167
  "search": "Iskanje",
167
168
  "searchPlaceholder": "Išči oglase",
168
169
  "allCategories": "Vse kategorije",
169
- "empty": "Noben oglas ne ustreza iskanju."
170
+ "empty": "Noben oglas ne ustreza iskanju.",
171
+ "clearFilters": "Počisti filtre"
170
172
  },
171
173
  "listing": {
172
174
  "notFound": "Oglas ni bil najden.",
@@ -190,6 +192,8 @@
190
192
  "images": "Slike",
191
193
  "description": "Opis",
192
194
  "price": "Cena",
195
+ "aboutThisItem": "O izdelku",
196
+ "noDescription": "Ni opisa.",
193
197
  "reviews": "Ocene",
194
198
  "noReviews": "Ocene še ni."
195
199
  },
@@ -202,6 +206,7 @@
202
206
  "create": "Nov oglas",
203
207
  "empty": "Nimate še oglasov.",
204
208
  "soldOut": "Razprodano",
209
+ "active": "Aktivno",
205
210
  "inactive": "Neaktivno",
206
211
  "edit": "Uredi",
207
212
  "deactivate": "Deaktiviraj",
@@ -21,6 +21,7 @@ export function BrowsePage() {
21
21
  categoryId,
22
22
  });
23
23
  const listings = data?.items ?? [];
24
+ const hasFilters = Boolean(search.trim() || categoryId);
24
25
 
25
26
  return (
26
27
  <div className="flex flex-col gap-6">
@@ -61,13 +62,39 @@ export function BrowsePage() {
61
62
  {isLoading ? (
62
63
  <LoadingState />
63
64
  ) : listings.length === 0 ? (
64
- <Typography size="body-3" className="text-text-default-2">
65
- {t(($) => $.browse.empty)}
66
- </Typography>
65
+ <div className="flex flex-col items-start gap-3">
66
+ <Typography
67
+ size="body-3"
68
+ className="text-text-default-2"
69
+ >
70
+ {t(($) => $.browse.empty)}
71
+ </Typography>
72
+ {hasFilters ? (
73
+ <Button
74
+ size="xs"
75
+ onPress={() => {
76
+ setSearch("");
77
+ setCategoryId(undefined);
78
+ }}
79
+ >
80
+ {t(($) => $.browse.clearFilters)}
81
+ </Button>
82
+ ) : (
83
+ <Button
84
+ size="xs"
85
+ link={{ to: "/my-listings" }}
86
+ >
87
+ {t(($) => $.sell.title)}
88
+ </Button>
89
+ )}
90
+ </div>
67
91
  ) : (
68
92
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
69
93
  {listings.map((listing) => (
70
- <ListingCard key={listing.id} listing={listing} />
94
+ <ListingCard
95
+ key={listing.id}
96
+ listing={listing}
97
+ />
71
98
  ))}
72
99
  </div>
73
100
  )}
@@ -1,3 +1,4 @@
1
+ import { Link } from "@povio/ui";
1
2
  import { Button, TextInput, Typography, useToast } from "@povio/ui/tanstack";
2
3
  import { useQueryClient } from "@tanstack/react-query";
3
4
  import { useTranslation } from "react-i18next";
@@ -5,6 +6,7 @@ import { useTranslation } from "react-i18next";
5
6
  import { LoadingState } from "@/components/shared/layout/LoadingState";
6
7
  import { PageHeader } from "@/components/shared/page/PageHeader";
7
8
  import { CartQueries } from "@/openapi/cart/cart.queries";
9
+ import { getFallbackImageUrl } from "@/utils/image-fallback";
8
10
  import { formatCents } from "@/utils/listing-form";
9
11
 
10
12
  export function CartPage() {
@@ -15,6 +17,7 @@ export function CartPage() {
15
17
  const updateItem = CartQueries.useUpdate();
16
18
  const removeItem = CartQueries.useDeleteApiCartById();
17
19
  const items = data ?? [];
20
+ const subtotalCents = items.reduce((sum, item) => sum + item.listing.priceCents * item.quantity, 0);
18
21
 
19
22
  const onQty = async (id: string, quantity: number) => {
20
23
  try {
@@ -40,7 +43,10 @@ export function CartPage() {
40
43
  title={t(($) => $.cart.title)}
41
44
  actions={
42
45
  items.length > 0 ? (
43
- <Button size="xs" link={{ to: "/checkout" }}>
46
+ <Button
47
+ size="xs"
48
+ link={{ to: "/checkout" }}
49
+ >
44
50
  {t(($) => $.cart.checkout)}
45
51
  </Button>
46
52
  ) : null
@@ -50,37 +56,83 @@ export function CartPage() {
50
56
  {isLoading ? (
51
57
  <LoadingState />
52
58
  ) : items.length === 0 ? (
53
- <Typography size="body-3" className="text-text-default-2">
54
- {t(($) => $.cart.empty)}
55
- </Typography>
59
+ <div className="flex flex-col items-start gap-3">
60
+ <Typography
61
+ size="body-3"
62
+ className="text-text-default-2"
63
+ >
64
+ {t(($) => $.cart.empty)}
65
+ </Typography>
66
+ <Button
67
+ size="xs"
68
+ link={{ to: "/" }}
69
+ >
70
+ {t(($) => $.layout.header.nav.browse)}
71
+ </Button>
72
+ </div>
56
73
  ) : (
57
74
  <div className="flex flex-col gap-4">
58
- {items.map((item) => (
59
- <div
60
- key={item.id}
61
- className="flex flex-col gap-2 border-elevation-outline-default-1 border-b pb-4"
62
- >
63
- <Typography size="title-5">{item.listing.title}</Typography>
64
- <Typography size="body-3">{formatCents(item.listing.priceCents)}</Typography>
65
- <div className="flex flex-wrap items-end gap-2">
66
- <TextInput
67
- variant="filled"
68
- size="extra-small"
69
- value={String(item.quantity)}
70
- onChange={(value) => void onQty(item.id, Number(value) || 1)}
71
- label={t(($) => $.cart.quantity)}
72
- />
73
- <Button
74
- size="xs"
75
- variant="outlined"
76
- color="secondary"
77
- onPress={() => void onRemove(item.id)}
75
+ {items.map((item) => {
76
+ const imageSrc = item.listing.images[0]?.url ?? getFallbackImageUrl({ width: 80, height: 80 });
77
+
78
+ return (
79
+ <div
80
+ key={item.id}
81
+ className="flex items-start gap-4 border-elevation-outline-default-1 border-b pb-4"
82
+ >
83
+ <Link
84
+ to="/listings/$id"
85
+ params={{ id: item.listing.id }}
86
+ className="shrink-0 overflow-hidden rounded-m border border-elevation-outline-default-1 no-underline!"
87
+ >
88
+ <img
89
+ src={imageSrc}
90
+ alt=""
91
+ className="h-16 w-16 object-cover"
92
+ />
93
+ </Link>
94
+ <div className="flex min-w-0 flex-1 flex-col gap-2">
95
+ <Link
96
+ to="/listings/$id"
97
+ params={{ id: item.listing.id }}
98
+ className="min-w-0 no-underline!"
99
+ >
100
+ <Typography size="title-5">{item.listing.title}</Typography>
101
+ </Link>
102
+ <div className="flex flex-wrap items-end gap-2">
103
+ <TextInput
104
+ variant="filled"
105
+ size="extra-small"
106
+ value={String(item.quantity)}
107
+ onChange={(value) => void onQty(item.id, Number(value) || 1)}
108
+ label={t(($) => $.cart.quantity)}
109
+ />
110
+ <Button
111
+ size="xs"
112
+ variant="outlined"
113
+ color="secondary"
114
+ onPress={() => void onRemove(item.id)}
115
+ >
116
+ {t(($) => $.cart.remove)}
117
+ </Button>
118
+ </div>
119
+ </div>
120
+ <Typography
121
+ size="title-5"
122
+ variant="prominent-1"
123
+ className="shrink-0"
78
124
  >
79
- {t(($) => $.cart.remove)}
80
- </Button>
125
+ {formatCents(item.listing.priceCents)}
126
+ </Typography>
81
127
  </div>
82
- </div>
83
- ))}
128
+ );
129
+ })}
130
+ <Typography
131
+ size="title-5"
132
+ variant="prominent-1"
133
+ >
134
+ {t(($) => $.cart.subtotal)}: {formatCents(subtotalCents)}
135
+ </Typography>
84
136
  </div>
85
137
  )}
86
138
  </div>
@@ -1,4 +1,4 @@
1
- import { Link, Typography } from "@povio/ui";
1
+ import { Link, Tag, Typography } from "@povio/ui";
2
2
  import { useTranslation } from "react-i18next";
3
3
 
4
4
  import type { ListingModels } from "@/openapi/listing/listing.models";
@@ -20,7 +20,11 @@ export function ListingCard({ listing }: ListingCardProps) {
20
20
  className="relative overflow-hidden rounded-m border border-elevation-outline-default-1 bg-elevation-fill-default-1 no-underline! shadow-sm transition-all hover:border-elevation-outline-default-2 hover:bg-elevation-fill-default-2"
21
21
  >
22
22
  <div className="aspect-video w-full bg-elevation-fill-default-2">
23
- <img src={imageSrc} alt="" className="h-full w-full object-cover" />
23
+ <img
24
+ src={imageSrc}
25
+ alt=""
26
+ className="h-full w-full object-cover"
27
+ />
24
28
  </div>
25
29
  <div className="flex flex-col gap-2 p-4">
26
30
  <Typography
@@ -31,19 +35,22 @@ export function ListingCard({ listing }: ListingCardProps) {
31
35
  >
32
36
  {listing.title}
33
37
  </Typography>
34
- <Typography size="body-3" className="text-text-default-1">
38
+ <Typography
39
+ size="title-5"
40
+ variant="prominent-1"
41
+ className="text-text-default-1"
42
+ >
35
43
  {formatCents(listing.priceCents)}
36
44
  </Typography>
37
45
  {listing.quantity <= 0 ? (
38
- <Typography size="label-3" className="text-text-default-2">
46
+ <Typography
47
+ size="label-3"
48
+ className="text-text-default-2"
49
+ >
39
50
  {t(($) => $.sell.soldOut)}
40
51
  </Typography>
41
52
  ) : null}
42
- {listing.category ? (
43
- <Typography size="label-3" className="text-text-default-2">
44
- {listing.category.name}
45
- </Typography>
46
- ) : null}
53
+ {listing.category ? <Tag color="secondary">{listing.category.name}</Tag> : null}
47
54
  </div>
48
55
  </Link>
49
56
  );
@@ -65,7 +65,7 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
65
65
  title={listing.title}
66
66
  />
67
67
 
68
- <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(16rem,22rem)]">
68
+ <div className="grid grid-cols-1 items-start gap-6 md:grid-cols-[minmax(0,1.4fr)_minmax(16rem,22rem)]">
69
69
  <ListingGallery images={listing.images} />
70
70
 
71
71
  <Card className="flex flex-col gap-4">
@@ -90,7 +90,9 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
90
90
  color="secondary"
91
91
  label={listing.watchedByMe ? t(($) => $.listing.unwatch) : t(($) => $.listing.watch)}
92
92
  disableTooltip
93
- icon={listing.watchedByMe ? <Heart className="fill-current" /> : Heart}
93
+ icon={
94
+ listing.watchedByMe ? <Heart className="fill-current text-interactive-contained-error-idle" /> : Heart
95
+ }
94
96
  onPress={() => void onToggleWatch()}
95
97
  />
96
98
  )}
@@ -134,15 +136,6 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
134
136
  : t(($) => $.listing.noRating)}
135
137
  </Typography>
136
138
 
137
- {listing.description ? (
138
- <Typography
139
- size="body-3"
140
- className="whitespace-pre-wrap text-text-default-2"
141
- >
142
- {listing.description}
143
- </Typography>
144
- ) : null}
145
-
146
139
  {isOwn ? <Typography size="body-3">{t(($) => $.listing.ownListing)}</Typography> : null}
147
140
 
148
141
  {canBuy ? (
@@ -166,6 +159,30 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
166
159
  </Card>
167
160
  </div>
168
161
 
162
+ <div className="flex flex-col gap-3">
163
+ <Typography
164
+ size="title-5"
165
+ variant="prominent-1"
166
+ >
167
+ {t(($) => $.listing.aboutThisItem)}
168
+ </Typography>
169
+ {listing.description ? (
170
+ <Typography
171
+ size="body-3"
172
+ className="whitespace-pre-wrap text-text-default-2"
173
+ >
174
+ {listing.description}
175
+ </Typography>
176
+ ) : (
177
+ <Typography
178
+ size="body-3"
179
+ className="text-text-default-2"
180
+ >
181
+ {t(($) => $.listing.noDescription)}
182
+ </Typography>
183
+ )}
184
+ </div>
185
+
169
186
  <div className="flex flex-col gap-3">
170
187
  <Typography
171
188
  size="title-5"
@@ -1,4 +1,4 @@
1
- import { Drawer, Typography } from "@povio/ui";
1
+ import { Drawer, Link, Tag, Typography } from "@povio/ui";
2
2
  import { Button, useToast } from "@povio/ui/tanstack";
3
3
  import { useQueryClient } from "@tanstack/react-query";
4
4
  import { useNavigate } from "@tanstack/react-router";
@@ -9,9 +9,9 @@ import { useTranslation } from "react-i18next";
9
9
  import { LoadingState } from "@/components/shared/layout/LoadingState";
10
10
  import { PageHeader } from "@/components/shared/page/PageHeader";
11
11
  import { ListingQueries } from "@/openapi/listing/listing.queries";
12
+ import { getFallbackImageUrl } from "@/utils/image-fallback";
12
13
  import { formatCents } from "@/utils/listing-form";
13
14
 
14
- import { ListingCard } from "./ListingCard";
15
15
  import { ListingForm } from "./ListingForm";
16
16
 
17
17
  interface MyListingsPageProps {
@@ -86,50 +86,88 @@ export function MyListingsPage({ openCreate = false }: MyListingsPageProps) {
86
86
  {isLoading ? (
87
87
  <LoadingState />
88
88
  ) : listings.length === 0 ? (
89
- <Typography
90
- size="body-3"
91
- className="text-text-default-2"
92
- >
93
- {t(($) => $.sell.empty)}
94
- </Typography>
89
+ <div className="flex flex-col items-start gap-3">
90
+ <Typography
91
+ size="body-3"
92
+ className="text-text-default-2"
93
+ >
94
+ {t(($) => $.sell.empty)}
95
+ </Typography>
96
+ <Button
97
+ size="xs"
98
+ icon={Plus}
99
+ onPress={() => setIsCreateOpen(true)}
100
+ >
101
+ {t(($) => $.sell.create)}
102
+ </Button>
103
+ </div>
95
104
  ) : (
96
105
  <div className="flex flex-col gap-4">
97
- {listings.map((listing) => (
98
- <div
99
- key={listing.id}
100
- className="flex flex-col gap-2"
101
- >
102
- <ListingCard listing={listing} />
103
- <div className="flex flex-wrap items-center gap-2">
106
+ {listings.map((listing) => {
107
+ const imageSrc = listing.images[0]?.url ?? getFallbackImageUrl({ width: 80, height: 80 });
108
+ const isActive = listing.status === "active";
109
+
110
+ return (
111
+ <div
112
+ key={listing.id}
113
+ className="flex flex-col gap-3 border-elevation-outline-default-1 border-b pb-4 sm:flex-row sm:items-center"
114
+ >
115
+ <Link
116
+ to="/listings/$id"
117
+ params={{ id: listing.id }}
118
+ className="flex min-w-0 flex-1 items-center gap-3 no-underline!"
119
+ >
120
+ <img
121
+ src={imageSrc}
122
+ alt=""
123
+ className="h-16 w-16 shrink-0 rounded-m border border-elevation-outline-default-1 object-cover"
124
+ />
125
+ <Typography
126
+ size="title-5"
127
+ variant="prominent-1"
128
+ className="min-w-0 text-text-default-1"
129
+ >
130
+ {listing.title}
131
+ </Typography>
132
+ </Link>
104
133
  <Typography
105
- size="label-3"
106
- className="text-text-default-2"
134
+ size="body-3"
135
+ className="text-text-default-1"
107
136
  >
108
137
  {formatCents(listing.priceCents)}
109
- {listing.quantity <= 0 ? ` · ${t(($) => $.sell.soldOut)}` : ""}
110
- {listing.status === "inactive" ? ` · ${t(($) => $.sell.inactive)}` : ""}
111
138
  </Typography>
112
- <Button
113
- size="xs"
114
- variant="outlined"
115
- color="secondary"
116
- link={{ to: "/sell/$id", params: { id: listing.id } }}
139
+ <Typography
140
+ size="body-3"
141
+ className="text-text-default-2"
117
142
  >
118
- {t(($) => $.sell.edit)}
119
- </Button>
120
- {listing.status === "active" ? (
143
+ {listing.quantity}
144
+ </Typography>
145
+ <Tag color={isActive ? "success" : "secondary"}>
146
+ {isActive ? t(($) => $.sell.active) : t(($) => $.sell.inactive)}
147
+ </Tag>
148
+ <div className="flex flex-wrap items-center gap-2">
121
149
  <Button
122
150
  size="xs"
123
151
  variant="outlined"
124
152
  color="secondary"
125
- onPress={() => void onDeactivate(listing.id)}
153
+ link={{ to: "/sell/$id", params: { id: listing.id } }}
126
154
  >
127
- {t(($) => $.sell.deactivate)}
155
+ {t(($) => $.sell.edit)}
128
156
  </Button>
129
- ) : null}
157
+ {isActive ? (
158
+ <Button
159
+ size="xs"
160
+ variant="outlined"
161
+ color="secondary"
162
+ onPress={() => void onDeactivate(listing.id)}
163
+ >
164
+ {t(($) => $.sell.deactivate)}
165
+ </Button>
166
+ ) : null}
167
+ </div>
130
168
  </div>
131
- </div>
132
- ))}
169
+ );
170
+ })}
133
171
  </div>
134
172
  )}
135
173
  </div>
@@ -31,13 +31,27 @@ export function WatchlistPage() {
31
31
  {isLoading ? (
32
32
  <LoadingState />
33
33
  ) : items.length === 0 ? (
34
- <Typography size="body-3" className="text-text-default-2">
35
- {t(($) => $.watchlist.empty)}
36
- </Typography>
34
+ <div className="flex flex-col items-start gap-3">
35
+ <Typography
36
+ size="body-3"
37
+ className="text-text-default-2"
38
+ >
39
+ {t(($) => $.watchlist.empty)}
40
+ </Typography>
41
+ <Button
42
+ size="xs"
43
+ link={{ to: "/" }}
44
+ >
45
+ {t(($) => $.layout.header.nav.browse)}
46
+ </Button>
47
+ </div>
37
48
  ) : (
38
49
  <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
39
50
  {items.map((item) => (
40
- <div key={item.id} className="flex flex-col gap-2">
51
+ <div
52
+ key={item.id}
53
+ className="flex flex-col gap-2"
54
+ >
41
55
  <ListingCard listing={item.listing} />
42
56
  <Button
43
57
  size="xs"
@@ -7,6 +7,7 @@ import { useTranslation } from "react-i18next";
7
7
 
8
8
  import { BrandLogo } from "@/components/shared/branding/BrandLogo";
9
9
  import { useAuth } from "@/hooks/useAuth";
10
+ import { CartQueries } from "@/openapi/cart/cart.queries";
10
11
 
11
12
  import { MobileNavigation } from "./MobileNavigation";
12
13
  import { NavLink } from "./NavLink";
@@ -16,6 +17,10 @@ export function AppHeader() {
16
17
  const navigate = useNavigate();
17
18
  const { isAuthenticated, user, logout } = useAuth();
18
19
  const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
20
+ const { data: cartItems } = CartQueries.useGetAll();
21
+ const cartCount = cartItems?.length ?? 0;
22
+ const cartLabel =
23
+ cartCount > 0 ? t(($) => $.layout.header.nav.cartCount, { count: cartCount }) : t(($) => $.layout.header.nav.cart);
19
24
 
20
25
  const menuItems = [
21
26
  { label: t(($) => $.layout.header.profile), id: "profile" },
@@ -53,7 +58,7 @@ export function AppHeader() {
53
58
  <NavLink to="/my-listings">{t(($) => $.layout.header.nav.sell)}</NavLink>
54
59
  <NavLink to="/watchlist">{t(($) => $.layout.header.nav.watchlist)}</NavLink>
55
60
  <NavLink to="/orders">{t(($) => $.layout.header.nav.orders)}</NavLink>
56
- <NavLink to="/cart">{t(($) => $.layout.header.nav.cart)}</NavLink>
61
+ <NavLink to="/cart">{cartLabel}</NavLink>
57
62
  </nav>
58
63
  </div>
59
64
 
@@ -5,6 +5,7 @@ import type { Key } from "react";
5
5
  import { useTranslation } from "react-i18next";
6
6
 
7
7
  import { useAuth } from "@/hooks/useAuth";
8
+ import { CartQueries } from "@/openapi/cart/cart.queries";
8
9
 
9
10
  import { NavLink } from "./NavLink";
10
11
 
@@ -12,6 +13,10 @@ export function MobileNavigation() {
12
13
  const { t } = useTranslation();
13
14
  const navigate = useNavigate();
14
15
  const { isAuthenticated, user, logout } = useAuth();
16
+ const { data: cartItems } = CartQueries.useGetAll();
17
+ const cartCount = cartItems?.length ?? 0;
18
+ const cartLabel =
19
+ cartCount > 0 ? t(($) => $.layout.header.nav.cartCount, { count: cartCount }) : t(($) => $.layout.header.nav.cart);
15
20
  const accountLabel = user?.name || user?.email || t(($) => $.layout.header.profile);
16
21
 
17
22
  const handleMenuAction = (key: Key) => {
@@ -29,7 +34,7 @@ export function MobileNavigation() {
29
34
  <NavLink to="/my-listings">{t(($) => $.layout.header.nav.sell)}</NavLink>
30
35
  <NavLink to="/watchlist">{t(($) => $.layout.header.nav.watchlist)}</NavLink>
31
36
  <NavLink to="/orders">{t(($) => $.layout.header.nav.orders)}</NavLink>
32
- <NavLink to="/cart">{t(($) => $.layout.header.nav.cart)}</NavLink>
37
+ <NavLink to="/cart">{cartLabel}</NavLink>
33
38
  </nav>
34
39
 
35
40
  {isAuthenticated ? (
@@ -1,13 +1,16 @@
1
1
  import clsx from "clsx";
2
2
  import type { HTMLAttributes } from "react";
3
+ import { useTranslation } from "react-i18next";
3
4
 
4
5
  export function BrandLogo({ className, ...props }: HTMLAttributes<HTMLSpanElement>) {
6
+ const { t } = useTranslation();
7
+
5
8
  return (
6
9
  <span
7
10
  className={clsx("inline-flex h-[34px] items-center font-semibold text-xl tracking-tight", className)}
8
11
  {...props}
9
12
  >
10
- Tiny
13
+ {t(($) => $.appName)}
11
14
  </span>
12
15
  );
13
16
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "info": {
3
3
  "title": "Marketplace API",
4
- "description": "OpenAPI spec for the Tiny marketplace backend and reserved /api/user auth.",
4
+ "description": "OpenAPI spec for the Marketplace backend and reserved /api/user auth.",
5
5
  "version": "1.0.0"
6
6
  },
7
7
  "servers": [
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.6.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.25.0",
14
+ "robodev": "^0.26.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }
@@ -11,7 +11,7 @@
11
11
  "@robodev-ai/sdk": "^0.6.0"
12
12
  },
13
13
  "devDependencies": {
14
- "robodev": "^0.25.0",
14
+ "robodev": "^0.26.0",
15
15
  "rulesync": "^8.18.0",
16
16
  "typescript": "^5.9.2"
17
17
  }