robodev 0.24.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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/templates/auth-chat/package.json +1 -1
  3. package/templates/backend/package.json +1 -1
  4. package/templates/empty/package.json +1 -1
  5. package/templates/marketplace/README.md +3 -3
  6. package/templates/marketplace/api/_lib.seed.test.ts +10 -0
  7. package/templates/marketplace/api/_lib.ts +114 -57
  8. package/templates/marketplace/api/categories.ts +1 -1
  9. package/templates/marketplace/api/listings/[id].ts +1 -1
  10. package/templates/marketplace/api/listings/paginate.ts +1 -1
  11. package/templates/marketplace/api/listings.ts +1 -1
  12. package/templates/marketplace/apps/fe/src/assets/locales/en/translation.json +11 -5
  13. package/templates/marketplace/apps/fe/src/assets/locales/sl/translation.json +11 -5
  14. package/templates/marketplace/apps/fe/src/components/features/marketplace/BrowsePage.tsx +31 -4
  15. package/templates/marketplace/apps/fe/src/components/features/marketplace/CartPage.tsx +80 -28
  16. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingCard.tsx +16 -9
  17. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingDetailsPage.tsx +107 -48
  18. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingForm.tsx +278 -0
  19. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingFormPage.tsx +6 -222
  20. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingGallery.tsx +54 -0
  21. package/templates/marketplace/apps/fe/src/components/features/marketplace/MyListingsPage.tsx +120 -29
  22. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrderStatusTag.tsx +24 -0
  23. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrderSummaryCard.tsx +39 -0
  24. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrdersPage.tsx +57 -45
  25. package/templates/marketplace/apps/fe/src/components/features/marketplace/WatchlistPage.tsx +18 -4
  26. package/templates/marketplace/apps/fe/src/components/layout/app-header/AppHeader.tsx +6 -1
  27. package/templates/marketplace/apps/fe/src/components/layout/app-header/MobileNavigation.tsx +6 -1
  28. package/templates/marketplace/apps/fe/src/components/shared/branding/BrandLogo.tsx +4 -1
  29. package/templates/marketplace/apps/fe/src/pages/(private)/my-listings.tsx +11 -1
  30. package/templates/marketplace/apps/fe/src/pages/(private)/sell/index.tsx +6 -10
  31. package/templates/marketplace/openapi.json +1 -1
  32. package/templates/marketplace/package.json +1 -1
  33. package/templates/space/package.json +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "robodev",
3
- "version": "0.24.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.24.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.24.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.24.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
 
@@ -0,0 +1,10 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+
4
+ import { seedListingPhotoSeed } from "./_lib";
5
+
6
+ test("seedListingPhotoSeed is stable and URL-safe", () => {
7
+ assert.equal(seedListingPhotoSeed("Vintage analog synthesizer"), "vintage-analog-synthesizer");
8
+ assert.equal(seedListingPhotoSeed("Vintage analog synthesizer"), seedListingPhotoSeed("Vintage analog synthesizer"));
9
+ assert.equal(seedListingPhotoSeed(" "), "listing");
10
+ });
@@ -286,7 +286,16 @@ export async function sendMail(
286
286
  await email.send({ to: input.to, subject: input.subject, text: input.text });
287
287
  }
288
288
 
