From be8634aad2ff9dc66d81ab87d161354cc2912e3a Mon Sep 17 00:00:00 2001 From: Alberto Quiles Date: Tue, 6 Oct 2026 01:15:47 +0200 Subject: [PATCH] feat: add collections section - fix #472 --- .../collections/collections.component.tsx | 139 ++++++++++++++++++ .../collections/collections.module.scss | 27 ++++ .../account/components/collections/index.ts | 1 + .../src/modules/account/components/index.ts | 1 + .../navigator/navigator.component.tsx | 6 + .../components/router/router.component.tsx | 5 + app/client/src/shared/hooks/index.ts | 1 + app/client/src/shared/hooks/useApi.ts | 6 +- app/client/src/shared/hooks/useCollections.ts | 39 +++++ .../src/shared/types/collections.types.ts | 22 +++ app/client/src/shared/types/index.ts | 1 + .../v3/user/@me/collections/main.request.ts | 91 ++++++++++++ .../api/v3/user/@me/collections/main.ts | 8 + .../src/modules/api/v3/user/@me/main.ts | 2 + 14 files changed, 347 insertions(+), 2 deletions(-) create mode 100644 app/client/src/modules/account/components/collections/collections.component.tsx create mode 100644 app/client/src/modules/account/components/collections/collections.module.scss create mode 100644 app/client/src/modules/account/components/collections/index.ts create mode 100644 app/client/src/shared/hooks/useCollections.ts create mode 100644 app/client/src/shared/types/collections.types.ts create mode 100644 app/server/src/modules/api/v3/user/@me/collections/main.request.ts create mode 100644 app/server/src/modules/api/v3/user/@me/collections/main.ts diff --git a/app/client/src/modules/account/components/collections/collections.component.tsx b/app/client/src/modules/account/components/collections/collections.component.tsx new file mode 100644 index 00000000..f00459e2 --- /dev/null +++ b/app/client/src/modules/account/components/collections/collections.component.tsx @@ -0,0 +1,139 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { + FileInputComponent, + TableComponent, + UploadIconComponent, +} from "@openhotel/web-components"; +import { Collection, CollectionManifest } from "shared/types"; +import { useCollections } from "shared/hooks"; +import dayjs from "dayjs"; +import styles from "./collections.module.scss"; + +type Props = {} & React.HTMLProps; + +export const CollectionsComponent: React.FC = () => { + const { get, upload } = useCollections(); + + const [collections, setCollections] = useState([]); + const [isAvailable, setIsAvailable] = useState(true); + + const [isUploading, setIsUploading] = useState(false); + const [published, setPublished] = useState(null); + const [errors, setErrors] = useState([]); + + const $reload = useCallback( + () => + get() + .then((collections) => { + setCollections(collections); + setIsAvailable(true); + }) + .catch(() => setIsAvailable(false)), + [get], + ); + + useEffect(() => { + $reload(); + }, []); + + const onUploadCollection = useCallback( + async ([file]: File[]) => { + if (!file?.size || isUploading) return; + + setIsUploading(true); + setPublished(null); + setErrors([]); + + try { + const manifest = await upload(file); + setPublished(manifest); + await $reload(); + } catch (e) { + setErrors( + e?.data?.errors?.length + ? e.data.errors + : ["The collection could not be uploaded, try again later"], + ); + } + + setIsUploading(false); + }, + [upload, $reload, isUploading], + ); + + return ( +
+

Collections

