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,243 +1,27 @@
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
1
  import { useTranslation } from "react-i18next";
15
- import { z } from "zod";
16
2
 
17
- import { RowInputWrapper } from "@/components/shared/forms/RowInputWrapper";
18
3
  import { PageHeader } from "@/components/shared/page/PageHeader";
19
- import { CategoryQueries } from "@/openapi/category/category.queries";
20
- import { ListingQueries } from "@/openapi/listing/listing.queries";
21
4
  import type { ListingModels } from "@/openapi/listing/listing.models";
22
- import { bindLocalListingImages, centsToDollarsInput, dollarsToCents } from "@/utils/listing-form";
23
- import { createListing, updateListing } from "@/utils/listing-submit";
24
5
 
25
- const listingFormSchema = z.object({
26
- title: z.string().min(1),
27
- description: z.string().optional(),
28
- categoryId: z.string().min(1),
29
- price: z.string().min(1),
30
- quantity: z.string().min(1),
31
- shipping: z.string().min(1),
32
- shippingDays: z.string().min(1),
33
- });
6
+ import { ListingForm } from "./ListingForm";
34
7
 
35
8
  interface ListingFormPageProps {
36
- listing?: ListingModels.ListingDto;
9
+ listing: ListingModels.ListingDto;
37
10
  }
38
11
 
