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
@@ -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
  );
@@ -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,39 +59,78 @@ 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 md: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={
94
+ listing.watchedByMe ? <Heart className="fill-current text-interactive-contained-error-idle" /> : Heart
95
+ }
96
+ onPress={() => void onToggleWatch()}
97
+ />
98
+ )}
99
+ </div>
100
+
101
+ <Typography
102
+ size="label-3"
103
+ className="text-text-default-2"
104
+ >
105
+ {listing.quantity > 0
106
+ ? t(($) => $.listing.inStock, { count: listing.quantity })
107
+ : t(($) => $.listing.soldOut)}
108
+ </Typography>
109
+ <Typography
110
+ size="label-3"
111
+ className="text-text-default-2"
112
+ >
113
+ {t(($) => $.listing.shipping)}: {formatCents(listing.shippingCents)} ·{" "}
114
+ {t(($) => $.listing.shippingDays, { count: listing.shippingDays })}
115
+ </Typography>
116
+ <Typography
117
+ size="label-3"
118
+ className="text-text-default-2"
119
+ >
77
120
  {t(($) => $.listing.seller)}: {listing.sellerName || listing.sellerEmail}
78
121
  </Typography>
79
122
  {listing.category ? (
80
- <Typography size="label-3" className="text-text-default-2">
123
+ <Typography
124
+ size="label-3"
125
+ className="text-text-default-2"
126
+ >
81
127
  {t(($) => $.listing.category)}: {listing.category.name}
82
128
  </Typography>
83
129
  ) : 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">
130
+ <Typography
131
+ size="label-3"
132
+ className="text-text-default-2"
133
+ >
94
134
  {listing.sellerAverageRating != null
95
135
  ? t(($) => $.listing.rating, { rating: listing.sellerAverageRating })
96
136
  : t(($) => $.listing.noRating)}
@@ -107,35 +147,54 @@ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
107
147
  onChange={setQty}
108
148
  label={t(($) => $.listing.quantity)}
109
149
  />
110
- <Button size="xs" isLoading={addToCart.isPending} onPress={() => void onAddToCart()}>
150
+ <Button
151
+ size="xs"
152
+ isLoading={addToCart.isPending}
153
+ onPress={() => void onAddToCart()}
154
+ >
111
155
  {t(($) => $.listing.addToCart)}
112
156
  </Button>
113
157
  </div>
114
158
  ) : null}
159
+ </Card>
160
+ </div>
115
161
 
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>
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>
132
185
 
133
- <div className="flex max-w-3xl flex-col gap-3">
134
- <Typography size="title-5" variant="prominent-1">
186
+ <div className="flex flex-col gap-3">
187
+ <Typography
188
+ size="title-5"
189
+ variant="prominent-1"
190
+ >
135
191
  {t(($) => $.listing.reviews)}
136
192
  </Typography>