289
- export async function ensureSeed(db: Db) {
289
+ export function seedListingPhotoSeed(title: string): string {
290
+ const slug = title
291
+ .toLowerCase()
292
+ .replace(/[^a-z0-9]+/g, "-")
293
+ .replace(/^-+|-+$/g, "");
294
+ return slug || "listing";
295
+ }
296
+
297
+ export async function ensureSeed(ctx: ApiHandlerContext<unknown, unknown>) {
298
+ const db = ctx.db;
290
299
  const existingCategories = await db.select({ id: categories.id }).from(categories).limit(1);
291
300
  if (existingCategories.length === 0) {
292
301
  try {
@@ -302,41 +311,109 @@ export async function ensureSeed(db: Db) {
302
311
  }
303
312
 
304
313
  const existingListings = await db.select({ id: listings.id }).from(listings).limit(1);
305
- if (existingListings.length > 0) return;
314
+ if (existingListings.length === 0) {
315
+ const categoryRows = await db.select().from(categories);
316
+ if (categoryRows.length > 0) {
317
+ const bySlug = new Map(categoryRows.map((row) => [row.slug, row]));
318
+ const now = new Date();
319
+ try {
320
+ await db.insert(listings).values(
321
+ LISTING_SEEDS.flatMap((seed) => {
322
+ const category = bySlug.get(seed.slug);
323
+ if (!category) return [];
324
+ return [
325
+ {
326
+ sellerId: SEED_SELLER_ID,
327
+ sellerName: SEED_SELLER_NAME,
328
+ sellerEmail: SEED_SELLER_EMAIL,
329
+ categoryId: category.id,
330
+ title: seed.title,
331
+ description: seed.description,
332
+ priceCents: seed.priceCents,
333
+ quantity: seed.quantity,
334
+ shippingCents: seed.shippingCents,
335
+ shippingDays: seed.shippingDays,
336
+ status: LISTING_STATUS.active,
337
+ createdAt: now,
338
+ updatedAt: now,
339
+ },
340
+ ];
341
+ }),
342
+ );
343
+ } catch {
344
+ // First request won the seed race.
345
+ }
346
+ }
347
+ }
306
348
 
307
- const categoryRows = await db.select().from(categories);
308
- if (categoryRows.length === 0) return;
309
- const bySlug = new Map(categoryRows.map((row) => [row.slug, row]));
310
- const now = new Date();
311
- try {
312
- await db.insert(listings).values(
313
- LISTING_SEEDS.flatMap((seed) => {
314
- const category = bySlug.get(seed.slug);
315
- if (!category) return [];
316
- return [
317
- {
318
- sellerId: SEED_SELLER_ID,
319
- sellerName: SEED_SELLER_NAME,
320
- sellerEmail: SEED_SELLER_EMAIL,
321
- categoryId: category.id,
322
- title: seed.title,
323
- description: seed.description,
324
- priceCents: seed.priceCents,
325
- quantity: seed.quantity,
326
- shippingCents: seed.shippingCents,
327
- shippingDays: seed.shippingDays,
328
- status: LISTING_STATUS.active,
329
- createdAt: now,
330
- updatedAt: now,
331
- },
332
- ];
333
- }),
334
- );
335
- } catch {
336
- // First request won the seed race.
349
+ await seedListingPhotos(ctx);
350
+ }
351
+
352
+ async function seedListingPhotos(ctx: ApiHandlerContext<unknown, unknown>) {
353
+ const seedRows = await ctx.db.select().from(listings).where(eq(listings.sellerId, SEED_SELLER_ID));
354
+ if (seedRows.length === 0) return;
355
+
356
+ const listingIds = seedRows.map((row) => row.id);
357
+ const imageRows = await ctx.db
358
+ .select({ listingId: listingImages.listingId })
359
+ .from(listingImages)
360
+ .where(inArray(listingImages.listingId, listingIds));
361
+ const withImage = new Set(imageRows.map((row) => row.listingId));
362
+
363
+ for (const listing of seedRows) {
364
+ if (withImage.has(listing.id)) continue;
365
+ try {
366
+ await seedOneListingPhoto(ctx, listing);
367
+ } catch {
368
+ // Per-listing failures must not abort the rest of seed.
369
+ }
337
370
  }
338
371
  }
339
372
 
373
+ async function seedOneListingPhoto(ctx: ApiHandlerContext<unknown, unknown>, listing: ListingRow) {
374
+ const existing = await ctx.db
375
+ .select({ id: listingImages.id })
376
+ .from(listingImages)
377
+ .where(eq(listingImages.listingId, listing.id))
378
+ .limit(1);
379
+ if (existing.length > 0) return;
380
+
381
+ const seed = seedListingPhotoSeed(listing.title);
382
+ const response = await fetch(`https://picsum.photos/seed/${encodeURIComponent(seed)}/800/600`);
383
+ if (!response.ok) return;
384
+ const bytes = new Uint8Array(await response.arrayBuffer());
385
+ if (bytes.length === 0) return;
386
+
387
+ const headerType = response.headers.get("content-type")?.split(";")[0]?.trim();
388
+ const mimeType = headerType && ALLOWED_LISTING_IMAGE_TYPES.has(headerType) ? headerType : "image/jpeg";
389
+ const ext = mimeType === "image/png" ? "png" : mimeType === "image/webp" ? "webp" : "jpg";
390
+ const fileName = `${seed}.${ext}`;
391
+ const key = `listing-images/${SEED_SELLER_ID}/${listing.id}-${crypto.randomUUID()}-${fileName}`;
392
+
393
+ const [row] = await ctx.db
394
+ .insert(media)
395
+ .values({
396
+ key,
397
+ userId: SEED_SELLER_ID,
398
+ resourceName: LISTING_IMAGE_RESOURCE,
399
+ fileName,
400
+ fileSize: bytes.length,
401
+ mimeType,
402
+ })
403
+ .returning();
404
+
405
+ await ctx.storage.upload(row!.key, bytes, {
406
+ public: true,
407
+ contentType: mimeType,
408
+ });
409
+ await ctx.db.update(media).set({ uploaded: new Date() }).where(eq(media.id, row!.id));
410
+ await ctx.db.insert(listingImages).values({
411
+ listingId: listing.id,
412
+ mediaId: row!.id,
413
+ sortOrder: 0,
414
+ });
415
+ }
416
+
340
417
  function sanitizeFilename(name: string): string {
341
418
  const base = name.split(/[/\\]/).pop() ?? "";
342
419
  return base
@@ -527,11 +604,7 @@ export async function enrichListings(
527
604
  });
528
605
  }
529
606
 
530
- export async function enrichOne(
531
- ctx: ApiHandlerContext<unknown, unknown>,
532
- listing: ListingRow,
533
- currentUserId: string,
534
- ) {
607
+ export async function enrichOne(ctx: ApiHandlerContext<unknown, unknown>, listing: ListingRow, currentUserId: string) {
535
608
  const [dto] = await enrichListings(ctx, [listing], currentUserId);
536
609
  return dto!;
537
610
  }
@@ -546,14 +619,9 @@ export async function replaceListingImages(
546
619
  if (removeImageIds.length > 0) {
547
620
  await ctx.db
548
621
  .delete(listingImages)
549
- .where(
550
- and(eq(listingImages.listingId, listingId), inArray(listingImages.mediaId, removeImageIds)),
551
- );
622
+ .where(and(eq(listingImages.listingId, listingId), inArray(listingImages.mediaId, removeImageIds)));
552
623
  }
553
- const remaining = await ctx.db
554
- .select()
555
- .from(listingImages)
556
- .where(eq(listingImages.listingId, listingId));
624
+ const remaining = await ctx.db.select().from(listingImages).where(eq(listingImages.listingId, listingId));
557
625
  if (remaining.length + files.length > MAX_LISTING_IMAGES) {
558
626
  return fail(400, "Too many images");
559
627
  }
@@ -607,8 +675,7 @@ export function parseListingWrite(body: {
607
675
  if (body.priceCents < 0 || body.quantity < 0 || body.shippingCents < 0 || body.shippingDays < 1) {
608
676
  return fail(400, "Invalid listing amounts");
609
677
  }
610
- const status =
611
- body.status === LISTING_STATUS.inactive ? LISTING_STATUS.inactive : LISTING_STATUS.active;
678
+ const status = body.status === LISTING_STATUS.inactive ? LISTING_STATUS.inactive : LISTING_STATUS.active;
612
679
  return {
613
680
  title: body.title.trim(),
614
681
  description: body.description?.trim() ? body.description.trim() : null,
@@ -664,14 +731,4 @@ export function toReviewDto(row: ReviewRow): ReviewDto {
664
731
  };
665
732
  }
666
733
 
667
- export {
668
- cartItems,
669
- categories,
670
- listingImages,
671
- listings,
672
- media,
673
- orderItems,
674
- orders,
675
- reviews,
676
- watches,
677
- };
734
+ export { cartItems, categories, listingImages, listings, media, orderItems, orders, reviews, watches };
@@ -4,7 +4,7 @@ import { categories, ensureSeed } from "./_lib";
4
4
  export const get = defineApi({
5
5
  auth: "required",
6
6
  handler: async (ctx) => {
7
- await ensureSeed(ctx.db);
7
+ await ensureSeed(ctx);
8
8
  const rows = await ctx.db.select().from(categories);
9
9
  return rows.map((row) => ({ id: row.id, slug: row.slug, name: row.name }));
10
10
  },
@@ -31,7 +31,7 @@ const listingBody = z.object({
31
31
  export const get = defineApi({
32
32
  auth: "required",
33
33
  handler: async (ctx) => {
34
- await ensureSeed(ctx.db);
34
+ await ensureSeed(ctx);
35
35
  const listing = await findListing(ctx.db, ctx.params.id);
36
36
  if (!listing) return fail(404, "Listing not found");
37
37
  return enrichOne(ctx, listing, ctx.user!.id);
@@ -11,7 +11,7 @@ import {
11
11
  export const get = defineApi({
12
12
  auth: "required",
13
13
  handler: async (ctx) => {
14
- await ensureSeed(ctx.db);
14
+ await ensureSeed(ctx);
15
15
  const query = ctx.query as Record<string, unknown>;
16
16
  const search = typeof query.search === "string" ? query.search : undefined;
17
17
  const categoryId = typeof query.categoryId === "string" ? query.categoryId : undefined;
@@ -31,7 +31,7 @@ const listingBody = z.object({
31
31
  export const get = defineApi({
32
32
  auth: "required",
33
33
  handler: async (ctx) => {
34
- await ensureSeed(ctx.db);
34
+ await ensureSeed(ctx);
35
35
  const query = ctx.query as Record<string, unknown>;
36
36
  const mine = isTruthyQuery(query.mine);
37
37
  const search = typeof query.search === "string" ? query.search : undefined;
@@ -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",
@@ -281,6 +286,7 @@
281
286
  "sales": "Sales",
282
287
  "emptyPurchases": "No purchases yet.",
283
288
  "emptySales": "No sales yet.",
289
+ "view": "View",
284
290
  "status": {
285
291
  "paid": "Paid",
286
292
  "shipped": "Shipped",
@@ -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",
@@ -281,6 +286,7 @@
281
286
  "sales": "Prodaje",
282
287
  "emptyPurchases": "Nimate še nakupov.",
283
288
  "emptySales": "Nimate še prodaj.",
289
+ "view": "Poglej",
284
290
  "status": {
285
291
  "paid": "Plačano",
286
292
  "shipped": "Poslano",
@@ -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>