39
12
  export function ListingFormPage({ listing }: ListingFormPageProps) {
40
13
  const { t } = useTranslation();
41
- const navigate = useNavigate();
42
- const queryClient = useQueryClient();
43
- const { successToast, errorToast } = useToast();
44
- const { data: categories } = CategoryQueries.useGetAll();
45
- const [pickedFiles, setPickedFiles] = useState<File[]>([]);
46
- const [removedIds, setRemovedIds] = useState<string[]>([]);
47
- const [isPending, setIsPending] = useState(false);
48
- const remainingImages = (listing?.images ?? []).filter((image) => !removedIds.includes(image.id));
49
-
50
- const form = useForm({
51
- zodSchema: listingFormSchema,
52
- defaultValues: {
53
- title: listing?.title ?? "",
54
- description: listing?.description ?? "",
55
- categoryId: listing?.categoryId ?? "",
56
- price: listing ? centsToDollarsInput(listing.priceCents) : "",
57
- quantity: listing ? String(listing.quantity) : "1",
58
- shipping: listing ? centsToDollarsInput(listing.shippingCents) : "",
59
- shippingDays: listing ? String(listing.shippingDays) : "3",
60
- },
61
- });
62
- const categoryId = useFormValue(form, (values) => values.categoryId);
63
-
64
- const onSubmit = async (data: z.infer<typeof listingFormSchema>) => {
65
- try {
66
- setIsPending(true);
67
- const fields = {
68
- title: data.title,
69
- description: data.description,
70
- categoryId: data.categoryId,
71
- priceCents: dollarsToCents(data.price),
72
- quantity: Number(data.quantity),
73
- shippingCents: dollarsToCents(data.shipping),
74
- shippingDays: Number(data.shippingDays),
75
- files: pickedFiles,
76
- removeImageIds: removedIds,
77
- };
78
- if (listing) {
79
- await updateListing(listing.id, fields);
80
- successToast({ text: t(($) => $.listingForm.updateSuccess) });
81
- } else {
82
- await createListing(fields);
83
- successToast({ text: t(($) => $.listingForm.createSuccess) });
84
- }
85
- await queryClient.invalidateQueries({ queryKey: ListingQueries.keys.all });
86
- await navigate({ to: "/my-listings" });
87
- } catch {
88
- errorToast({
89
- text: listing ? t(($) => $.listingForm.updateError) : t(($) => $.listingForm.createError),
90
- });
91
- } finally {
92
- setIsPending(false);
93
- }
94
- };
95
14
 
96
15
  return (
97
16
  <div className="flex flex-col gap-6">
98
17
  <PageHeader
99
18
  enableBack
100
19
  backProps={{ backLink: { to: "/my-listings" } }}
101
- title={listing ? t(($) => $.listingForm.editTitle) : t(($) => $.listingForm.createTitle)}
20
+ title={t(($) => $.listingForm.editTitle)}
102
21
  />
103
-
104
- <form onSubmit={form.handleSubmit(onSubmit)} className="flex max-w-xl flex-col gap-4">
105
- <RowInputWrapper label={t(($) => $.listingForm.title)} isRequired>
106
- <TextInput
107
- variant="filled"
108
- size="extra-small"
109
- field={{ form, name: "title" }}
110
- label={t(($) => $.listingForm.title)}
111
- placeholder={t(($) => $.listingForm.titlePlaceholder)}
112
- hideLabel
113
- isRequired
114
- />
115
- </RowInputWrapper>
116
-
117
- <RowInputWrapper label={t(($) => $.listingForm.description)}>
118
- <TextArea
119
- variant="filled"
120
- size="extra-small"
121
- field={{ form, name: "description" }}
122
- label={t(($) => $.listingForm.description)}
123
- placeholder={t(($) => $.listingForm.descriptionPlaceholder)}
124
- hideLabel
125
- />
126
- </RowInputWrapper>
127
-
128
- <RowInputWrapper label={t(($) => $.listingForm.category)} isRequired>
129
- <div className="flex flex-wrap gap-1">
130
- {(categories ?? []).map((category) => (
131
- <Button
132
- key={category.id}
133
- type="button"
134
- size="xs"
135
- variant={categoryId === category.id ? "contained" : "outlined"}
136
- color={categoryId === category.id ? "primary" : "secondary"}
137
- onPress={() => form.setFieldValue("categoryId", category.id)}
138
- >
139
- {category.name}
140
- </Button>
141
- ))}
142
- </div>
143
- </RowInputWrapper>
144
-
145
- <RowInputWrapper label={t(($) => $.listingForm.price)} isRequired>
146
- <TextInput
147
- variant="filled"
148
- size="extra-small"
149
- field={{ form, name: "price" }}
150
- label={t(($) => $.listingForm.price)}
151
- hideLabel
152
- isRequired
153
- />
154
- </RowInputWrapper>
155
-
156
- <RowInputWrapper label={t(($) => $.listingForm.quantity)} isRequired>
157
- <TextInput
158
- variant="filled"
159
- size="extra-small"
160
- field={{ form, name: "quantity" }}
161
- label={t(($) => $.listingForm.quantity)}
162
- hideLabel
163
- isRequired
164
- />
165
- </RowInputWrapper>
166
-
167
- <RowInputWrapper label={t(($) => $.listingForm.shipping)} isRequired>
168
- <TextInput
169
- variant="filled"
170
- size="extra-small"
171
- field={{ form, name: "shipping" }}
172
- label={t(($) => $.listingForm.shipping)}
173
- hideLabel
174
- isRequired
175
- />
176
- </RowInputWrapper>
177
-
178
- <RowInputWrapper label={t(($) => $.listingForm.shippingDays)} isRequired>
179
- <TextInput
180
- variant="filled"
181
- size="extra-small"
182
- field={{ form, name: "shippingDays" }}
183
- label={t(($) => $.listingForm.shippingDays)}
184
- hideLabel
185
- isRequired
186
- />
187
- </RowInputWrapper>
188
-
189
- {remainingImages.length > 0 ? (
190
- <div className="flex flex-wrap gap-2">
191
- {remainingImages.map((image) => (
192
- <div key={image.id} className="flex flex-col gap-1">
193
- <img src={image.url} alt="" className="h-20 w-20 object-cover" />
194
- <Button
195
- type="button"
196
- size="xs"
197
- variant="outlined"
198
- color="secondary"
199
- onPress={() => setRemovedIds((current) => [...current, image.id])}
200
- >
201
- {t(($) => $.listingForm.removeImage)}
202
- </Button>
203
- </div>
204
- ))}
205
- </div>
206
- ) : null}
207
-
208
- {pickedFiles.length > 0 ? (
209
- <Typography size="label-3" className="text-text-default-2">
210
- {pickedFiles.map((file) => file.name).join(", ")}
211
- </Typography>
212
- ) : null}
213
-
214
- <FileUpload
215
- label={t(($) => $.listingForm.images)}
216
- emptyText={t(($) => $.listingForm.imageEmptyText)}
217
- uploadText={t(($) => $.listingForm.imageUploadText)}
218
- browseText={t(($) => $.listingForm.imageBrowseText)}
219
- acceptedFileTypes={["image/jpeg", "image/png", "image/webp"]}
220
- allowsMultiple
221
- {...bindLocalListingImages((file) =>
222
- setPickedFiles((current) => [...current, file].slice(0, 8)),
223
- )}
224
- />
225
-
226
- <div className="flex justify-end gap-2">
227
- <Button
228
- type="button"
229
- variant="outlined"
230
- color="secondary"
231
- size="xs"
232
- onPress={() => navigate({ to: "/my-listings" })}
233
- >
234
- {t(($) => $.listingForm.cancel)}
235
- </Button>
236
- <Button type="submit" size="xs" isLoading={isPending} isDisabled={isPending}>
237
- {listing ? t(($) => $.listingForm.submitEdit) : t(($) => $.listingForm.submitCreate)}
238
- </Button>
239
- </div>
240
- </form>
22
+ <div className="max-w-xl">
23
+ <ListingForm listing={listing} />
24
+ </div>
241
25
  </div>
242
26
  );
243
27
  }