137
193
  {!reviews || reviews.length === 0 ? (
138
- <Typography size="body-3" className="text-text-default-2">
194
+ <Typography
195
+ size="body-3"
196
+ className="text-text-default-2"
197
+ >
139
198
  {t(($) => $.listing.noReviews)}
140
199
  </Typography>
141
200
  ) : (
@@ -0,0 +1,278 @@
1
+ import {
2
+ Button,
3
+ FileUpload,
4
+ TextArea,
5
+ TextInput,
6
+ Typography,
7
+ useForm,
8
+ useFormValue,
9
+ useToast,
10
+ } from "@povio/ui/tanstack";
11
+ import { useQueryClient } from "@tanstack/react-query";
12
+ import { useNavigate } from "@tanstack/react-router";
13
+ import { useState } from "react";
14
+ import { useTranslation } from "react-i18next";
15
+ import { z } from "zod";
16
+
17
+ import { RowInputWrapper } from "@/components/shared/forms/RowInputWrapper";
18
+ import { CategoryQueries } from "@/openapi/category/category.queries";
19
+ import { ListingQueries } from "@/openapi/listing/listing.queries";
20
+ import type { ListingModels } from "@/openapi/listing/listing.models";
21
+ import { bindLocalListingImages, centsToDollarsInput, dollarsToCents } from "@/utils/listing-form";
22
+ import { createListing, updateListing } from "@/utils/listing-submit";
23
+
24
+ const listingFormSchema = z.object({
25
+ title: z.string().min(1),
26
+ description: z.string().optional(),
27
+ categoryId: z.string().min(1),
28
+ price: z.string().min(1),
29
+ quantity: z.string().min(1),
30
+ shipping: z.string().min(1),
31
+ shippingDays: z.string().min(1),
32
+ });
33
+
34
+ export interface ListingFormProps {
35
+ listing?: ListingModels.ListingDto;
36
+ onSuccess?: () => void | Promise<void>;
37
+ onCancel?: () => void;
38
+ }
39
+
40
+ export function ListingForm({ listing, onSuccess, onCancel }: ListingFormProps) {
41
+ const { t } = useTranslation();
42
+ const navigate = useNavigate();
43
+ const queryClient = useQueryClient();
44
+ const { successToast, errorToast } = useToast();
45
+ const { data: categories } = CategoryQueries.useGetAll();
46
+ const [pickedFiles, setPickedFiles] = useState<File[]>([]);
47
+ const [removedIds, setRemovedIds] = useState<string[]>([]);
48
+ const [isPending, setIsPending] = useState(false);
49
+ const remainingImages = (listing?.images ?? []).filter((image) => !removedIds.includes(image.id));
50
+
51
+ const form = useForm({
52
+ zodSchema: listingFormSchema,
53
+ defaultValues: {
54
+ title: listing?.title ?? "",
55
+ description: listing?.description ?? "",
56
+ categoryId: listing?.categoryId ?? "",
57
+ price: listing ? centsToDollarsInput(listing.priceCents) : "",
58
+ quantity: listing ? String(listing.quantity) : "1",
59
+ shipping: listing ? centsToDollarsInput(listing.shippingCents) : "",
60
+ shippingDays: listing ? String(listing.shippingDays) : "3",
61
+ },
62
+ });
63
+ const categoryId = useFormValue(form, (values) => values.categoryId);
64
+
65
+ const goToMyListings = () => {
66
+ void navigate({ to: "/my-listings" });
67
+ };
68
+
69
+ const onSubmit = async (data: z.infer<typeof listingFormSchema>) => {
70
+ try {
71
+ setIsPending(true);
72
+ const fields = {
73
+ title: data.title,
74
+ description: data.description,
75
+ categoryId: data.categoryId,
76
+ priceCents: dollarsToCents(data.price),
77
+ quantity: Number(data.quantity),
78
+ shippingCents: dollarsToCents(data.shipping),
79
+ shippingDays: Number(data.shippingDays),
80
+ files: pickedFiles,
81
+ removeImageIds: removedIds,
82
+ };
83
+ if (listing) {
84
+ await updateListing(listing.id, fields);
85
+ successToast({ text: t(($) => $.listingForm.updateSuccess) });
86
+ } else {
87
+ await createListing(fields);
88
+ successToast({ text: t(($) => $.listingForm.createSuccess) });
89
+ }
90
+ await queryClient.invalidateQueries({ queryKey: ListingQueries.keys.all });
91
+ if (onSuccess) {
92
+ await onSuccess();
93
+ return;
94
+ }
95
+ goToMyListings();
96
+ } catch {
97
+ errorToast({
98
+ text: listing ? t(($) => $.listingForm.updateError) : t(($) => $.listingForm.createError),
99
+ });
100
+ } finally {
101
+ setIsPending(false);
102
+ }
103
+ };
104
+
105
+ return (
106
+ <form
107
+ onSubmit={form.handleSubmit(onSubmit)}
108
+ className="flex w-full flex-col gap-4"
109
+ >
110
+ <RowInputWrapper
111
+ label={t(($) => $.listingForm.title)}
112
+ isRequired
113
+ >
114
+ <TextInput
115
+ variant="filled"
116
+ size="extra-small"
117
+ field={{ form, name: "title" }}
118
+ label={t(($) => $.listingForm.title)}
119
+ placeholder={t(($) => $.listingForm.titlePlaceholder)}
120
+ hideLabel
121
+ isRequired
122
+ />
123
+ </RowInputWrapper>
124
+
125
+ <RowInputWrapper label={t(($) => $.listingForm.description)}>
126
+ <TextArea
127
+ variant="filled"
128
+ size="extra-small"
129
+ field={{ form, name: "description" }}
130
+ label={t(($) => $.listingForm.description)}
131
+ placeholder={t(($) => $.listingForm.descriptionPlaceholder)}
132
+ hideLabel
133
+ />
134
+ </RowInputWrapper>
135
+
136
+ <RowInputWrapper
137
+ label={t(($) => $.listingForm.category)}
138
+ isRequired
139
+ >
140
+ <div className="flex flex-wrap gap-1">
141
+ {(categories ?? []).map((category) => (
142
+ <Button
143
+ key={category.id}
144
+ type="button"
145
+ size="xs"
146
+ variant={categoryId === category.id ? "contained" : "outlined"}
147
+ color={categoryId === category.id ? "primary" : "secondary"}
148
+ onPress={() => form.setFieldValue("categoryId", category.id)}
149
+ >
150
+ {category.name}
151
+ </Button>
152
+ ))}
153
+ </div>
154
+ </RowInputWrapper>
155
+
156
+ <RowInputWrapper
157
+ label={t(($) => $.listingForm.price)}
158
+ isRequired
159
+ >
160
+ <TextInput
161
+ variant="filled"
162
+ size="extra-small"
163
+ field={{ form, name: "price" }}
164
+ label={t(($) => $.listingForm.price)}
165
+ hideLabel
166
+ isRequired
167
+ />
168
+ </RowInputWrapper>
169
+
170
+ <RowInputWrapper
171
+ label={t(($) => $.listingForm.quantity)}
172
+ isRequired
173
+ >
174
+ <TextInput
175
+ variant="filled"
176
+ size="extra-small"
177
+ field={{ form, name: "quantity" }}
178
+ label={t(($) => $.listingForm.quantity)}
179
+ hideLabel
180
+ isRequired
181
+ />
182
+ </RowInputWrapper>
183
+
184
+ <RowInputWrapper
185
+ label={t(($) => $.listingForm.shipping)}
186
+ isRequired
187
+ >
188
+ <TextInput
189
+ variant="filled"
190
+ size="extra-small"
191
+ field={{ form, name: "shipping" }}
192
+ label={t(($) => $.listingForm.shipping)}
193
+ hideLabel
194
+ isRequired
195
+ />
196
+ </RowInputWrapper>
197
+
198
+ <RowInputWrapper
199
+ label={t(($) => $.listingForm.shippingDays)}
200
+ isRequired
201
+ >
202
+ <TextInput
203
+ variant="filled"
204
+ size="extra-small"
205
+ field={{ form, name: "shippingDays" }}
206
+ label={t(($) => $.listingForm.shippingDays)}
207
+ hideLabel
208
+ isRequired
209
+ />
210
+ </RowInputWrapper>
211
+
212
+ {remainingImages.length > 0 ? (
213
+ <div className="flex flex-wrap gap-2">
214
+ {remainingImages.map((image) => (
215
+ <div
216
+ key={image.id}
217
+ className="flex flex-col gap-1"
218
+ >
219
+ <img
220
+ src={image.url}
221
+ alt=""
222
+ className="h-20 w-20 object-cover"
223
+ />
224
+ <Button
225
+ type="button"
226
+ size="xs"
227
+ variant="outlined"
228
+ color="secondary"
229
+ onPress={() => setRemovedIds((current) => [...current, image.id])}
230
+ >
231
+ {t(($) => $.listingForm.removeImage)}
232
+ </Button>
233
+ </div>
234
+ ))}
235
+ </div>
236
+ ) : null}
237
+
238
+ {pickedFiles.length > 0 ? (
239
+ <Typography
240
+ size="label-3"
241
+ className="text-text-default-2"
242
+ >
243
+ {pickedFiles.map((file) => file.name).join(", ")}
244
+ </Typography>
245
+ ) : null}
246
+
247
+ <FileUpload
248
+ label={t(($) => $.listingForm.images)}
249
+ emptyText={t(($) => $.listingForm.imageEmptyText)}
250
+ uploadText={t(($) => $.listingForm.imageUploadText)}
251
+ browseText={t(($) => $.listingForm.imageBrowseText)}
252
+ acceptedFileTypes={["image/jpeg", "image/png", "image/webp"]}
253
+ allowsMultiple
254
+ {...bindLocalListingImages((file) => setPickedFiles((current) => [...current, file].slice(0, 8)))}
255
+ />
256
+
257
+ <div className="flex justify-end gap-2">
258
+ <Button
259
+ type="button"
260
+ variant="outlined"
261
+ color="secondary"
262
+ size="xs"
263
+ onPress={() => (onCancel ? onCancel() : goToMyListings())}
264
+ >
265
+ {t(($) => $.listingForm.cancel)}
266
+ </Button>
267
+ <Button
268
+ type="submit"
269
+ size="xs"
270
+ isLoading={isPending}
271
+ isDisabled={isPending}
272
+ >
273
+ {listing ? t(($) => $.listingForm.submitEdit) : t(($) => $.listingForm.submitCreate)}
274
+ </Button>
275
+ </div>
276
+ </form>
277
+ );
278
+ }