robodev 0.24.0 → 0.25.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 (24) 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/api/_lib.seed.test.ts +10 -0
  6. package/templates/marketplace/api/_lib.ts +114 -57
  7. package/templates/marketplace/api/categories.ts +1 -1
  8. package/templates/marketplace/api/listings/[id].ts +1 -1
  9. package/templates/marketplace/api/listings/paginate.ts +1 -1
  10. package/templates/marketplace/api/listings.ts +1 -1
  11. package/templates/marketplace/apps/fe/src/assets/locales/en/translation.json +1 -0
  12. package/templates/marketplace/apps/fe/src/assets/locales/sl/translation.json +1 -0
  13. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingDetailsPage.tsx +91 -49
  14. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingForm.tsx +278 -0
  15. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingFormPage.tsx +6 -222
  16. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingGallery.tsx +54 -0
  17. package/templates/marketplace/apps/fe/src/components/features/marketplace/MyListingsPage.tsx +61 -8
  18. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrderStatusTag.tsx +24 -0
  19. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrderSummaryCard.tsx +39 -0
  20. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrdersPage.tsx +57 -45
  21. package/templates/marketplace/apps/fe/src/pages/(private)/my-listings.tsx +11 -1
  22. package/templates/marketplace/apps/fe/src/pages/(private)/sell/index.tsx +6 -10
  23. package/templates/marketplace/package.json +1 -1
  24. 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.25.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.25.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.25.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.25.0",
14
14
  "typescript": "^5.9.2"
15
15
  }
16
16
  }
@@ -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;
@@ -281,6 +281,7 @@
281
281
  "sales": "Sales",
282
282
  "emptyPurchases": "No purchases yet.",
283
283
  "emptySales": "No sales yet.",