@@ -0,0 +1,54 @@
1
+ import { Button } from "@povio/ui";
2
+ import { useState } from "react";
3
+ import { useTranslation } from "react-i18next";
4
+
5
+ import type { ListingModels } from "@/openapi/listing/listing.models";
6
+ import { getFallbackImageUrl } from "@/utils/image-fallback";
7
+
8
+ interface ListingGalleryProps {
9
+ images: ListingModels.ListingDto["images"];
10
+ }
11
+
12
+ export function ListingGallery({ images }: ListingGalleryProps) {
13
+ const { t } = useTranslation();
14
+ const [activeIndex, setActiveIndex] = useState(0);
15
+ const fallback = getFallbackImageUrl({ width: 800, height: 600 });
16
+ const active = images[activeIndex] ?? images[0];
17
+ const imageSrc = active?.url ?? fallback;
18
+
19
+ return (
20
+ <div className="flex flex-col gap-3">
21
+ <div className="aspect-[4/3] w-full overflow-hidden rounded-m border border-elevation-outline-default-1 bg-elevation-fill-default-2">
22
+ <img
23
+ src={imageSrc}
24
+ alt=""
25
+ className="h-full w-full object-cover"
26
+ />
27
+ </div>
28
+ {images.length > 1 ? (
29
+ <div
30
+ className="flex flex-wrap gap-2"
31
+ aria-label={t(($) => $.listing.images)}
32
+ >
33
+ {images.map((image, index) => (
34
+ <Button
35
+ key={image.id}
36
+ type="button"
37
+ size="xs"
38
+ variant={index === activeIndex ? "contained" : "outlined"}
39
+ color={index === activeIndex ? "primary" : "secondary"}
40
+ onPress={() => setActiveIndex(index)}
41
+ className="h-16 min-h-16 w-16 min-w-16 overflow-hidden p-0"
42
+ >
43
+ <img
44
+ src={image.url}
45
+ alt=""
46
+ className="h-full w-full object-cover"
47
+ />
48
+ </Button>
49
+ ))}
50
+ </div>
51
+ ) : null}
52
+ </div>
53
+ );
54
+ }
@@ -1,22 +1,40 @@
1
- import { Button, Typography, useToast } from "@povio/ui/tanstack";
1
+ import { Drawer, Link, Tag, Typography } from "@povio/ui";
2
+ import { Button, useToast } from "@povio/ui/tanstack";
2
3
  import { useQueryClient } from "@tanstack/react-query";
