robodev 0.23.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 (181) hide show
  1. package/package.json +1 -1
  2. package/src/emit-starter.test.ts +36 -0
  3. package/templates/auth-chat/package.json +1 -1
  4. package/templates/backend/package.json +1 -1
  5. package/templates/catalog.json +5 -0
  6. package/templates/empty/package.json +1 -1
  7. package/templates/marketplace/.config/local.spa.template.yml +27 -0
  8. package/templates/marketplace/.oxlint-base.json +29 -0
  9. package/templates/marketplace/.oxlintrc.json +78 -0
  10. package/templates/marketplace/.rulesync/rules/frontend-api-boundary.md +36 -0
  11. package/templates/marketplace/.rulesync/rules/frontend-component-structure.md +38 -0
  12. package/templates/marketplace/.rulesync/rules/frontend-forms.md +32 -0
  13. package/templates/marketplace/.rulesync/rules/frontend-notifications.md +24 -0
  14. package/templates/marketplace/.rulesync/rules/frontend-povio-components.md +42 -0
  15. package/templates/marketplace/.rulesync/rules/frontend-povio-ui.md +45 -0
  16. package/templates/marketplace/.rulesync/rules/frontend-query-autocomplete.md +29 -0
  17. package/templates/marketplace/.rulesync/rules/frontend-tables-and-lists.md +34 -0
  18. package/templates/marketplace/.rulesync/rules/frontend-translations.md +26 -0
  19. package/templates/marketplace/.rulesync/rules/project-overview.md +32 -0
  20. package/templates/marketplace/.rulesync/rules/robodev-api.md +30 -0
  21. package/templates/marketplace/.rulesync/rules/robodev-auth.md +40 -0
  22. package/templates/marketplace/.rulesync/rules/robodev-database.md +22 -0
  23. package/templates/marketplace/.rulesync/rules/role-based-app-structure.md +36 -0
  24. package/templates/marketplace/.rulesync/skills/media-feature/SKILL.md +21 -0
  25. package/templates/marketplace/.rulesync/skills/povio-ui-styling/SKILL.md +208 -0
  26. package/templates/marketplace/.rulesync/skills/povio-ui-styling/agents/openai.yaml +4 -0
  27. package/templates/marketplace/.rulesync/skills/robodev-api-route/SKILL.md +13 -0
  28. package/templates/marketplace/.rulesync/skills/robodev-table/SKILL.md +12 -0
  29. package/templates/marketplace/README.md +40 -0
  30. package/templates/marketplace/api/_lib.seed.test.ts +10 -0
  31. package/templates/marketplace/api/_lib.ts +734 -0
  32. package/templates/marketplace/api/cart/[id].ts +51 -0
  33. package/templates/marketplace/api/cart.ts +70 -0
  34. package/templates/marketplace/api/categories.ts +11 -0
  35. package/templates/marketplace/api/checkout.ts +157 -0
  36. package/templates/marketplace/api/health.ts +7 -0
  37. package/templates/marketplace/api/listings/[id].ts +101 -0
  38. package/templates/marketplace/api/listings/paginate.ts +29 -0
  39. package/templates/marketplace/api/listings.ts +90 -0
  40. package/templates/marketplace/api/me.ts +14 -0
  41. package/templates/marketplace/api/orders/[id]/complete.ts +27 -0
  42. package/templates/marketplace/api/orders/[id]/ship.ts +24 -0
  43. package/templates/marketplace/api/orders/[id].ts +15 -0
  44. package/templates/marketplace/api/purchases.ts +19 -0
  45. package/templates/marketplace/api/reviews.ts +58 -0
  46. package/templates/marketplace/api/sales.ts +19 -0
  47. package/templates/marketplace/api/watches/[listingId].ts +18 -0
  48. package/templates/marketplace/api/watches.ts +51 -0
  49. package/templates/marketplace/apps/fe/index.html +24 -0
  50. package/templates/marketplace/apps/fe/openapi-codegen.config.ts +72 -0
  51. package/templates/marketplace/apps/fe/package.json +84 -0
  52. package/templates/marketplace/apps/fe/public/apple-touch-icon.png +0 -0
  53. package/templates/marketplace/apps/fe/public/favicon-96x96.png +0 -0
  54. package/templates/marketplace/apps/fe/public/favicon.ico +0 -0
  55. package/templates/marketplace/apps/fe/public/favicon.svg +3 -0
  56. package/templates/marketplace/apps/fe/public/site.webmanifest +21 -0
  57. package/templates/marketplace/apps/fe/public/web-app-manifest-192x192.png +0 -0
  58. package/templates/marketplace/apps/fe/public/web-app-manifest-512x512.png +0 -0
  59. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Bold.otf +0 -0
  60. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Medium.otf +0 -0
  61. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Regular.otf +0 -0
  62. package/templates/marketplace/apps/fe/src/assets/fonts/GeneralSans-Semibold.otf +0 -0
  63. package/templates/marketplace/apps/fe/src/assets/locales/en/translation.json +336 -0
  64. package/templates/marketplace/apps/fe/src/assets/locales/sl/translation.json +336 -0
  65. package/templates/marketplace/apps/fe/src/clients/app-rest-client.ts +15 -0
  66. package/templates/marketplace/apps/fe/src/clients/auth-token-store.ts +101 -0
  67. package/templates/marketplace/apps/fe/src/clients/rest/app-error-handler.ts +31 -0
  68. package/templates/marketplace/apps/fe/src/clients/rest/interceptors/authorization-header.interceptor.ts +15 -0
  69. package/templates/marketplace/apps/fe/src/clients/rest/interceptors/refresh-token.interceptor.ts +37 -0
  70. package/templates/marketplace/apps/fe/src/clients/rest/interceptors/response.interceptor.ts +17 -0
  71. package/templates/marketplace/apps/fe/src/components/404.tsx +18 -0
  72. package/templates/marketplace/apps/fe/src/components/features/auth/AuthBrandPanel.tsx +60 -0
  73. package/templates/marketplace/apps/fe/src/components/features/auth/AuthLayout.tsx +23 -0
  74. package/templates/marketplace/apps/fe/src/components/features/auth/LoginPage.tsx +111 -0
  75. package/templates/marketplace/apps/fe/src/components/features/auth/RegisterPage.tsx +143 -0
  76. package/templates/marketplace/apps/fe/src/components/features/marketplace/BrowsePage.tsx +76 -0
  77. package/templates/marketplace/apps/fe/src/components/features/marketplace/CartPage.tsx +88 -0
  78. package/templates/marketplace/apps/fe/src/components/features/marketplace/CheckoutPage.tsx +193 -0
  79. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingCard.tsx +50 -0
  80. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingDetailsPage.tsx +194 -0
  81. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingForm.tsx +278 -0
  82. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingFormPage.tsx +27 -0
  83. package/templates/marketplace/apps/fe/src/components/features/marketplace/ListingGallery.tsx +54 -0
  84. package/templates/marketplace/apps/fe/src/components/features/marketplace/MyListingsPage.tsx +137 -0
  85. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrderStatusTag.tsx +24 -0
  86. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrderSummaryCard.tsx +39 -0
  87. package/templates/marketplace/apps/fe/src/components/features/marketplace/OrdersPage.tsx +236 -0
  88. package/templates/marketplace/apps/fe/src/components/features/marketplace/WatchlistPage.tsx +56 -0
  89. package/templates/marketplace/apps/fe/src/components/features/profile/ProfilePage.tsx +96 -0
  90. package/templates/marketplace/apps/fe/src/components/googleAnalytics/GoogleAnalytics.tsx +35 -0
  91. package/templates/marketplace/apps/fe/src/components/layout/AppLayout.tsx +19 -0
  92. package/templates/marketplace/apps/fe/src/components/layout/app-header/AppHeader.tsx +95 -0
  93. package/templates/marketplace/apps/fe/src/components/layout/app-header/MobileNavigation.tsx +55 -0
  94. package/templates/marketplace/apps/fe/src/components/layout/app-header/NavLink.tsx +29 -0
  95. package/templates/marketplace/apps/fe/src/components/shared/branding/BrandLogo.tsx +13 -0
  96. package/templates/marketplace/apps/fe/src/components/shared/error/ErrorFallback.tsx +43 -0
  97. package/templates/marketplace/apps/fe/src/components/shared/error/ErrorText.tsx +20 -0
  98. package/templates/marketplace/apps/fe/src/components/shared/error/NotFound.tsx +36 -0
  99. package/templates/marketplace/apps/fe/src/components/shared/forms/RequiredLabel.tsx +21 -0
  100. package/templates/marketplace/apps/fe/src/components/shared/forms/RowInputWrapper.tsx +50 -0
  101. package/templates/marketplace/apps/fe/src/components/shared/head/AppHead.tsx +32 -0
  102. package/templates/marketplace/apps/fe/src/components/shared/head/DefaultAppHead.tsx +34 -0
  103. package/templates/marketplace/apps/fe/src/components/shared/layout/LoadingState.tsx +9 -0
  104. package/templates/marketplace/apps/fe/src/components/shared/layout/ThinPageWrapper.tsx +5 -0
  105. package/templates/marketplace/apps/fe/src/components/shared/page/PageHeader.tsx +69 -0
  106. package/templates/marketplace/apps/fe/src/components/shared/ui/Card.tsx +60 -0
  107. package/templates/marketplace/apps/fe/src/components/shared/ui/GoogleLoginButton.tsx +19 -0
  108. package/templates/marketplace/apps/fe/src/components/shared/ui/RequiredLabel.tsx +22 -0
  109. package/templates/marketplace/apps/fe/src/components/shared/ui/TableActions.tsx +22 -0
  110. package/templates/marketplace/apps/fe/src/config/app.config.ts +35 -0
  111. package/templates/marketplace/apps/fe/src/config/i18n.ts +43 -0
  112. package/templates/marketplace/apps/fe/src/config/inits/a11y.ts +14 -0
  113. package/templates/marketplace/apps/fe/src/config/inits/logger.ts +7 -0
  114. package/templates/marketplace/apps/fe/src/config/inits/sentry.ts +21 -0
  115. package/templates/marketplace/apps/fe/src/config/jwt.config.ts +2 -0
  116. package/templates/marketplace/apps/fe/src/config/query.config.ts +20 -0
  117. package/templates/marketplace/apps/fe/src/hooks/useAuth.ts +5 -0
  118. package/templates/marketplace/apps/fe/src/main.tsx +52 -0
  119. package/templates/marketplace/apps/fe/src/pages/(guest)/login.tsx +23 -0
  120. package/templates/marketplace/apps/fe/src/pages/(guest)/register.tsx +23 -0
  121. package/templates/marketplace/apps/fe/src/pages/(guest)/route.tsx +18 -0
  122. package/templates/marketplace/apps/fe/src/pages/(private)/cart.tsx +22 -0
  123. package/templates/marketplace/apps/fe/src/pages/(private)/checkout.tsx +22 -0
  124. package/templates/marketplace/apps/fe/src/pages/(private)/index.tsx +22 -0
  125. package/templates/marketplace/apps/fe/src/pages/(private)/listings/$id/index.tsx +37 -0
  126. package/templates/marketplace/apps/fe/src/pages/(private)/my-listings.tsx +32 -0
  127. package/templates/marketplace/apps/fe/src/pages/(private)/orders/$id.tsx +40 -0
  128. package/templates/marketplace/apps/fe/src/pages/(private)/orders/index.tsx +22 -0
  129. package/templates/marketplace/apps/fe/src/pages/(private)/profile.tsx +23 -0
  130. package/templates/marketplace/apps/fe/src/pages/(private)/route.tsx +18 -0
  131. package/templates/marketplace/apps/fe/src/pages/(private)/sales.tsx +22 -0
  132. package/templates/marketplace/apps/fe/src/pages/(private)/sell/$id.tsx +44 -0
  133. package/templates/marketplace/apps/fe/src/pages/(private)/sell/index.tsx +15 -0
  134. package/templates/marketplace/apps/fe/src/pages/(private)/watchlist.tsx +22 -0
  135. package/templates/marketplace/apps/fe/src/pages/(public)/auth.tsx +37 -0
  136. package/templates/marketplace/apps/fe/src/pages/(public)/route.tsx +9 -0
  137. package/templates/marketplace/apps/fe/src/pages/__root.tsx +164 -0
  138. package/templates/marketplace/apps/fe/src/providers/AppErrorBoundary.tsx +10 -0
  139. package/templates/marketplace/apps/fe/src/providers/OpenApiRuntimeProvider.tsx +31 -0
  140. package/templates/marketplace/apps/fe/src/providers/index.tsx +44 -0
  141. package/templates/marketplace/apps/fe/src/providers/jwt.provider.tsx +95 -0
  142. package/templates/marketplace/apps/fe/src/routeTree.gen.ts +435 -0
  143. package/templates/marketplace/apps/fe/src/styles/base.css +103 -0
  144. package/templates/marketplace/apps/fe/src/styles/fonts/fonts.tsx +10 -0
  145. package/templates/marketplace/apps/fe/src/styles/fonts/general-sans.css +31 -0
  146. package/templates/marketplace/apps/fe/src/styles/globals.css +28 -0
  147. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/button.override.ts +536 -0
  148. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/checkbox.override.ts +71 -0
  149. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/input.override.ts +252 -0
  150. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/label.override.ts +91 -0
  151. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/modal.override.ts +57 -0
  152. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/radio.override.ts +46 -0
  153. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/table.override.ts +104 -0
  154. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/tag.override.ts +66 -0
  155. package/templates/marketplace/apps/fe/src/styles/overrides/defaults/typography.override.ts +115 -0
  156. package/templates/marketplace/apps/fe/src/styles/overrides/outline.clsx.ts +10 -0
  157. package/templates/marketplace/apps/fe/src/styles/overrides/uiOverrides.override.ts +60 -0
  158. package/templates/marketplace/apps/fe/src/styles/theme.css +2177 -0
  159. package/templates/marketplace/apps/fe/src/types/i18next.d.ts +12 -0
  160. package/templates/marketplace/apps/fe/src/types/table.d.ts +17 -0
  161. package/templates/marketplace/apps/fe/src/types/ui.d.ts +26 -0
  162. package/templates/marketplace/apps/fe/src/types/vite-env.d.ts +20 -0
  163. package/templates/marketplace/apps/fe/src/utils/date.utils.ts +17 -0
  164. package/templates/marketplace/apps/fe/src/utils/image-fallback.ts +9 -0
  165. package/templates/marketplace/apps/fe/src/utils/listing-form.test.ts +69 -0
  166. package/templates/marketplace/apps/fe/src/utils/listing-form.ts +77 -0
  167. package/templates/marketplace/apps/fe/src/utils/listing-submit.ts +29 -0
  168. package/templates/marketplace/apps/fe/src/utils/number.utils.ts +12 -0
  169. package/templates/marketplace/apps/fe/src/utils/string.utils.ts +5 -0
  170. package/templates/marketplace/apps/fe/src/vite-env.d.ts +1 -0
  171. package/templates/marketplace/apps/fe/tsconfig.app.json +32 -0
  172. package/templates/marketplace/apps/fe/tsconfig.json +9 -0
  173. package/templates/marketplace/apps/fe/tsconfig.node.json +31 -0
  174. package/templates/marketplace/apps/fe/vite.config.ts +125 -0
  175. package/templates/marketplace/database.ts +154 -0
  176. package/templates/marketplace/openapi.json +2303 -0
  177. package/templates/marketplace/oxfmt.config.js +23 -0
  178. package/templates/marketplace/package.json +18 -0
  179. package/templates/marketplace/rulesync.jsonc +18 -0
  180. package/templates/marketplace/tsconfig.json +11 -0
  181. package/templates/space/package.json +1 -1