284
+ "view": "View",
284
285
  "status": {
285
286
  "paid": "Paid",
286
287
  "shipped": "Shipped",
@@ -281,6 +281,7 @@
281
281
  "sales": "Prodaje",
282
282
  "emptyPurchases": "Nimate še nakupov.",
283
283
  "emptySales": "Nimate še prodaj.",
284
+ "view": "Poglej",
284
285
  "status": {
285
286
  "paid": "Plačano",
286
287
  "shipped": "Poslano",
@@ -1,5 +1,6 @@
1
- import { Button, TextInput, Typography, useToast } from "@povio/ui/tanstack";
1
+ import { Button, IconButton, TextInput, Typography, useToast } from "@povio/ui/tanstack";
2
2
  import { useQueryClient } from "@tanstack/react-query";
3
+ import { Heart } from "lucide-react";
3
4
  import { useState } from "react";
4
5
  import { useTranslation } from "react-i18next";
5
6
 
@@ -11,9 +12,10 @@ import { ListingQueries } from "@/openapi/listing/listing.queries";
11
12
  import type { ListingModels } from "@/openapi/listing/listing.models";
12
13
  import { ReviewQueries } from "@/openapi/review/review.queries";
13
14
  import { WatchQueries } from "@/openapi/watch/watch.queries";
14
- import { getFallbackImageUrl } from "@/utils/image-fallback";
15
15
  import { formatCents } from "@/utils/listing-form";
16
16
 
17
+ import { ListingGallery } from "./ListingGallery";
18
+
17
19
  interface ListingDetailsPageProps {
18
20
  listing: ListingModels.ListingDto;
19
21
  }
@@ -30,7 +32,6 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
30
32
  const { data: reviews } = ReviewQueries.useGetAll({ listingId: listing.id });
31
33
  const isOwn = user?.id === listing.sellerId;
32
34
  const canBuy = !isOwn && listing.status === "active" && listing.quantity > 0;
33
- const imageSrc = listing.images[0]?.url ?? getFallbackImageUrl({ width: 800, height: 450 });
34
35
 
35
36
  const onAddToCart = async () => {
36
37
  try {
@@ -58,44 +59,90 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
58
59
 
59
60
  return (
60
61
  <div className="flex flex-col gap-6">
61
- <PageHeader enableBack backProps={{ backLink: { to: "/" } }} title={listing.title} />
62
+ <PageHeader
63
+ enableBack
64
+ backProps={{ backLink: { to: "/" } }}
65
+ title={listing.title}
66
+ />
62
67
 
63
- <Card className="max-w-3xl overflow-hidden p-0">
64
- <div className="aspect-video w-full bg-elevation-fill-default-2">
65
- <img src={imageSrc} alt="" className="h-full w-full object-cover" />
66
- </div>
67
- <div className="flex flex-col gap-4 p-4">
68
- <Typography size="title-5" variant="prominent-1">
69
- {formatCents(listing.priceCents)}
70
- </Typography>
71
- {listing.description ? (
72
- <Typography size="body-3" className="whitespace-pre-wrap text-text-default-2">
73
- {listing.description}
68
+ <div className="grid grid-cols-1 items-start gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(16rem,22rem)]">
69
+ <ListingGallery images={listing.images} />
70
+
71
+ <Card className="flex flex-col gap-4">
72
+ <div className="flex items-start justify-between gap-3">
73
+ <Typography
74
+ size="title-5"
75
+ variant="prominent-1"
76
+ >
77
+ {formatCents(listing.priceCents)}
74
78
  </Typography>
75
- ) : null}
76
- <Typography size="label-3" className="text-text-default-2">
79
+ {isOwn ? (
80
+ <Button
81
+ size="xs"
82
+ link={{ to: "/sell/$id", params: { id: listing.id } }}
83
+ >
84
+ {t(($) => $.sell.edit)}
85
+ </Button>
86
+ ) : (
87
+ <IconButton
88
+ size="xs"
89
+ variant="ghost"
90
+ color="secondary"
91
+ label={listing.watchedByMe ? t(($) => $.listing.unwatch) : t(($) => $.listing.watch)}
92
+ disableTooltip
93
+ icon={listing.watchedByMe ? <Heart className="fill-current" /> : Heart}
94
+ onPress={() => void onToggleWatch()}
95
+ />
96
+ )}
97
+ </div>
98
+
99
+ <Typography
100
+ size="label-3"
101
+ className="text-text-default-2"
102
+ >
103
+ {listing.quantity > 0
104
+ ? t(($) => $.listing.inStock, { count: listing.quantity })
105
+ : t(($) => $.listing.soldOut)}
106
+ </Typography>
107
+ <Typography
108
+ size="label-3"
109
+ className="text-text-default-2"
110
+ >
111
+ {t(($) => $.listing.shipping)}: {formatCents(listing.shippingCents)} ·{" "}
112
+ {t(($) => $.listing.shippingDays, { count: listing.shippingDays })}
113
+ </Typography>
114
+ <Typography
115
+ size="label-3"
116
+ className="text-text-default-2"
117
+ >
77
118
  {t(($) => $.listing.seller)}: {listing.sellerName || listing.sellerEmail}
78
119
  </Typography>
79
120
  {listing.category ? (
80
- <Typography size="label-3" className="text-text-default-2">
121
+ <Typography
122
+ size="label-3"
123
+ className="text-text-default-2"
124
+ >
81
125
  {t(($) => $.listing.category)}: {listing.category.name}
82
126
  </Typography>
83
127
  ) : null}
84
- <Typography size="label-3" className="text-text-default-2">
85
- {t(($) => $.listing.shipping)}: {formatCents(listing.shippingCents)} ·{" "}
86
- {t(($) => $.listing.shippingDays, { count: listing.shippingDays })}
87
- </Typography>
88
- <Typography size="label-3" className="text-text-default-2">
89
- {listing.quantity > 0
90
- ? t(($) => $.listing.inStock, { count: listing.quantity })
91
- : t(($) => $.listing.soldOut)}
92
- </Typography>
93
- <Typography size="label-3" className="text-text-default-2">
128
+ <Typography
129
+ size="label-3"
130
+ className="text-text-default-2"
131
+ >
94
132
  {listing.sellerAverageRating != null
95
133
  ? t(($) => $.listing.rating, { rating: listing.sellerAverageRating })
96
134
  : t(($) => $.listing.noRating)}
97
135
  </Typography>
98
136
 
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
+
99
146
  {isOwn ? <Typography size="body-3">{t(($) => $.listing.ownListing)}</Typography> : null}
100
147
 
101
148
  {canBuy ? (
@@ -107,35 +154,30 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
107
154
  onChange={setQty}
108
155
  label={t(($) => $.listing.quantity)}
109
156
  />
110
- <Button size="xs" isLoading={addToCart.isPending} onPress={() => void onAddToCart()}>
157
+ <Button
158
+ size="xs"
159
+ isLoading={addToCart.isPending}
160
+ onPress={() => void onAddToCart()}
161
+ >
111
162
  {t(($) => $.listing.addToCart)}
112
163
  </Button>
113
164
  </div>
114
165
  ) : null}
166
+ </Card>
167
+ </div>
115
168
 
116
- {!isOwn ? (
117
- <Button
118
- size="xs"
119
- variant="outlined"
120
- color="secondary"
121
- onPress={() => void onToggleWatch()}
122
- >
123
- {listing.watchedByMe ? t(($) => $.listing.unwatch) : t(($) => $.listing.watch)}
124
- </Button>
125
- ) : (
126
- <Button size="xs" link={{ to: "/sell/$id", params: { id: listing.id } }}>
127
- {t(($) => $.sell.edit)}
128
- </Button>
129
- )}
130
- </div>
131
- </Card>
132
-
133
- <div className="flex max-w-3xl flex-col gap-3">
134
- <Typography size="title-5" variant="prominent-1">
169
+ <div className="flex flex-col gap-3">
170
+ <Typography
171
+ size="title-5"
172
+ variant="prominent-1"
173
+ >
135
174
  {t(($) => $.listing.reviews)}
136
175
  </Typography>
137
176
  {!reviews || reviews.length === 0 ? (
138
- <Typography size="body-3" className="text-text-default-2">
177
+ <Typography
178
+ size="body-3"
179
+ className="text-text-default-2"
180
+ >
139
181
  {t(($) => $.listing.noReviews)}
140
182
  </Typography>
141
183
  ) : (