4
+ import { useNavigate } from "@tanstack/react-router";
3
5
  import { Plus } from "lucide-react";
6
+ import { useEffect, useState } from "react";
4
7
  import { useTranslation } from "react-i18next";
5
8
 
6
9
  import { LoadingState } from "@/components/shared/layout/LoadingState";
7
10
  import { PageHeader } from "@/components/shared/page/PageHeader";
8
11
  import { ListingQueries } from "@/openapi/listing/listing.queries";
12
+ import { getFallbackImageUrl } from "@/utils/image-fallback";
9
13
  import { formatCents } from "@/utils/listing-form";
10
14
 
11
- import { ListingCard } from "./ListingCard";
15
+ import { ListingForm } from "./ListingForm";
12
16
 
13
- export function MyListingsPage() {
17
+ interface MyListingsPageProps {
18
+ openCreate?: boolean;
19
+ }
20
+
21
+ export function MyListingsPage({ openCreate = false }: MyListingsPageProps) {
14
22
  const { t } = useTranslation();
23
+ const navigate = useNavigate();
15
24
  const queryClient = useQueryClient();
16
25
  const { successToast, errorToast } = useToast();
17
26
  const { data, isLoading } = ListingQueries.useGetAll({ mine: "true" });
18
27
  const deactivate = ListingQueries.useDeleteApiListingsById();
19
28
  const listings = data ?? [];
29
+ const [isCreateOpen, setIsCreateOpen] = useState(openCreate);
30
+
31
+ useEffect(() => {
32
+ if (!openCreate) {
33
+ return;
34
+ }
35
+ setIsCreateOpen(true);
36
+ void navigate({ to: "/my-listings", search: {}, replace: true });
37
+ }, [openCreate, navigate]);
20
38
 
21
39
  const onDeactivate = async (id: string) => {
22
40
  try {
@@ -33,50 +51,123 @@ export function MyListingsPage() {
33
51
  <PageHeader
34
52
  title={t(($) => $.sell.title)}
35
53
  actions={
36
- <Button size="xs" icon={Plus} link={{ to: "/sell" }}>
37
- {t(($) => $.sell.create)}
38
- </Button>
54
+ <Drawer
55
+ label={t(($) => $.listingForm.createTitle)}
56
+ isOpen={isCreateOpen}
57
+ onOpenChange={setIsCreateOpen}
58
+ className="w-full max-w-xl overflow-y-auto"
59
+ trigger={
60
+ <Button
61
+ size="xs"
62
+ icon={Plus}
63
+ >
64
+ {t(($) => $.sell.create)}
65
+ </Button>
66
+ }
67
+ >
68
+ {(close) => (
69
+ <div className="flex flex-col gap-4 p-6">
70
+ <Typography
71
+ size="title-5"
72
+ variant="prominent-1"
73
+ >
74
+ {t(($) => $.listingForm.createTitle)}
75
+ </Typography>
76
+ <ListingForm
77
+ onSuccess={() => close()}
78
+ onCancel={close}
79
+ />
80
+ </div>
81
+ )}
82
+ </Drawer>
39
83
  }
40
84
  />
41
85
 
42
86
  {isLoading ? (
43
87
  <LoadingState />
44
88
  ) : listings.length === 0 ? (
45
- <Typography size="body-3" className="text-text-default-2">
46
- {t(($) => $.sell.empty)}
47
- </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>
48
104
  ) : (
49
105
  <div className="flex flex-col gap-4">
50
- {listings.map((listing) => (
51
- <div key={listing.id} className="flex flex-col gap-2">
52
- <ListingCard listing={listing} />
53
- <div className="flex flex-wrap items-center gap-2">
54
- <Typography size="label-3" className="text-text-default-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>
133
+ <Typography
134
+ size="body-3"
135
+ className="text-text-default-1"
136
+ >
55
137
  {formatCents(listing.priceCents)}
56
- {listing.quantity <= 0 ? ` · ${t(($) => $.sell.soldOut)}` : ""}
57
- {listing.status === "inactive" ? ` · ${t(($) => $.sell.inactive)}` : ""}
58
138
  </Typography>
59
- <Button
60
- size="xs"
61
- variant="outlined"
62
- color="secondary"
63
- link={{ to: "/sell/$id", params: { id: listing.id } }}
139
+ <Typography
140
+ size="body-3"
141
+ className="text-text-default-2"
64
142
  >
65
- {t(($) => $.sell.edit)}
66
- </Button>
67
- {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">
68
149
  <Button
69
150
  size="xs"
70
151
  variant="outlined"
71
152
  color="secondary"
72
- onPress={() => void onDeactivate(listing.id)}
153
+ link={{ to: "/sell/$id", params: { id: listing.id } }}
73
154
  >
74
- {t(($) => $.sell.deactivate)}
155
+ {t(($) => $.sell.edit)}
75
156
  </Button>
76
- ) : 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>
77
168
  </div>
78
- </div>
79
- ))}
169
+ );
170
+ })}
80
171
  </div>
81
172
  )}
82
173
  </div>
@@ -0,0 +1,24 @@
1
+ import { Tag } from "@povio/ui";
2
+ import { useTranslation } from "react-i18next";
3
+
4
+ type OrderStatus = "paid" | "shipped" | "completed";
5
+
6
+ const STATUS_COLOR: Record<OrderStatus, "warning" | "primary" | "success"> = {
7
+ paid: "warning",
8
+ shipped: "primary",
9
+ completed: "success",
10
+ };
11
+
12
+ interface OrderStatusTagProps {
13
+ status: string;
14
+ }
15
+
16
+ export function OrderStatusTag({ status }: OrderStatusTagProps) {
17
+ const { t } = useTranslation();
18
+ const known = status === "paid" || status === "shipped" || status === "completed" ? status : null;
19
+ if (!known) {
20
+ return null;
21
+ }
22
+
23
+ return <Tag color={STATUS_COLOR[known]}>{t(($) => $.orders.status[known])}</Tag>;
24
+ }
@@ -0,0 +1,39 @@
1
+ import { Button, Typography } from "@povio/ui/tanstack";
2
+ import { useTranslation } from "react-i18next";
3
+
4
+ import { Card } from "@/components/shared/ui/Card";
5
+ import type { OrderModels } from "@/openapi/order/order.models";
6
+ import { formatCents } from "@/utils/listing-form";
7
+
8
+ import { OrderStatusTag } from "./OrderStatusTag";
9
+
10
+ interface OrderSummaryCardProps {
11
+ order: OrderModels.OrderDto;
12
+ }
13
+
14
+ export function OrderSummaryCard({ order }: OrderSummaryCardProps) {
15
+ const { t } = useTranslation();
16
+
17
+ return (
18
+ <Card>
19
+ <Typography size="title-5">{order.items.map((item) => item.title).join(", ")}</Typography>
20
+ <div className="flex flex-wrap items-center gap-2">
21
+ <OrderStatusTag status={order.status} />
22
+ <Typography
23
+ size="label-3"
24
+ className="text-text-default-2"
25
+ >
26
+ {formatCents(order.totalCents)}
27
+ </Typography>
28
+ </div>
29
+ <Button
30
+ size="xs"
31
+ variant="outlined"
32
+ color="secondary"
33
+ link={{ to: "/orders/$id", params: { id: order.id } }}
34
+ >
35
+ {t(($) => $.orders.view)}
36
+ </Button>
37
+ </Card>
38
+ );
39
+ }