@@ -0,0 +1,194 @@
1
+ import { Button, IconButton, TextInput, Typography, useToast } from "@povio/ui/tanstack";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import { Heart } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { useTranslation } from "react-i18next";
6
+
7
+ import { PageHeader } from "@/components/shared/page/PageHeader";
8
+ import { Card } from "@/components/shared/ui/Card";
9
+ import { useAuth } from "@/hooks/useAuth";
10
+ import { CartQueries } from "@/openapi/cart/cart.queries";
11
+ import { ListingQueries } from "@/openapi/listing/listing.queries";
12
+ import type { ListingModels } from "@/openapi/listing/listing.models";
13
+ import { ReviewQueries } from "@/openapi/review/review.queries";
14
+ import { WatchQueries } from "@/openapi/watch/watch.queries";
15
+ import { formatCents } from "@/utils/listing-form";
16
+
17
+ import { ListingGallery } from "./ListingGallery";
18
+
19
+ interface ListingDetailsPageProps {
20
+ listing: ListingModels.ListingDto;
21
+ }
22
+
23
+ export function ListingDetailsPage({ listing }: ListingDetailsPageProps) {
24
+ const { t } = useTranslation();
25
+ const { user } = useAuth();
26
+ const queryClient = useQueryClient();
27
+ const { successToast, errorToast } = useToast();
28
+ const [qty, setQty] = useState("1");
29
+ const addToCart = CartQueries.useCreate();
30
+ const watch = WatchQueries.useCreate();
31
+ const unwatch = WatchQueries.useDeleteApiWatchesByListingId();
32
+ const { data: reviews } = ReviewQueries.useGetAll({ listingId: listing.id });
33
+ const isOwn = user?.id === listing.sellerId;
34
+ const canBuy = !isOwn && listing.status === "active" && listing.quantity > 0;
35
+
36
+ const onAddToCart = async () => {
37
+ try {
38
+ await addToCart.mutateAsync({ data: { listingId: listing.id, quantity: Number(qty) || 1 } });
39
+ await queryClient.invalidateQueries({ queryKey: CartQueries.keys.all });
40
+ successToast({ text: t(($) => $.listing.addedToCart) });
41
+ } catch {
42
+ errorToast({ text: t(($) => $.listing.addToCartError) });
43
+ }
44
+ };
45
+
46
+ const onToggleWatch = async () => {
47
+ try {
48
+ if (listing.watchedByMe) {
49
+ await unwatch.mutateAsync({ listingId: listing.id });
50
+ } else {
51
+ await watch.mutateAsync({ data: { listingId: listing.id } });
52
+ }
53
+ await queryClient.invalidateQueries({ queryKey: ListingQueries.keys.all });
54
+ await queryClient.invalidateQueries({ queryKey: WatchQueries.keys.all });
55
+ } catch {
56
+ errorToast({ text: t(($) => $.listing.watchError) });
57
+ }
58
+ };
59
+
60
+ return (
61
+ <div className="flex flex-col gap-6">
62
+ <PageHeader
63
+ enableBack
64
+ backProps={{ backLink: { to: "/" } }}
65
+ title={listing.title}
66
+ />
67
+
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)}
78
+ </Typography>
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
+ >
118
+ {t(($) => $.listing.seller)}: {listing.sellerName || listing.sellerEmail}
119
+ </Typography>
120
+ {listing.category ? (
121
+ <Typography
122
+ size="label-3"
123
+ className="text-text-default-2"
124
+ >
125
+ {t(($) => $.listing.category)}: {listing.category.name}
126
+ </Typography>
127
+ ) : null}
128
+ <Typography
129
+ size="label-3"
130
+ className="text-text-default-2"
131
+ >
132
+ {listing.sellerAverageRating != null
133
+ ? t(($) => $.listing.rating, { rating: listing.sellerAverageRating })
134
+ : t(($) => $.listing.noRating)}
135
+ </Typography>
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
+
146
+ {isOwn ? <Typography size="body-3">{t(($) => $.listing.ownListing)}</Typography> : null}
147
+
148
+ {canBuy ? (
149
+ <div className="flex flex-wrap items-end gap-2">
150
+ <TextInput
151
+ variant="filled"
152
+ size="extra-small"
153
+ value={qty}
154
+ onChange={setQty}
155
+ label={t(($) => $.listing.quantity)}
156
+ />
157
+ <Button
158
+ size="xs"
159
+ isLoading={addToCart.isPending}
160
+ onPress={() => void onAddToCart()}
161
+ >
162
+ {t(($) => $.listing.addToCart)}
163
+ </Button>
164
+ </div>
165
+ ) : null}
166
+ </Card>
167
+ </div>
168
+
169
+ <div className="flex flex-col gap-3">
170
+ <Typography
171
+ size="title-5"
172
+ variant="prominent-1"
173
+ >
174
+ {t(($) => $.listing.reviews)}
175
+ </Typography>
176
+ {!reviews || reviews.length === 0 ? (
177
+ <Typography
178
+ size="body-3"
179
+ className="text-text-default-2"
180
+ >
181
+ {t(($) => $.listing.noReviews)}
182
+ </Typography>
183
+ ) : (
184
+ reviews.map((review) => (
185
+ <Card key={review.id}>
186
+ <Typography size="label-3">{review.rating}/5</Typography>
187
+ {review.body ? <Typography size="body-3">{review.body}</Typography> : null}
188
+ </Card>
189
+ ))
190
+ )}
191
+ </div>
192
+ </div>
193
+ );
194
+ }
@@ -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
+ }
@@ -0,0 +1,27 @@
1
+ import { useTranslation } from "react-i18next";
2
+
3
+ import { PageHeader } from "@/components/shared/page/PageHeader";
4
+ import type { ListingModels } from "@/openapi/listing/listing.models";
5
+
6
+ import { ListingForm } from "./ListingForm";
7
+
8
+ interface ListingFormPageProps {
9
+ listing: ListingModels.ListingDto;
10
+ }
11
+
12
+ export function ListingFormPage({ listing }: ListingFormPageProps) {
13
+ const { t } = useTranslation();
14
+
15
+ return (
16
+ <div className="flex flex-col gap-6">
17
+ <PageHeader
18
+ enableBack
19
+ backProps={{ backLink: { to: "/my-listings" } }}
20
+ title={t(($) => $.listingForm.editTitle)}
21
+ />
22
+ <div className="max-w-xl">
23
+ <ListingForm listing={listing} />
24
+ </div>
25
+ </div>
26
+ );
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
+ }
@@ -0,0 +1,137 @@
1
+ import { Drawer, Typography } from "@povio/ui";
2
+ import { Button, useToast } from "@povio/ui/tanstack";
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+ import { useNavigate } from "@tanstack/react-router";
5
+ import { Plus } from "lucide-react";
6
+ import { useEffect, useState } from "react";
7
+ import { useTranslation } from "react-i18next";
8
+
9
+ import { LoadingState } from "@/components/shared/layout/LoadingState";
10
+ import { PageHeader } from "@/components/shared/page/PageHeader";
11
+ import { ListingQueries } from "@/openapi/listing/listing.queries";
12
+ import { formatCents } from "@/utils/listing-form";
13
+
14
+ import { ListingCard } from "./ListingCard";
15
+ import { ListingForm } from "./ListingForm";
16
+
17
+ interface MyListingsPageProps {
18
+ openCreate?: boolean;
19
+ }
20
+
21
+ export function MyListingsPage({ openCreate = false }: MyListingsPageProps) {
22
+ const { t } = useTranslation();
23
+ const navigate = useNavigate();
24
+ const queryClient = useQueryClient();
25
+ const { successToast, errorToast } = useToast();
26
+ const { data, isLoading } = ListingQueries.useGetAll({ mine: "true" });
27
+ const deactivate = ListingQueries.useDeleteApiListingsById();
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]);
38
+
39
+ const onDeactivate = async (id: string) => {
40
+ try {
41
+ await deactivate.mutateAsync({ id });
42
+ await queryClient.invalidateQueries({ queryKey: ListingQueries.keys.all });
43
+ successToast({ text: t(($) => $.sell.deactivateSuccess) });
44
+ } catch {
45
+ errorToast({ text: t(($) => $.sell.deactivateError) });
46
+ }
47
+ };
48
+
49
+ return (
50
+ <div className="flex flex-col gap-6">
51
+ <PageHeader
52
+ title={t(($) => $.sell.title)}
53
+ actions={
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>
83
+ }
84
+ />
85
+
86
+ {isLoading ? (
87
+ <LoadingState />
88
+ ) : listings.length === 0 ? (
89
+ <Typography
90
+ size="body-3"
91
+ className="text-text-default-2"
92
+ >
93
+ {t(($) => $.sell.empty)}
94
+ </Typography>
95
+ ) : (
96
+ <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">
104
+ <Typography
105
+ size="label-3"
106
+ className="text-text-default-2"
107
+ >
108
+ {formatCents(listing.priceCents)}
109
+ {listing.quantity <= 0 ? ` · ${t(($) => $.sell.soldOut)}` : ""}
110
+ {listing.status === "inactive" ? ` · ${t(($) => $.sell.inactive)}` : ""}
111
+ </Typography>
112
+ <Button
113
+ size="xs"
114
+ variant="outlined"
115
+ color="secondary"
116
+ link={{ to: "/sell/$id", params: { id: listing.id } }}
117
+ >
118
+ {t(($) => $.sell.edit)}
119
+ </Button>
120
+ {listing.status === "active" ? (
121
+ <Button
122
+ size="xs"
123
+ variant="outlined"
124
+ color="secondary"
125
+ onPress={() => void onDeactivate(listing.id)}
126
+ >
127
+ {t(($) => $.sell.deactivate)}
128
+ </Button>
129
+ ) : null}
130
+ </div>
131
+ </div>
132
+ ))}
133
+ </div>
134
+ )}
135
+ </div>
136
+ );
137
+ }