+ {!isAvailable ? ( + + ) : ( +
+ + + + {isUploading ? ( + "Uploading..." + ) : ( + <> + Upload *.collection file + + )} + + + {published ? ( + + ) : null} + {errors.length ? ( +
    + {errors.map((error) => ( +
  • {error}
  • + ))} +
+ ) : null} +
+ )} + + {isAvailable ? ( + ({ + ...collection, + name: collection.category?.label ?? "-", + version: collection.latestVersion || "-", + updated: dayjs(collection.updatedAt).format("YYYY/MM/DD HH:mm"), + }))} + columns={[ + { + key: "id", + label: "Id", + }, + { + key: "name", + label: "Name", + }, + { + key: "version", + label: "Version", + }, + { + key: "furnitureCount", + label: "Furniture", + }, + { + key: "updated", + label: "Updated", + }, + ]} + /> + ) : null} +
+ ); +}; diff --git a/app/client/src/modules/account/components/collections/collections.module.scss b/app/client/src/modules/account/components/collections/collections.module.scss new file mode 100644 index 00000000..9a930351 --- /dev/null +++ b/app/client/src/modules/account/components/collections/collections.module.scss @@ -0,0 +1,27 @@ +.collections { + width: 100%; + gap: 1rem; + display: flex; + flex-direction: column; + + .upload { + padding: 2rem; + background-color: var(--text-2-bg); + border-radius: 1rem; + gap: 1rem; + display: flex; + flex-direction: column; + + .icon { + height: 2rem; + } + + .errors { + margin: 0; + padding-left: 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + } +} diff --git a/app/client/src/modules/account/components/collections/index.ts b/app/client/src/modules/account/components/collections/index.ts new file mode 100644 index 00000000..77f9be93 --- /dev/null +++ b/app/client/src/modules/account/components/collections/index.ts @@ -0,0 +1 @@ +export * from "./collections.component"; diff --git a/app/client/src/modules/account/components/index.ts b/app/client/src/modules/account/components/index.ts index 2cee5af1..320ca8d6 100644 --- a/app/client/src/modules/account/components/index.ts +++ b/app/client/src/modules/account/components/index.ts @@ -1,6 +1,7 @@ export * from "./actions"; export * from "./connections"; export * from "./my-hotels"; +export * from "./collections"; export * from "./otp"; export * from "./navigator"; export * from "./languages"; diff --git a/app/client/src/modules/account/components/navigator/navigator.component.tsx b/app/client/src/modules/account/components/navigator/navigator.component.tsx index ce079e3b..74a5f08c 100644 --- a/app/client/src/modules/account/components/navigator/navigator.component.tsx +++ b/app/client/src/modules/account/components/navigator/navigator.component.tsx @@ -9,6 +9,7 @@ import { HotelIconComponent, KeysIconComponent, NavItemComponent, + UploadIconComponent, } from "@openhotel/web-components"; import { LinkComponent } from "shared/components"; import { useAccount, useUser } from "shared/hooks"; @@ -43,6 +44,11 @@ export const HomeNavigatorComponent: React.FC = () => { My Hotels + + }> + Collections + + }> Connections diff --git a/app/client/src/modules/application/components/router/router.component.tsx b/app/client/src/modules/application/components/router/router.component.tsx index ff1988d3..d144c79b 100644 --- a/app/client/src/modules/application/components/router/router.component.tsx +++ b/app/client/src/modules/application/components/router/router.component.tsx @@ -15,6 +15,7 @@ import { RecoverPasswordComponent } from "modules/recover-password"; import { ProvidersComponent } from "modules/application/components/providers"; import { AccountComponent, + CollectionsComponent, ConnectionsComponent, DeleteComponent, GithubComponent, @@ -104,6 +105,10 @@ const router = createBrowserRouter([ path: "my-hotels", element: , }, + { + path: "collections", + element: , + }, { path: "connections", element: , diff --git a/app/client/src/shared/hooks/index.ts b/app/client/src/shared/hooks/index.ts index 131159dd..1b4a66a0 100644 --- a/app/client/src/shared/hooks/index.ts +++ b/app/client/src/shared/hooks/index.ts @@ -7,6 +7,7 @@ export * from "./useConnection"; export * from "./useAdmin"; export * from "./useHotel"; export * from "./useMyHotels"; +export * from "./useCollections"; export * from "./useCookies"; export * from "./useHotels"; export * from "./useLanguages"; diff --git a/app/client/src/shared/hooks/useApi.ts b/app/client/src/shared/hooks/useApi.ts index 44c6fba6..08308c3d 100644 --- a/app/client/src/shared/hooks/useApi.ts +++ b/app/client/src/shared/hooks/useApi.ts @@ -16,14 +16,16 @@ export const useApi = () => { rawResponse = false, preventReload = false, }: Request) => { + const isFile = body instanceof Blob; + const response = await fetch(`/api/v3${pathname}`, { method, headers: new Headers({ - "Content-Type": "application/json", + ...(isFile ? {} : { "Content-Type": "application/json" }), fingerprint, ...headers, }), - body: body ? JSON.stringify(body) : undefined, + body: isFile ? body : body ? JSON.stringify(body) : undefined, credentials: "include", cache: cache ? "default" : "no-store", }).then(async (data) => { diff --git a/app/client/src/shared/hooks/useCollections.ts b/app/client/src/shared/hooks/useCollections.ts new file mode 100644 index 00000000..a23005d5 --- /dev/null +++ b/app/client/src/shared/hooks/useCollections.ts @@ -0,0 +1,39 @@ +import { useApi } from "./useApi"; +import { useCallback } from "react"; +import { RequestMethod } from "shared/enums"; +import { useAccount } from "./useAccount"; +import { Collection, CollectionManifest } from "shared/types"; + +export const useCollections = () => { + const { fetch } = useApi(); + const { getAccountHeaders } = useAccount(); + + const get = useCallback(async (): Promise => { + const { data } = await fetch({ + method: RequestMethod.GET, + pathname: `/user/@me/collections`, + headers: getAccountHeaders(), + }); + + return data.collections; + }, [fetch, getAccountHeaders]); + + const upload = useCallback( + async (file: File): Promise => { + const { data } = await fetch({ + method: RequestMethod.POST, + pathname: `/user/@me/collections`, + headers: getAccountHeaders(), + body: file, + }); + + return data.manifest; + }, + [fetch, getAccountHeaders], + ); + + return { + get, + upload, + }; +}; diff --git a/app/client/src/shared/types/collections.types.ts b/app/client/src/shared/types/collections.types.ts new file mode 100644 index 00000000..b36764e0 --- /dev/null +++ b/app/client/src/shared/types/collections.types.ts @@ -0,0 +1,22 @@ +export type CollectionCategory = { + label: string; + description?: string; +}; + +export type Collection = { + id: string; + category: CollectionCategory | null; + latestVersion: number; + furnitureCount: number; + createdAt: number; + updatedAt: number; +}; + +export type CollectionManifest = { + id: string; + version: number; + category: CollectionCategory; + furniture: { + id: string; + }[]; +}; diff --git a/app/client/src/shared/types/index.ts b/app/client/src/shared/types/index.ts index cfa70b2f..29ac8131 100644 --- a/app/client/src/shared/types/index.ts +++ b/app/client/src/shared/types/index.ts @@ -4,6 +4,7 @@ export * from "./connections.types"; export * from "./user.types"; export * from "./tokens.types"; export * from "./hotels.types"; +export * from "./collections.types"; export * from "./backup.types"; export * from "./apps.types"; export * from "./login.types"; diff --git a/app/server/src/modules/api/v3/user/@me/collections/main.request.ts b/app/server/src/modules/api/v3/user/@me/collections/main.request.ts new file mode 100644 index 00000000..f54c09a2 --- /dev/null +++ b/app/server/src/modules/api/v3/user/@me/collections/main.request.ts @@ -0,0 +1,91 @@ +import { + RequestType, + RequestMethod, + getResponse, + HttpStatusCode, + RequestKind, +} from "@oh/utils"; +import { System } from "modules/system/main.ts"; + +export const mainGetRequest: RequestType = { + method: RequestMethod.GET, + pathname: "", + kind: RequestKind.ACCOUNT, + func: async (request: Request) => { + const account = await System.accounts.getAccount({ request }); + + const { accountId, restrictions, blocked } = account.getObject(); + if (restrictions || blocked) return getResponse(HttpStatusCode.LOCKED); + + if (!System.getConfig().onet.enabled) + return getResponse(HttpStatusCode.SERVICE_UNAVAILABLE); + + try { + const { status, data } = await System.onet.fetch<{ + collections: unknown[]; + }>({ + pathname: `/auth/collections?accountId=${encodeURIComponent(accountId)}`, + }); + + if (status !== 200) { + console.error(`Onet collections list failed (${status})`); + return getResponse(HttpStatusCode.SERVICE_UNAVAILABLE); + } + + return getResponse(HttpStatusCode.OK, { + data: { collections: data.collections }, + }); + } catch (e) { + console.error(e); + return getResponse(HttpStatusCode.SERVICE_UNAVAILABLE); + } + }, +}; + +export const mainPostRequest: RequestType = { + method: RequestMethod.POST, + pathname: "", + kind: RequestKind.ACCOUNT, + func: async (request: Request) => { + const account = await System.accounts.getAccount({ request }); + + const { accountId, restrictions, blocked } = account.getObject(); + if (restrictions || blocked) return getResponse(HttpStatusCode.LOCKED); + + if (!System.getConfig().onet.enabled) + return getResponse(HttpStatusCode.SERVICE_UNAVAILABLE); + + const file = new Uint8Array(await request.arrayBuffer()); + if (!file.length) return getResponse(HttpStatusCode.BAD_REQUEST); + + try { + const { status, data } = await System.onet.fetch<{ + manifest?: unknown; + errors?: string[]; + }>({ + method: RequestMethod.POST, + pathname: `/auth/collections?accountId=${encodeURIComponent(accountId)}`, + body: file, + headers: { "content-type": "application/zip" }, + }); + + if (status === 400) { + return getResponse(HttpStatusCode.BAD_REQUEST, { + data: { errors: data?.errors ?? [] }, + }); + } + + if (status !== 200) { + console.error(`Onet collection publish failed (${status})`); + return getResponse(HttpStatusCode.SERVICE_UNAVAILABLE); + } + + return getResponse(HttpStatusCode.OK, { + data: { manifest: data.manifest }, + }); + } catch (e) { + console.error(e); + return getResponse(HttpStatusCode.SERVICE_UNAVAILABLE); + } + }, +}; diff --git a/app/server/src/modules/api/v3/user/@me/collections/main.ts b/app/server/src/modules/api/v3/user/@me/collections/main.ts new file mode 100644 index 00000000..b13caacd --- /dev/null +++ b/app/server/src/modules/api/v3/user/@me/collections/main.ts @@ -0,0 +1,8 @@ +import { RequestType, getPathRequestList } from "@oh/utils"; + +import { mainGetRequest, mainPostRequest } from "./main.request.ts"; + +export const collectionsRequestList: RequestType[] = getPathRequestList({ + requestList: [mainGetRequest, mainPostRequest], + pathname: "/collections", +}); diff --git a/app/server/src/modules/api/v3/user/@me/main.ts b/app/server/src/modules/api/v3/user/@me/main.ts index 7fdd91b5..6db7a722 100644 --- a/app/server/src/modules/api/v3/user/@me/main.ts +++ b/app/server/src/modules/api/v3/user/@me/main.ts @@ -5,6 +5,7 @@ import { scopesGetRequest } from "./scopes.request.ts"; import { emailGetRequest } from "./email.request.ts"; import { hotelRequestList } from "./hotel/main.ts"; import { appsGetRequest } from "./apps.request.ts"; +import { collectionsRequestList } from "./collections/main.ts"; export const meRequestList: RequestType[] = getPathRequestList({ requestList: [ @@ -15,6 +16,7 @@ export const meRequestList: RequestType[] = getPathRequestList({ appsGetRequest, ...connectionRequestList, ...hotelRequestList, + ...collectionsRequestList, ], pathname: "/@me", });