diff --git a/src/api/Places.ts b/src/api/Places.ts index 671d716b..1b2effc3 100644 --- a/src/api/Places.ts +++ b/src/api/Places.ts @@ -4,7 +4,10 @@ import Options from "decentraland-gatsby/dist/utils/api/Options" import Time from "decentraland-gatsby/dist/utils/date/Time" import env from "decentraland-gatsby/dist/utils/env" -import { DecentralandCategories } from "../entities/Category/types" +import { + CategoryCountTargetOptions, + DecentralandCategories, +} from "../entities/Category/types" import { AggregatePlaceAttributes, PlaceListOptions, @@ -195,11 +198,13 @@ export default class Places extends API { ) } - async getCategories() { + async getCategories( + target: CategoryCountTargetOptions = CategoryCountTargetOptions.ALL + ) { const result = await super.fetch<{ ok: boolean data: { name: string; count: number }[] - }>("/categories") + }>(`/categories?target=${target}`) return result.data } diff --git a/src/components/Banner/index.css b/src/components/Banner/index.css new file mode 100644 index 00000000..cd83242a --- /dev/null +++ b/src/components/Banner/index.css @@ -0,0 +1,62 @@ +.banner { + width: 100%; + height: 172px; + padding-left: 24px; + border-radius: 12px; + justify-content: space-between; + align-items: center; + display: flex; + position: relative; + overflow: hidden; +} + +.banner > div { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; +} + +.banner > div > img { + border-radius: 8px; + width: 64px; +} + +.banner > div > div { + display: flex; + flex-direction: column; +} + +.banner__title { + color: white; + font-size: 20px; + margin-bottom: 0; + font-weight: 700; + font-family: "Inter"; +} + +.banner__description { + color: white; + font-size: 16px; + font-weight: 500; + font-family: "Inter"; +} + +.banner > img { + width: 500px; + height: 172px; +} + +.banner > svg { + position: absolute; + right: 16px; + top: 16px; +} + +.genesis-banner { + background: linear-gradient(360deg, #ff7562 0%, #b60f55 100%); +} + +.worlds-banner { + background: linear-gradient(180deg, #161518 0%, #6c27a1 100%); +} diff --git a/src/components/Banner/index.tsx b/src/components/Banner/index.tsx new file mode 100644 index 00000000..953d0796 --- /dev/null +++ b/src/components/Banner/index.tsx @@ -0,0 +1,53 @@ +import React from "react" + +import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" + +import { CategoryCountTargetOptions } from "../../entities/Category/types" +import DCLLogo from "../../images/dcl-logo.svg" +import GenesisBanner from "../../images/genesis-banner.png" +import WorldBanner from "../../images/worlds-banner.png" +import WorldsLogo from "../../images/worlds-logo.svg" +import { Close } from "../Icon/Close" + +import "./index.css" + +type BannerProps = { + type: CategoryCountTargetOptions.PLACES | CategoryCountTargetOptions.WORLDS + onClose: (e: React.MouseEvent) => void +} + +export default ({ onClose, type }: BannerProps) => { + const l = useFormatMessage() + + return ( +
+
+ +
+

{l(`pages.${type}.banner.title`)}

+

+ {l(`pages.${type}.banner.description`)} +

+
+
+ + +
+ ) +} diff --git a/src/components/BannerMobile/index.css b/src/components/BannerMobile/index.css new file mode 100644 index 00000000..a87330c7 --- /dev/null +++ b/src/components/BannerMobile/index.css @@ -0,0 +1,61 @@ +.banner-mobile { + max-width: 346px; + width: 100%; + height: 316px; + background: linear-gradient(180deg, #161518 0%, #6c27a1 100%); + border-radius: 16px; + overflow: hidden; + flex-direction: column; + justify-content: flex-start; + align-items: center; + display: flex; + margin: auto; + position: relative; +} + +.banner-mobile > div { + padding: 24px 12px; + display: flex; + flex-direction: column; + gap: 4px; + align-items: center; + text-align: center; +} + +.banner-mobile > div > img { + width: 40px; + height: 40px; + border-radius: 8px; +} + +.banner-mobile__title { + color: white; + font-size: 20px; + font-weight: 700; + margin-bottom: 0; +} + +.banner-mobile__description { + color: white; + font-size: 16px; + font-weight: 500; +} + +.banner-mobile > img { + width: 346px; + height: 132px; +} + +.banner-mobile > svg { + position: absolute; + top: 6px; + right: 6px; +} + +.genesis-banner-mobile { + background: linear-gradient(360deg, #ff7562 0%, #b60f55 100%); +} + +.worlds-banner-mobile { + background: linear-gradient(180deg, #161518 0%, #6c27a1 100%); +} diff --git a/src/components/BannerMobile/index.tsx b/src/components/BannerMobile/index.tsx new file mode 100644 index 00000000..86a27c0b --- /dev/null +++ b/src/components/BannerMobile/index.tsx @@ -0,0 +1,54 @@ +import React from "react" + +import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" + +import { CategoryCountTargetOptions } from "../../entities/Category/types" +import DCLLogo from "../../images/dcl-logo.svg" +import GenesisBanner from "../../images/genesis-banner-mobile.png" +import WorldBanner from "../../images/worlds-banner.png" +import WorldsLogo from "../../images/worlds-logo.svg" +import { Close } from "../Icon/Close" + +import "./index.css" + +type BannerMobileProps = { + type: CategoryCountTargetOptions.PLACES | CategoryCountTargetOptions.WORLDS + onClose: (e: React.MouseEvent) => void +} + +export default ({ type, onClose }: BannerMobileProps) => { + const l = useFormatMessage() + + return ( +
+
+ Decentraland Logo +

+ {l(`pages.${type}.banner.title`)} +

+

+ {l(`pages.${type}.banner.description`)} +

+
+ + +
+ ) +} diff --git a/src/components/Category/AppearsOnCategory.css b/src/components/Category/AppearsOnCategory.css new file mode 100644 index 00000000..bc1b9e4e --- /dev/null +++ b/src/components/Category/AppearsOnCategory.css @@ -0,0 +1,12 @@ +.appears-on-categories-container { + border-top: solid 1px rgba(115, 110, 125, 0.24); + width: 100%; + padding: 25px 20px; +} + +.appears-on-categories-container + .category-filter__box + .dcl.filter + .filter-background { + background: var(--secondary-on-modal); +} diff --git a/src/components/Category/AppearsOnCategory.tsx b/src/components/Category/AppearsOnCategory.tsx new file mode 100644 index 00000000..2758b969 --- /dev/null +++ b/src/components/Category/AppearsOnCategory.tsx @@ -0,0 +1,33 @@ +import React from "react" + +import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" + +import { CategoryFilter, CategoryFilterProps } from "./CategoryFilter" + +import "./AppearsOnCategory.css" + +type AppearsOnCategoryProps = { + categories: string[] + onSelectCategory: ( + e: React.MouseEvent, + props: CategoryFilterProps + ) => void +} + +const AppearsOnCategory = ({ + categories, + onSelectCategory, +}: AppearsOnCategoryProps) => { + const l = useFormatMessage() + + return ( +
+

{l("components.place_detail.appears_on")}

+ {categories.map((id) => ( + + ))} +
+ ) +} + +export default AppearsOnCategory diff --git a/src/components/Category/CategoryList.tsx b/src/components/Category/CategoryList.tsx index ba7a0f5d..4a832af2 100644 --- a/src/components/Category/CategoryList.tsx +++ b/src/components/Category/CategoryList.tsx @@ -16,19 +16,29 @@ type CategoryList = { props: CategoryFilterProps ) => void categories: Category[] + label: string applyfilter?: boolean + isNew?: boolean } export const CategoryList = React.memo((props: CategoryList) => { - const { categories, onChange } = props + const { categories, onChange, isNew, label } = props const l = useFormatMessage() return (
- + {isNew ? ( + + ) : ( +
+

+ {label} {l("categories.title")} +

+
+ )} .dcl.back { + margin-right: 5px; +} diff --git a/src/components/Category/OnlyViewCategoryNavbar.tsx b/src/components/Category/OnlyViewCategoryNavbar.tsx new file mode 100644 index 00000000..9d5a5cff --- /dev/null +++ b/src/components/Category/OnlyViewCategoryNavbar.tsx @@ -0,0 +1,39 @@ +import React from "react" + +import { Back } from "decentraland-ui/dist/components/Back/Back" + +import { Close } from "../Icon/Close" +import { CategoryFilter, CategoryFilterProps } from "./CategoryFilter" + +import "./OnlyViewCategoryNavbar.css" + +type OnlyViewCategoryNavbarPros = { + category: string + onClickBack: (e: React.MouseEvent) => void + onClickCategoryFilter: ( + e: React.MouseEvent, + props: CategoryFilterProps + ) => void +} + +const OnlyViewCategoryNavbar = ({ + category, + onClickBack, + onClickCategoryFilter, +}: OnlyViewCategoryNavbarPros) => { + return ( +
+ +
+ } + /> +
+
+ ) +} + +export default OnlyViewCategoryNavbar diff --git a/src/components/Category/SelectedCategoriesNavbar.css b/src/components/Category/SelectedCategoriesNavbar.css new file mode 100644 index 00000000..fe3f0aeb --- /dev/null +++ b/src/components/Category/SelectedCategoriesNavbar.css @@ -0,0 +1,20 @@ +.clear-all-filter-btn .dcl.filter > span { + display: flex; + align-items: center; +} + +.clear-all-filter-btn .dcl.filter > span > p { + color: inherit; + font-weight: 700; + margin-left: 8px; +} + +.category-filters-box { + display: flex; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.category-filters-box .dcl.filter { + margin: 2px 0; +} diff --git a/src/components/Category/SelectedCategoriesNavbar.tsx b/src/components/Category/SelectedCategoriesNavbar.tsx new file mode 100644 index 00000000..117ec97e --- /dev/null +++ b/src/components/Category/SelectedCategoriesNavbar.tsx @@ -0,0 +1,47 @@ +import React from "react" + +import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" +import { Filter } from "decentraland-ui/dist/components/Filter/Filter" + +import { Category } from "../../entities/Category/types" +import { Close } from "../Icon/Close" +import { Trash } from "../Icon/Trash" +import { CategoryFilterProps } from "./CategoryFilter" +import { CategoryFilters } from "./CategoryFilters" + +import "./SelectedCategoriesNavbar.css" + +type SelectedCategoriesNavbarProps = { + categories: Category[] + onChangeFilters: ( + e: React.MouseEvent, + props: CategoryFilterProps + ) => void + onClickClearAll: (e: React.MouseEvent) => void +} + +const SelectedCategoriesNavbar = ({ + categories, + onChangeFilters, + onClickClearAll, +}: SelectedCategoriesNavbarProps) => { + const l = useFormatMessage() + + return ( +
+ } + /> + + +

{l("pages.places.clear_all")}

+
+
+
+ ) +} + +export default SelectedCategoriesNavbar diff --git a/src/components/Icon/Close.tsx b/src/components/Icon/Close.tsx index 0466c0d0..37585a65 100644 --- a/src/components/Icon/Close.tsx +++ b/src/components/Icon/Close.tsx @@ -1,7 +1,7 @@ import React from "react" type CloseProps = React.SVGAttributes & { - type?: "primary" | "secondary" + type?: "primary" | "secondary" | "filled" } export const Close = React.memo( @@ -19,7 +19,7 @@ export const Close = React.memo( )} - {type !== "primary" && ( + {type === "secondary" && ( )} + {type === "filled" && ( + + + + + )} ) } diff --git a/src/components/Place/PlaceDetails/PlaceDetails.css b/src/components/Place/PlaceDetails/PlaceDetails.css index c3787c59..4d13b140 100644 --- a/src/components/Place/PlaceDetails/PlaceDetails.css +++ b/src/components/Place/PlaceDetails/PlaceDetails.css @@ -98,19 +98,6 @@ justify-content: flex-start; } -.place-details__categories-container { - border-top: solid 1px rgba(115, 110, 125, 0.24); - width: 100%; - padding: 25px 20px; -} - -.place-details__categories-container - .category-filter__box - .dcl.filter - .filter-background { - background: var(--secondary-on-modal); -} - @media (max-width: 768px) { .place-details__container > .dcl.tabs diff --git a/src/components/Place/PlaceDetails/PlaceDetails.tsx b/src/components/Place/PlaceDetails/PlaceDetails.tsx index b15cf8be..2d6cfa29 100644 --- a/src/components/Place/PlaceDetails/PlaceDetails.tsx +++ b/src/components/Place/PlaceDetails/PlaceDetails.tsx @@ -26,10 +26,8 @@ import locations from "../../../modules/locations" import { getPois } from "../../../modules/pois" import { getRating } from "../../../modules/rating" import RatingButton, { RatingButtonProps } from "../../Button/RatingButton" -import { - CategoryFilter, - CategoryFilterProps, -} from "../../Category/CategoryFilter" +import AppearsOnCategory from "../../Category/AppearsOnCategory" +import { CategoryFilterProps } from "../../Category/CategoryFilter" import PlaceStats from "../PlaceStats/PlaceStats" import "./PlaceDetails.css" @@ -131,12 +129,10 @@ export default React.memo(function PlaceDetails(props: PlaceDetailsProps) {
{place?.categories.length > 0 && ( -
-

{l("components.place_detail.appears_on")}

- {place.categories.map((id) => ( - - ))} -
+ )} diff --git a/src/components/World/WorldDetails/WorldDetails.tsx b/src/components/World/WorldDetails/WorldDetails.tsx index 7ce44ba6..190541ac 100644 --- a/src/components/World/WorldDetails/WorldDetails.tsx +++ b/src/components/World/WorldDetails/WorldDetails.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useState } from "react" +import React, { useCallback, useMemo, useState } from "react" import ReactMarkdown from "react-markdown" @@ -6,6 +6,7 @@ import useAuthContext from "decentraland-gatsby/dist/context/Auth/useAuthContext import useFeatureFlagContext from "decentraland-gatsby/dist/context/FeatureFlag/useFeatureFlagContext" import isAdmin from "decentraland-gatsby/dist/entities/Auth/isAdmin" import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" +import { navigate } from "decentraland-gatsby/dist/plugins/intl" import { SceneContentRating } from "decentraland-gatsby/dist/utils/api/Catalyst.types" import TokenList from "decentraland-gatsby/dist/utils/dom/TokenList" import { Tabs } from "decentraland-ui/dist/components/Tabs/Tabs" @@ -16,8 +17,11 @@ import Label from "semantic-ui-react/dist/commonjs/elements/Label" import { AggregatePlaceAttributes } from "../../../entities/Place/types" import { FeatureFlags } from "../../../modules/ff" +import locations from "../../../modules/locations" import { getRating } from "../../../modules/rating" import RatingButton, { RatingButtonProps } from "../../Button/RatingButton" +import AppearsOnCategory from "../../Category/AppearsOnCategory" +import { CategoryFilterProps } from "../../Category/CategoryFilter" import PlaceStats from "../../Place/PlaceStats/PlaceStats" import "./WorldDetails.css" @@ -46,6 +50,22 @@ export default React.memo(function WorldDetails(props: WorldDetailsProps) { [place] ) + const handleCategorySelect = useCallback( + ( + e: React.MouseEvent, + props: CategoryFilterProps + ) => { + const { category } = props + + navigate( + locations.worlds({ + categories: [category], + }) + ) + }, + [] + ) + return (
+ + {place?.categories.length > 0 && ( + + )} + { + findActiveCategories.mockResolvedValueOnce(Promise.resolve([])) findCategoriesWithPlaces.mockResolvedValueOnce(Promise.resolve([])) - const placeResponse = await getCategoryList() + const request = new Request("/") + const url = new URL("https://localhost/") + const placeResponse = await getCategoryList({ request, url }) expect(placeResponse.body).toEqual({ ok: true, data: [], diff --git a/src/entities/Category/model.test.ts b/src/entities/Category/model.test.ts index c43e76c3..b692d65b 100644 --- a/src/entities/Category/model.test.ts +++ b/src/entities/Category/model.test.ts @@ -39,7 +39,8 @@ describe("CategoryModel", () => { ` SELECT c.name, count(pc.place_id) as count FROM "categories" c - LEFT JOIN "place_categories" pc ON pc.category_id = c.name + LEFT JOIN "place_categories" pc ON pc.category_id = c.name + LEFT JOIN "places" p ON pc.place_id = p.id WHERE c.active IS true GROUP BY c.name ORDER BY count DESC ` diff --git a/src/entities/Category/model.ts b/src/entities/Category/model.ts index 9b8ee46d..1391e51f 100644 --- a/src/entities/Category/model.ts +++ b/src/entities/Category/model.ts @@ -1,8 +1,17 @@ import { Model } from "decentraland-gatsby/dist/entities/Database/model" -import { SQL, table } from "decentraland-gatsby/dist/entities/Database/utils" +import { + SQL, + conditional, + table, +} from "decentraland-gatsby/dist/entities/Database/utils" +import PlaceModel from "../Place/model" import PlaceCategories from "../PlaceCategories/model" -import { CategoryAttributes, CategoryWithPlaceCount } from "./types" +import { + CategoryAttributes, + CategoryCountTargetOptions, + CategoryWithPlaceCount, +} from "./types" export default class CategoryModel extends Model { static tableName = "categories" @@ -19,13 +28,24 @@ export default class CategoryModel extends Model { ) } - static async findActiveCategoriesWithPlaces(): Promise< - CategoryWithPlaceCount[] - > { + static async findActiveCategoriesWithPlaces( + target: CategoryCountTargetOptions = CategoryCountTargetOptions.ALL + ): Promise { const query = SQL` SELECT c.name, count(pc.place_id) as count FROM ${table(CategoryModel)} c LEFT JOIN ${table(PlaceCategories)} pc ON pc.category_id = c.name - WHERE c.active IS true + LEFT JOIN ${table(PlaceModel)} p ON pc.place_id = p.id + WHERE c.active IS true + + ${conditional( + target === CategoryCountTargetOptions.WORLDS, + SQL`AND p.world IS true` + )} + ${conditional( + target === CategoryCountTargetOptions.PLACES, + SQL`AND p.world IS false` + )} + GROUP BY c.name ORDER BY count DESC ` diff --git a/src/entities/Category/routes.ts b/src/entities/Category/routes.ts index a8918837..73816fdd 100644 --- a/src/entities/Category/routes.ts +++ b/src/entities/Category/routes.ts @@ -1,18 +1,35 @@ +import Context from "decentraland-gatsby/dist/entities/Route/wkc/context/Context" import ApiResponse from "decentraland-gatsby/dist/entities/Route/wkc/response/ApiResponse" import routes from "decentraland-gatsby/dist/entities/Route/wkc/routes" import { categories as CategoryTranslations } from "../../intl/en.json" import CategoryModel from "./model" +import { CategoryCountTargetOptions } from "./types" export default routes((router) => { router.get("/categories", getCategoryList) }) -export async function getCategoryList() { - const categories = await CategoryModel.findActiveCategoriesWithPlaces() +export async function getCategoryList(ctx: Context<{}, "url" | "request">) { + let target = CategoryCountTargetOptions.ALL + switch (ctx.url.searchParams.get("target")) { + case CategoryCountTargetOptions.PLACES: + case CategoryCountTargetOptions.WORLDS: + target = ctx.url.searchParams.get("target")! as CategoryCountTargetOptions + break + default: + break + } - const withTranslations = categories.map((category) => ({ + const allActiveCategories = await CategoryModel.findActiveCategories() + + const categoriesWithCount = + await CategoryModel.findActiveCategoriesWithPlaces(target) + + const withTranslations = allActiveCategories.map((category) => ({ ...category, + count: + categoriesWithCount.find((c) => c.name === category.name)?.count || 0, i18n: { en: CategoryTranslations[ category.name as keyof typeof CategoryTranslations diff --git a/src/entities/Category/types.ts b/src/entities/Category/types.ts index 847e3377..95bc5039 100644 --- a/src/entities/Category/types.ts +++ b/src/entities/Category/types.ts @@ -17,3 +17,9 @@ export enum DecentralandCategories { // TODO: review this type with the other ones: naming and maybe we can merge some (@lauti7) export type Category = { name: string; active: boolean; count?: number } + +export enum CategoryCountTargetOptions { + ALL = "all", + PLACES = "places", + WORLDS = "worlds", +} diff --git a/src/entities/Place/model.test.ts b/src/entities/Place/model.test.ts index 88fa5775..e463e0ad 100644 --- a/src/entities/Place/model.test.ts +++ b/src/entities/Place/model.test.ts @@ -591,6 +591,7 @@ describe(`findWorld`, () => { order_by: "created_at", order: "desc", search: "", + categories: [], }) ).toEqual([worldPlaceTemplegame]) expect(namedQuery.mock.calls.length).toBe(1) @@ -625,6 +626,7 @@ describe(`findWorld`, () => { order: "desc", user: userLikeTrue.user, search: "decentraland", + categories: [], }) ).toEqual([worldPlaceTemplegame]) expect(namedQuery.mock.calls.length).toBe(1) @@ -670,6 +672,7 @@ describe(`findWorld`, () => { order: "desc", user: userLikeTrue.user, search: "de", + categories: [], }) ).toEqual([]) expect(namedQuery.mock.calls.length).toBe(0) @@ -684,6 +687,7 @@ describe(`countWorlds`, () => { only_favorites: false, names: ["templegame.dcl.eth"], search: "", + categories: [], }) ).toEqual(1) expect(namedQuery.mock.calls.length).toBe(1) @@ -711,6 +715,7 @@ describe(`countWorlds`, () => { only_favorites: false, names: ["templegame.dcl.eth"], search: "decentraland", + categories: [], }) ).toEqual(1) expect(namedQuery.mock.calls.length).toBe(1) @@ -739,6 +744,7 @@ describe(`countWorlds`, () => { names: ["templegame.dcl.eth"], user: "ABC", search: "a", + categories: [], }) ).toEqual(0) }) @@ -750,6 +756,7 @@ describe(`countWorlds`, () => { names: ["templegame.dcl.eth"], user: "ABC", search: "asdkad", + categories: [], }) ).toEqual(0) expect(namedQuery.mock.calls.length).toBe(0) diff --git a/src/entities/Place/model.ts b/src/entities/Place/model.ts index 5400175f..d36edd8d 100644 --- a/src/entities/Place/model.ts +++ b/src/entities/Place/model.ts @@ -497,6 +497,15 @@ export default class PlaceModel extends Model { options.search || "" )})) as rank` )} + ${conditional( + !!options.categories.length, + SQL`INNER JOIN ${table( + PlaceCategories + )} pc ON p.id = pc.place_id AND pc.category_id IN ${values( + options.categories + )}` + )} + WHERE p.disabled is false AND world is true ${conditional( @@ -517,7 +526,7 @@ export default class PlaceModel extends Model { static async countWorlds( options: Pick< FindWorldWithAggregatesOptions, - "user" | "only_favorites" | "names" | "search" + "user" | "only_favorites" | "names" | "search" | "categories" > ) { const isMissingEthereumAddress = @@ -537,6 +546,14 @@ export default class PlaceModel extends Model { UserFavoriteModel )} uf on p.id = uf.place_id AND uf.user = ${options.user}` )} + ${conditional( + !!options.categories.length, + SQL`INNER JOIN ${table( + PlaceCategories + )} pc ON p.id = pc.place_id AND pc.category_id IN ${values( + options.categories + )}` + )} ${conditional( !!options.search, SQL`, ts_rank_cd(p.textsearch, to_tsquery(${tsquery( diff --git a/src/entities/Social/routes.ts b/src/entities/Social/routes.ts index 6589f530..c1a54b44 100644 --- a/src/entities/Social/routes.ts +++ b/src/entities/Social/routes.ts @@ -98,6 +98,7 @@ export async function injectWorldMetadata(req: Request, res: Response) { order_by: PlaceListOrderBy.LIKE_SCORE_BEST, order: "asc", search: "", + categories: [], }) )[0] } diff --git a/src/entities/World/routes/getWorldList.ts b/src/entities/World/routes/getWorldList.ts index 04620e4a..e867ae75 100644 --- a/src/entities/World/routes/getWorldList.ts +++ b/src/entities/World/routes/getWorldList.ts @@ -33,6 +33,7 @@ export const getWorldList = Router.memo( search: ctx.url.searchParams.get("search"), order: oneOf(ctx.url.searchParams.get("order"), ["asc", "desc"]) || "desc", + categories: ctx.url.searchParams.getAll("categories"), }) const userAuth = await withAuthOptional(ctx) @@ -50,6 +51,7 @@ export const getWorldList = Router.memo( order_by: query.order_by, order: query.order, search: query.search, + categories: query.categories, } const [data, total, liveData] = await Promise.all([ diff --git a/src/entities/World/schemas.ts b/src/entities/World/schemas.ts index e75356dc..a035836e 100644 --- a/src/entities/World/schemas.ts +++ b/src/entities/World/schemas.ts @@ -48,6 +48,12 @@ export const getWorldListQuerySchema = schema({ "Filter worlds that contains a text expression, should have at least 3 characters otherwise the resultant list will be empty", nullable: true as any, }, + categories: { + type: "array", + items: { type: "string" }, + description: "Filter worlds by categories", + nullable: true as any, + }, }, }) diff --git a/src/entities/World/types.ts b/src/entities/World/types.ts index 99e061b2..0b6d06b1 100644 --- a/src/entities/World/types.ts +++ b/src/entities/World/types.ts @@ -6,6 +6,7 @@ export type GetWorldListQuery = { order_by: string order: string search: string + categories: string[] } export enum WorldListOrderBy { @@ -21,6 +22,7 @@ export type WorldListOptions = { order_by: string order: string search: string + categories: string[] } export type FindWorldWithAggregatesOptions = WorldListOptions & { diff --git a/src/hooks/usePlaceCategoriesManager.ts b/src/hooks/usePlaceCategoriesManager.ts index e8667337..1c053579 100644 --- a/src/hooks/usePlaceCategoriesManager.ts +++ b/src/hooks/usePlaceCategoriesManager.ts @@ -3,14 +3,18 @@ import { useCallback, useEffect, useMemo, useState } from "react" import useAsyncMemo from "decentraland-gatsby/dist/hooks/useAsyncMemo" import Places from "../api/Places" -import { Category } from "../entities/Category/types" +import { + Category, + CategoryCountTargetOptions, +} from "../entities/Category/types" export default function usePlaceCategoriesManager( + target: CategoryCountTargetOptions, initActiveCategories?: string[] ) { const [originalCategories] = useAsyncMemo( async () => { - const categories = await Places.get().getCategories() + const categories = await Places.get().getCategories(target) return categories.map((category) => ({ ...category, active: initActiveCategories?.includes(category.name) || false, @@ -82,14 +86,16 @@ export default function usePlaceCategoriesManager( [setCategories] ) - return [ + const isFilteringByCategory = + categories.filter(({ active }) => active).length > 0 + + return { categories, previousActiveCategories, categoriesStack, - { - handleAddCategory, - handleRemoveCategory, - handleSyncCategory, - }, - ] as const + isFilteringByCategory, + handleAddCategory, + handleRemoveCategory, + handleSyncCategory, + } as const } diff --git a/src/images/dcl-logo.svg b/src/images/dcl-logo.svg new file mode 100644 index 00000000..682b395d --- /dev/null +++ b/src/images/dcl-logo.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/images/genesis-banner-mobile.png b/src/images/genesis-banner-mobile.png new file mode 100644 index 00000000..ed669f7c Binary files /dev/null and b/src/images/genesis-banner-mobile.png differ diff --git a/src/images/genesis-banner.png b/src/images/genesis-banner.png new file mode 100644 index 00000000..95a9c087 Binary files /dev/null and b/src/images/genesis-banner.png differ diff --git a/src/images/worlds-banner.png b/src/images/worlds-banner.png new file mode 100644 index 00000000..a5c48a6e Binary files /dev/null and b/src/images/worlds-banner.png differ diff --git a/src/intl/en.json b/src/intl/en.json index d573f0e8..feb56a14 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -141,7 +141,11 @@ "search_results_title": "Search results for", "clear_all": "Clear All", "category_filter_title": "Categories", - "categories_selected": "Selected" + "categories_selected": "Selected", + "banner": { + "title": "Genesis City", + "description": "Map Made up of thousands of parcels owned and populated with dynamic content by its diverse community." + } }, "favorites": { "title": "Favorites", @@ -159,7 +163,11 @@ "worlds": { "description": "Your own 3D Space in the Metaverse. A place for Decentraland citizens to build, experiment, and host events and even interactive experiences without owning LAND.", "find_out_more": "Find Out More", - "search_results_title": "Search results for" + "search_results_title": "Search results for", + "banner": { + "title": "Decentraland Worlds", + "description": "Personal 3D space separate from Genesis City, where you can unleash your creativity, host events, and more!" + } } }, "social": { diff --git a/src/migrations/1708095544171_remove-wrong-categories-from-worlds.ts b/src/migrations/1708095544171_remove-wrong-categories-from-worlds.ts new file mode 100644 index 00000000..d0c42b81 --- /dev/null +++ b/src/migrations/1708095544171_remove-wrong-categories-from-worlds.ts @@ -0,0 +1,57 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" + +import PlaceModel from "../entities/Place/model" +import PlaceCategories from "../entities/PlaceCategories/model" +import PlacePositionModel from "../entities/PlacePosition/model" + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise { + pgm.sql( + `UPDATE ${PlaceModel.tableName} SET categories = '{}' WHERE world IS true` + ) + pgm.sql( + `DELETE FROM ${PlaceCategories.tableName} pc WHERE pc.place_id IN (SELECT p.id FROM ${PlaceModel.tableName} p WHERE p.world IS true)` + ) +} + +export async function down(pgm: MigrationBuilder): Promise { + const { content } = await import("../seed/base_categorized_content.json") + + const processed = content.reduce((acc, curr) => { + if (acc[curr.category_id]) { + acc[curr.category_id].push(curr.base_position) + } else { + acc[curr.category_id] = [curr.base_position] + } + return acc + }, {} as Record) + + for (const [category, positions] of Object.entries(processed)) { + pgm.sql(`UPDATE ${PlaceModel.tableName} + SET categories = array_append(categories, '${category}') + WHERE + disabled IS false AND world IS true AND base_position IN ( + SELECT DISTINCT(pp.base_position) + FROM ${PlacePositionModel.tableName} pp + WHERE pp.position IN (${positions.map((pos) => `'${pos}'`).join(",")}) + ) + `) + + pgm.sql(` + INSERT INTO ${PlaceCategories.tableName} (category_id, place_id) + SELECT + '${category}', p.id + FROM ${PlaceModel.tableName} p + WHERE + p.disabled IS false + AND p.world IS true + AND p.base_position IN ( + SELECT DISTINCT(pp.base_position) + FROM ${PlacePositionModel.tableName} pp + WHERE pp.position IN (${positions.map((pos) => `'${pos}'`).join(",")}) + ) + `) + } +} diff --git a/src/migrations/1708106235360_compute-worlds-categorization.ts b/src/migrations/1708106235360_compute-worlds-categorization.ts new file mode 100644 index 00000000..dfc78ea0 --- /dev/null +++ b/src/migrations/1708106235360_compute-worlds-categorization.ts @@ -0,0 +1,86 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import env from "decentraland-gatsby/dist/utils/env" +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" + +import PlaceModel from "../entities/Place/model" +import PlaceCategoriesModel from "../entities/PlaceCategories/model" + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise { + const { content } = await import("../seed/base_categorized_worlds.json") + + const isDev = env("PLACES_URL", "").includes(".zone") // .zone and .org runs with NODE_ENV=production, so we check the URL env var + + if (!isDev) { + const processed = content.reduce((acc, curr) => { + if (acc[curr.category_id]) { + acc[curr.category_id].push(curr.world_name) + } else { + acc[curr.category_id] = [curr.world_name] + } + return acc + }, {} as Record) + + for (const [category, worldNames] of Object.entries(processed)) { + pgm.sql(`UPDATE ${PlaceModel.tableName} + SET categories = array_append(categories, '${category}') + WHERE + disabled is false AND world_name IN (${worldNames + .map((name) => `'${name}'`) + .join(",")}) + `) + + pgm.sql(` + INSERT INTO ${PlaceCategoriesModel.tableName} (category_id, place_id) + SELECT + '${category}', p.id + FROM ${PlaceModel.tableName} p + WHERE + p.disabled is false + AND p.world_name IN (${worldNames + .map((name) => `'${name}'`) + .join(",")}) + `) + } + } else { + // Mock categories for Dev Database + const worldCategories = [ + ...new Set(content.map(({ category_id }) => category_id)), + ] + + const pick = (times: number) => { + const cats = [] + for (let i = times; i > 0; i--) { + const element = + worldCategories[Math.floor(Math.random() * worldCategories.length)] + cats.push(element) + } + return [...new Set(cats)] + } + const worlds = (await pgm.db.select( + `SELECT world_name, id FROM places WHERE disabled IS false and world IS true` + )) as { id: string; world_name: string }[] + + for (const { id, world_name } of worlds) { + const categories = pick(Math.floor(Math.random() * (3 - 1 + 1) + 1)) + for (const category of categories) { + pgm.sql( + `UPDATE ${PlaceModel.tableName} SET categories = array_append(categories, '${category}') WHERE world_name = '${world_name}'` + ) + pgm.sql( + `INSERT INTO ${PlaceCategoriesModel.tableName} (category_id, place_id) VALUES ('${category}', '${id}')` + ) + } + } + } +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.sql( + `UPDATE ${PlaceModel.tableName} SET categories = '{}' WHERE world IS true` + ) + pgm.sql( + `DELETE FROM ${PlaceCategoriesModel.tableName} pc WHERE pc.place_id IN (SELECT p.id FROM ${PlaceModel.tableName} p WHERE p.world IS true)` + ) +} diff --git a/src/modules/locations.ts b/src/modules/locations.ts index 74e394ac..a8977336 100644 --- a/src/modules/locations.ts +++ b/src/modules/locations.ts @@ -4,7 +4,6 @@ import { oneOf, } from "decentraland-gatsby/dist/entities/Schema/utils" import API from "decentraland-gatsby/dist/utils/api/API" -import env from "decentraland-gatsby/dist/utils/env" import { getPlaceListQuerySchema } from "../entities/Place/schemas" import { PlaceListOrderBy } from "../entities/Place/types" @@ -31,6 +30,8 @@ export type WorldsPageOptions = { order: "asc" | "desc" page: number search: string + categories: string[] + only_view_category: string } const pageOptionsDefault: PlacesPageOptions = { @@ -50,6 +51,8 @@ const pageWorldsOptionsDefault: WorldsPageOptions = { order: "desc", page: 1, search: "", + categories: [], + only_view_category: "", } export function toPlacesOptions(params: URLSearchParams): PlacesPageOptions { @@ -86,6 +89,8 @@ export function toWorldsOptions(params: URLSearchParams): WorldsPageOptions { oneOf(params.get("order"), ["asc", "desc"]) ?? pageOptionsDefault.order, page: numeric(params.get("page"), { min: 1 }) ?? pageOptionsDefault.page, search: params.get("search") ?? "", + categories: [...new Set(params.getAll("categories"))] ?? [], + only_view_category: params.get("only_view_category") ?? "", } } diff --git a/src/pages/genesis.css b/src/pages/genesis.css index e3f63ba0..b5c354ce 100644 --- a/src/pages/genesis.css +++ b/src/pages/genesis.css @@ -41,15 +41,6 @@ margin-bottom: 5px; } -.places-page__category-filters-box { - display: flex; - flex-wrap: wrap; -} - -.places-page__category-filters-box .dcl.filter { - margin: 2px 0; -} - .places-page .places-page__filters { border-right: 1px solid var(--divider); } @@ -117,6 +108,7 @@ .only-view-category-navbar__box { display: flex; align-items: center; + margin-bottom: 10px; } .only-view-category-navbar__box > .dcl.back { @@ -132,29 +124,11 @@ font-weight: 600; } -.clear-all-filter-btn .dcl.filter > span { - display: flex; - align-items: center; -} - -.clear-all-filter-btn .dcl.filter > span > p { - color: inherit; - font-weight: 700; - margin-left: 8px; -} - @media only screen and (min-width: 768px) { .places-page .places-page__filters .ui.header { margin-left: 22px; } - .ui.grid.places-page - > .row - > .column.places-page__list - .place-list__container { - width: calc(100% + 28px); - } - .ui.overview-list > .ui.container.full > .dcl.header-menu diff --git a/src/pages/genesis.tsx b/src/pages/genesis.tsx index c584bacd..fb56dce7 100644 --- a/src/pages/genesis.tsx +++ b/src/pages/genesis.tsx @@ -18,24 +18,21 @@ import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" import { navigate } from "decentraland-gatsby/dist/plugins/intl" import API from "decentraland-gatsby/dist/utils/api/API" import TokenList from "decentraland-gatsby/dist/utils/dom/TokenList" -import { Back } from "decentraland-ui/dist/components/Back/Back" import { Button } from "decentraland-ui/dist/components/Button/Button" import { Dropdown } from "decentraland-ui/dist/components/Dropdown/Dropdown" -import { Filter } from "decentraland-ui/dist/components/Filter/Filter" import { HeaderMenu } from "decentraland-ui/dist/components/HeaderMenu/HeaderMenu" import { useMobileMediaQuery } from "decentraland-ui/dist/components/Media/Media" +import { unique } from "radash" import Grid from "semantic-ui-react/dist/commonjs/collections/Grid" import Places from "../api/Places" -import { - CategoryFilter, - CategoryFilterProps, -} from "../components/Category/CategoryFilter" -import { CategoryFilters } from "../components/Category/CategoryFilters" +import Banner from "../components/Banner" +import BannerMobile from "../components/BannerMobile" +import { CategoryFilterProps } from "../components/Category/CategoryFilter" import { CategoryList } from "../components/Category/CategoryList" -import { Close } from "../components/Icon/Close" +import OnlyViewCategoryNavbar from "../components/Category/OnlyViewCategoryNavbar" +import SelectedCategoriesNavbar from "../components/Category/SelectedCategoriesNavbar" import { Filter as FilterIcon } from "../components/Icon/Filter" -import { Trash } from "../components/Icon/Trash" import Navigation, { NavigationTab } from "../components/Layout/Navigation" import NoResults from "../components/Layout/NoResults" import OverviewList from "../components/Layout/OverviewList" @@ -43,6 +40,7 @@ import SearchInput from "../components/Layout/SearchInput" import { CategoryModal } from "../components/Modal/CategoryModal" import PlaceList from "../components/Place/PlaceList/PlaceList" import { TrackingPlacesSearchContext } from "../context/TrackingContext" +import { CategoryCountTargetOptions } from "../entities/Category/types" import { getPlaceListQuerySchema } from "../entities/Place/schemas" import { AggregatePlaceAttributes, @@ -68,7 +66,8 @@ export default function IndexPage() { const track = useTrackContext() const [, setTrackingId] = useContext(TrackingPlacesSearchContext) - // TODO: remove one of these params + const [showBanner, setShowBanner] = useState(true) + const params = useMemo( () => toPlacesOptions(new URLSearchParams(location.search)), [location.search] @@ -84,14 +83,18 @@ export default function IndexPage() { const [totalPlaces, setTotalPlaces] = useState(0) const [allPlaces, setAllPlaces] = useState([]) - const isFilteringByCategory = params.categories.length > 0 - - const [ + const { categories, previousActiveCategories, categoriesStack, - { handleAddCategory, handleRemoveCategory, handleSyncCategory }, - ] = usePlaceCategoriesManager(params.categories) + isFilteringByCategory, + handleAddCategory, + handleRemoveCategory, + handleSyncCategory, + } = usePlaceCategoriesManager( + CategoryCountTargetOptions.PLACES, + params.categories + ) const [loadingPlaces, loadPlaces] = useAsyncTask(async () => { const options = API.fromPagination(params, { @@ -117,18 +120,15 @@ export default function IndexPage() { } if (isFilteringByCategory && !params.only_view_category) { - const categoriesFetch = [] - // TODO: review later. use map instead - for (const category of params.categories) { - const placesFetch = Places.get().getPlaces({ + const categoriesFetch = params.categories.map((category) => { + return Places.get().getPlaces({ ...options, offset, limit: 15, categories: [category], search: isSearching ? search : undefined, }) - categoriesFetch.push(placesFetch) - } + }) const responses = await Promise.all(categoriesFetch) for (const res of responses) { @@ -168,7 +168,11 @@ export default function IndexPage() { if (params.only_view_category) { setAllPlaces(response.data) } else { - setAllPlaces((allPlaces) => [...allPlaces, ...response.data]) + setAllPlaces((allPlaces) => { + const newPlaces = [...allPlaces, ...response.data] + // each category is singly requested, a same place can appear on more than one category + return unique(newPlaces, (place) => place.id) + }) } } @@ -423,6 +427,7 @@ export default function IndexPage() { )} @@ -510,38 +515,34 @@ export default function IndexPage() { )} {isFilteringByCategory && !params.only_view_category && ( -
- } - /> - - - {" "} -

{l("pages.places.clear_all")}

-
-
-
+ )} {params.only_view_category && ( -
- toggleViewAllCategory(null, true)} /> -
- toggleViewAllCategory(null, false)} - actionIcon={} - /> -
-
+ toggleViewAllCategory(null, true)} + onClickCategoryFilter={() => + toggleViewAllCategory(null, false) + } + category={params.only_view_category} + /> )} + {showBanner && + (isMobile ? ( + setShowBanner(false)} + /> + ) : ( + setShowBanner(false)} + /> + ))} {allPlaces.length > 0 && (!isFilteringByCategory || params.only_view_category) && ( .row > .column.worlds-page__list, .ui.grid.worlds-page > .row > .column.worlds-page__description { padding: 0 48px 24px; @@ -127,13 +131,6 @@ .worlds-page .worlds-page__filters .ui.header { margin-left: 22px; } - - .ui.grid.worlds-page - > .row - > .column.worlds-page__list - .place-list__container { - width: calc(100% + 28px); - } } @media only screen and (max-width: 767px) { @@ -182,3 +179,68 @@ margin-left: 0; } } + +#column-filters { + width: 320px; +} + +#column-worlds-list { + width: calc(100% - 320px); +} + +#column-worlds-list.full { + width: 100%; +} + +.worlds-page__category-filters-box-container { + display: flex; + flex-direction: column; +} + +.worlds-page__category-filters-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.worlds-page__category-filters-info p { + color: var(--secondary-text); + font-size: 14px; + font-weight: 700; + margin-bottom: 0; +} + +.worlds-page__category-filters-info > div { + display: flex; + align-items: center; +} + +.worlds-page__category-filters-info > div > .ui.basic.button { + margin-left: 5px; +} + +.worlds-page__category-filters_box--mobile > .dcl.box-children { + display: flex; + flex-wrap: wrap; +} + +.worlds-page__category-filters_box--mobile + > .dcl.box-children + .category-filter__box { + margin-bottom: 5px; +} + +.worlds-page__category-filters-box { + display: flex; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.worlds-page__category-filters-box .dcl.filter { + margin: 2px 0; +} + +.worlds-page .places-page__filters { + border-right: 1px solid var(--divider); +} diff --git a/src/pages/worlds.tsx b/src/pages/worlds.tsx index 52f5642b..00e0d4c0 100644 --- a/src/pages/worlds.tsx +++ b/src/pages/worlds.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useState } from "react" +import React, { + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from "react" import { Helmet } from "react-helmet" @@ -9,24 +15,40 @@ import useTrackContext from "decentraland-gatsby/dist/context/Track/useTrackCont import { oneOf } from "decentraland-gatsby/dist/entities/Schema/utils" import useAsyncTask from "decentraland-gatsby/dist/hooks/useAsyncTask" import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" -import { Link, navigate } from "decentraland-gatsby/dist/plugins/intl" +import { navigate } from "decentraland-gatsby/dist/plugins/intl" import API from "decentraland-gatsby/dist/utils/api/API" -import env from "decentraland-gatsby/dist/utils/env" +import TokenList from "decentraland-gatsby/dist/utils/dom/TokenList" +import { Back } from "decentraland-ui/dist/components/Back/Back" import { Button } from "decentraland-ui/dist/components/Button/Button" import { Dropdown } from "decentraland-ui/dist/components/Dropdown/Dropdown" import { HeaderMenu } from "decentraland-ui/dist/components/HeaderMenu/HeaderMenu" import { useMobileMediaQuery } from "decentraland-ui/dist/components/Media/Media" +import { unique } from "radash" import Grid from "semantic-ui-react/dist/commonjs/collections/Grid" import Places from "../api/Places" +import Banner from "../components/Banner" +import BannerMobile from "../components/BannerMobile" +import { + CategoryFilter, + CategoryFilterProps, +} from "../components/Category/CategoryFilter" +import { CategoryList } from "../components/Category/CategoryList" +import SelectedCategoriesNavbar from "../components/Category/SelectedCategoriesNavbar" +import { Close } from "../components/Icon/Close" +import { Filter } from "../components/Icon/Filter" import Navigation, { NavigationTab } from "../components/Layout/Navigation" import NoResults from "../components/Layout/NoResults" +import OverviewList from "../components/Layout/OverviewList" import SearchInput from "../components/Layout/SearchInput" +import { CategoryModal } from "../components/Modal/CategoryModal" import PlaceList from "../components/Place/PlaceList/PlaceList" -import WorldLabel from "../components/World/WorldLabel/WorldLabel" +import { TrackingPlacesSearchContext } from "../context/TrackingContext" +import { CategoryCountTargetOptions } from "../entities/Category/types" import { AggregatePlaceAttributes } from "../entities/Place/types" import { getWorldListQuerySchema } from "../entities/World/schemas" import { WorldListOrderBy } from "../entities/World/types" +import usePlaceCategoriesManager from "../hooks/usePlaceCategoriesManager" import usePlacesManager from "../hooks/usePlacesManager" import { FeatureFlags } from "../modules/ff" import locations, { @@ -39,11 +61,6 @@ import "./worlds.css" const PAGE_SIZE = 24 -const WORLDS_FIND_OUT_URL = env( - `WORLDS_FIND_OUT_URL`, - `https://decentraland.org/blog/announcements/introducing-decentraland-worlds-beta-your-own-3d-space-in-the-metaverse` -) - export default function WorldsPage() { const l = useFormatMessage() const isMobile = useMobileMediaQuery() @@ -58,34 +75,110 @@ export default function WorldsPage() { const isSearching = !!params.search && params.search.length > 2 const search = (isSearching && params.search) || "" + const [showBanner, setShowBanner] = useState(true) + const [totalWorlds, setTotalWorlds] = useState(0) const [allWorlds, setAllWorlds] = useState([]) + const [isCategoriesModalVisible, setIsCategoriesModalVisible] = + useState(false) + + const { + categories, + previousActiveCategories, + categoriesStack, + isFilteringByCategory, + handleAddCategory, + handleRemoveCategory, + handleSyncCategory, + } = usePlaceCategoriesManager( + CategoryCountTargetOptions.WORLDS, + params.categories + ) + + const [, setTrackingId] = useContext(TrackingPlacesSearchContext) + const [loadingWorlds, loadWorlds] = useAsyncTask(async () => { const options: Partial = API.fromPagination(params, { pageSize: PAGE_SIZE, }) - const placesFetch = await Places.get().getWorlds({ - ...options, - offset: offset, - search: isSearching ? search : undefined, - }) + let response = { + total: 0, + data: [] as AggregatePlaceAttributes[], + ok: false, + } - if (isSearching) { + if (params.only_view_category) { + const placesFetch = await Places.get().getWorlds({ + ...options, + offset: offset, + search: isSearching ? search : undefined, + categories: [params.only_view_category], + }) + response.data = placesFetch.data + response.ok = placesFetch.ok + response.total = placesFetch.total + } + + if (isFilteringByCategory && !params.only_view_category) { + const categoriesFetch = params.categories.map((category) => { + return Places.get().getWorlds({ + ...options, + offset, + limit: 15, + categories: [category], + search: isSearching ? search : undefined, + }) + }) + const responses = await Promise.all(categoriesFetch) + + for (const res of responses) { + response.total += res.total + response.data.push(...res.data) + } + } else if (!params.only_view_category) { + const placesFetch = await Places.get().getWorlds({ + ...options, + offset, + search: isSearching ? search : undefined, + }) + response = placesFetch + } + + if (isFilteringByCategory || isSearching || params.only_view_category) { + const newTrackingId = crypto.randomUUID() + setTrackingId(newTrackingId as string) track(SegmentPlace.WorldsSearch, { - resultsCount: placesFetch.total, - top10: placesFetch.data.slice(0, 10), + trackingId: newTrackingId, + resultsCount: response.total, + top10: response.data.slice(0, 10), search, + categories: isFilteringByCategory ? params.categories : undefined, + viewAllCategory: + params.only_view_category != "" + ? params.only_view_category + : undefined, + orderBy: params.order_by, place: SegmentPlace.Worlds, }) - setAllWorlds(placesFetch.data) - } else { - setAllWorlds((allWorlds) => [...allWorlds, ...placesFetch.data]) } - if (Number.isSafeInteger(placesFetch.total)) { - setTotalWorlds(placesFetch.total) + if (isSearching) { + setAllWorlds(response.data) + } else { + if (params.only_view_category) { + setAllWorlds(response.data) + } else { + setAllWorlds((allWorlds) => { + const newWorlds = [...allWorlds, ...response.data] + // each category is singly requested, a same world can appear on more than one category + return unique(newWorlds, (world) => world.id) + }) + } + } + if (Number.isSafeInteger(response.total)) { + setTotalWorlds(response.total) } }, [params, track]) @@ -93,7 +186,7 @@ export default function WorldsPage() { if (allWorlds.length === 0) { loadWorlds() } - }, [params.only_favorites, params.order, params.order_by]) + }, [params.only_favorites, params.order, params.order_by, params.categories]) useEffect(() => { setAllWorlds([]) @@ -108,10 +201,15 @@ export default function WorldsPage() { params.only_favorites, params.order, params.order_by, + params.categories, ]) useEffect(() => { - if (allWorlds.length > PAGE_SIZE) { + if ( + allWorlds.length > PAGE_SIZE && + !isFilteringByCategory && + !params.only_view_category + ) { setTimeout( () => window.scrollBy({ top: 500, left: 0, behavior: "smooth" }), 0 @@ -171,6 +269,119 @@ export default function WorldsPage() { [params, track] ) + const handleCategoriesFilterChange = useCallback( + (newCategories: string[]) => { + // change sorting when filter by categories + const newParams: WorldsPageOptions = { + ...params, + categories: newCategories, + } + if ( + (!newParams.order_by || + newParams.order_by !== WorldListOrderBy.LIKE_SCORE_BEST) && + newCategories.length > 0 + ) { + newParams.order_by = WorldListOrderBy.LIKE_SCORE_BEST + } else if (!newCategories.length) { + newParams.order_by = WorldListOrderBy.MOST_ACTIVE + } + + setAllWorlds([]) + navigate(locations.worlds(newParams)) + }, + [params] + ) + + const handleApplyCategoryListChange = useCallback( + ( + e: React.MouseEvent, + props: CategoryFilterProps + ) => { + const { active, category } = props + + const names = categories + .filter(({ active }) => active) + .map(({ name }) => name) + + if (active) { + handleAddCategory(category) + names.push(category) + } + + if (!active) { + handleRemoveCategory(category) + + names.splice(names.indexOf(category), 1) + } + + handleCategoriesFilterChange(names) + }, + [ + categories, + handleAddCategory, + handleRemoveCategory, + handleCategoriesFilterChange, + ] + ) + + const handleClearAll = useCallback(() => { + if (previousActiveCategories.length) { + handleCategoriesFilterChange([]) + } else { + handleSyncCategory( + categories.map((category) => ({ + ...category, + active: false, + })) + ) + } + }, [previousActiveCategories, categories]) + + const toggleViewAllCategory = useCallback( + (categoryId: string | null, back?: boolean) => { + const newParams = { ...params } + if (params.only_view_category) { + newParams.only_view_category = "" + if (!back) { + newParams.categories = newParams.categories.filter( + (category) => category != params.only_view_category + ) + } + } else { + newParams.only_view_category = categoryId! + } + + setAllWorlds([]) + navigate(locations.worlds(newParams)) + }, + [params] + ) + + const handleCategoryModalChange = useCallback( + ( + e: React.MouseEvent, + props: CategoryFilterProps + ) => { + const { active, category } = props + active && handleAddCategory(category) + !active && handleRemoveCategory(category) + }, + [handleAddCategory, handleRemoveCategory] + ) + + const handleApplyModalChange = useCallback( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (e: React.MouseEvent) => { + const names = categories + .filter(({ active }) => active) + .map(({ name }) => name) + + handleCategoriesFilterChange(names) + setIsCategoriesModalVisible(false) + }, + [categories, handleCategoriesFilterChange] + ) + const [ff] = useFeatureFlagContext() if (ff.flags[FeatureFlags.Maintenance]) { @@ -210,20 +421,24 @@ export default function WorldsPage() { - -
-
- -

{l("pages.worlds.description")}

-
- -
-
-
- - + {!isMobile && !params.only_view_category && ( + + + + )} + {isMobile && (
)} - {allWorlds.length > 0 && ( - handleFavorite(place.id, place)} - loadingFavorites={handlingFavorite} - dataPlace={SegmentPlace.Places} - /> - )} +
+
+

+ {totalWorlds} {l("navigation.worlds")} +

+ {isMobile && ( +
+ + + {getWorldListQuerySchema.properties.order_by.enum.map( + (orderBy) => { + return ( + + ) + } + )} + + +
+ )} +
+ {isFilteringByCategory && !params.only_view_category && ( + + )} + {params.only_view_category && ( +
+ toggleViewAllCategory(null, true)} /> +
+ toggleViewAllCategory(null, false)} + actionIcon={} + /> +
+
+ )} +
+ {showBanner && + (isMobile ? ( + setShowBanner(false)} + /> + ) : ( + setShowBanner(false)} + /> + ))} + {allWorlds.length > 0 && + (!isFilteringByCategory || params.only_view_category) && ( + + handleFavorite(place.id, place) + } + loadingFavorites={handlingFavorite} + dataPlace={SegmentPlace.Places} + /> + )} + {isFilteringByCategory && + !params.only_view_category && + categoriesStack.map((category) => { + const categorizedPlaces = places.filter((place) => + place.categories.includes(category.name) + ) + + if (categorizedPlaces.length > 0) { + return ( + + {l(`categories.${category.name}`)}{" "} + {category.count} + + } + places={categorizedPlaces} + onClick={() => toggleViewAllCategory(category.name)} + loadingFavorites={handlingFavorite} + search={search} + dataPlace={SegmentPlace.Worlds} + onClickFavorite={(_, place) => { + handleFavorite(place.id, place) + }} + loading={loadingWorlds} + /> + ) + } + })} {loading && ( )} - {!loading && totalWorlds > places.length && ( -
- -
- )} + {!loading && + totalWorlds > places.length && + (!isFilteringByCategory || params.only_view_category) && ( +
+ +
+ )} {!loading && isSearching && totalWorlds === 0 && ( )} + {isMobile && ( + { + setIsCategoriesModalVisible(false) + handleSyncCategory( + categories.map((category) => ({ + ...category, + active: !!previousActiveCategories.find( + ({ name }) => name === category.name + ), + })) + ) + }} + onClearAll={() => { + setIsCategoriesModalVisible(false) + handleClearAll() + }} + onChange={handleCategoryModalChange} + onActionClick={handleApplyModalChange} + /> + )} ) diff --git a/src/seed/base_categorized_worlds.json b/src/seed/base_categorized_worlds.json new file mode 100644 index 00000000..9dec01d1 --- /dev/null +++ b/src/seed/base_categorized_worlds.json @@ -0,0 +1,754 @@ +{ + "content": [ + { + "place_id": "40115ef9-3846-4dca-abc5-ed21101db938", + "world_name": "arcania.dcl.eth", + "category_id": "game" + }, + { + "place_id": "40115ef9-3846-4dca-abc5-ed21101db938", + "world_name": "arcania.dcl.eth", + "category_id": "art" + }, + { + "place_id": "40115ef9-3846-4dca-abc5-ed21101db938", + "world_name": "arcania.dcl.eth", + "category_id": "crypto" + }, + { + "place_id": "14b99cc2-775c-49ce-a8ff-26e71202a83f", + "world_name": "metadynelabs.dcl.eth", + "category_id": "game" + }, + { + "place_id": "0703e273-4e0f-4bc5-8dc5-28cec040d1ed", + "world_name": "strategicunit.dcl.eth", + "category_id": "social" + }, + { + "place_id": "d2341d4a-fa13-4da0-96c2-7a6e06a9dd44", + "world_name": "greenscreen.dcl.eth", + "category_id": "game" + }, + { + "place_id": "b1692817-85d6-44ac-938b-8367b385adac", + "world_name": "improve.dcl.eth", + "category_id": "social" + }, + { + "place_id": "dd9838e2-f2ca-4c44-820f-1c7b05a03d02", + "world_name": "outfits.dcl.eth", + "category_id": "game" + }, + { + "place_id": "dd9838e2-f2ca-4c44-820f-1c7b05a03d02", + "world_name": "outfits.dcl.eth", + "category_id": "fashion" + }, + { + "place_id": "ac95a895-4aaa-40db-b666-b01edbb95cb5", + "world_name": "meangreendoge.dcl.eth", + "category_id": "game" + }, + { + "place_id": "b6918481-7a88-44c5-b00d-a1d7d3cda2a1", + "world_name": "lowpolymodels.dcl.eth", + "category_id": "social" + }, + { + "place_id": "b6918481-7a88-44c5-b00d-a1d7d3cda2a1", + "world_name": "lowpolymodels.dcl.eth", + "category_id": "music" + }, + { + "place_id": "80a5135d-4890-496a-98ea-920b0facdf5f", + "world_name": "wasteland.dcl.eth", + "category_id": "game" + }, + { + "place_id": "3a506123-8ff6-4038-8d5b-63ababc7b348", + "world_name": "dachainrk.dcl.eth", + "category_id": "game" + }, + { + "place_id": "3a506123-8ff6-4038-8d5b-63ababc7b348", + "world_name": "dachainrk.dcl.eth", + "category_id": "art" + }, + { + "place_id": "1366f7ca-0417-4965-a9b7-c4c10ade97a3", + "world_name": "oldguy.dcl.eth", + "category_id": "game" + }, + { + "place_id": "e294e90e-3227-4a16-877a-92b0b6f0a663", + "world_name": "lasertagarena.dcl.eth", + "category_id": "game" + }, + { + "place_id": "e180331b-6376-4752-bac7-f0b8b2713ba8", + "world_name": "tophub.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "6548f2a7-0ce7-4b68-a5b7-f6535014a741", + "world_name": "dexou.dcl.eth", + "category_id": "art" + }, + { + "place_id": "6548f2a7-0ce7-4b68-a5b7-f6535014a741", + "world_name": "dexou.dcl.eth", + "category_id": "music" + }, + { + "place_id": "9159ce0b-24c6-47cf-a5a2-e65c5d7323c1", + "world_name": "dexou.dcl.eth", + "category_id": "music" + }, + { + "place_id": "8dde2f50-e3f7-4f11-86e0-53c67c333169", + "world_name": "bitfuel.dcl.eth", + "category_id": "business" + }, + { + "place_id": "284c5412-2f0b-4515-93b7-3b7657b0f202", + "world_name": "luciddreams.dcl.eth", + "category_id": "game" + }, + { + "place_id": "a8104e50-2b0d-4af5-8861-2624d309eeb4", + "world_name": "templegame.dcl.eth", + "category_id": "game" + }, + { + "place_id": "a8104e50-2b0d-4af5-8861-2624d309eeb4", + "world_name": "templegame.dcl.eth", + "category_id": "education" + }, + { + "place_id": "1722f688-f2c3-481f-ba82-5f83830a5bd9", + "world_name": "ded.dcl.eth", + "category_id": "education" + }, + { + "place_id": "1ef01c51-8f0b-4ca1-bfef-b8093f0226bf", + "world_name": "wizardry.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "89bda919-3d17-4c1b-aa25-17946db8c77e", + "world_name": "8metagames.dcl.eth", + "category_id": "game" + }, + { + "place_id": "229f63b2-3bc4-4f00-8a18-1ca212aff8b2", + "world_name": "residence.dcl.eth", + "category_id": "art" + }, + { + "place_id": "cd162d59-e1d6-4a6c-b2c4-420f1d45ebe9", + "world_name": "metafoxcrew.dcl.eth", + "category_id": "education" + }, + { + "place_id": "d89efc1e-f477-4a6e-98c4-fb3131bec702", + "world_name": "exodustown.dcl.eth", + "category_id": "art" + }, + { + "place_id": "7a84a7b1-0075-4076-a61c-ebb74d97281d", + "world_name": "mvmf21water.dcl.eth", + "category_id": "social" + }, + { + "place_id": "7a84a7b1-0075-4076-a61c-ebb74d97281d", + "world_name": "mvmf21water.dcl.eth", + "category_id": "music" + }, + { + "place_id": "46fa885a-157f-4cf5-bb5a-2066ec6ac4d8", + "world_name": "talentless.dcl.eth", + "category_id": "social" + }, + { + "place_id": "46fa885a-157f-4cf5-bb5a-2066ec6ac4d8", + "world_name": "talentless.dcl.eth", + "category_id": "music" + }, + { + "place_id": "7aaa7c59-6824-45f8-b22f-cd46b6d8f493", + "world_name": "sammich.dcl.eth", + "category_id": "game" + }, + { + "place_id": "9fd06083-da58-4780-8f97-aff5645ad980", + "world_name": "rnb.dcl.eth", + "category_id": "business" + }, + { + "place_id": "7b5237ca-f42f-486e-9f56-8ead8630c2cb", + "world_name": "paralax.dcl.eth", + "category_id": "social" + }, + { + "place_id": "e7b1cb48-d040-4a6d-8e05-32fd0bc2fed3", + "world_name": "madagascar.dcl.eth", + "category_id": "game" + }, + { + "place_id": "61561b05-5187-4383-9f5e-64dc1e2ae9b4", + "world_name": "mvmf21meta.dcl.eth", + "category_id": "social" + }, + { + "place_id": "61561b05-5187-4383-9f5e-64dc1e2ae9b4", + "world_name": "mvmf21meta.dcl.eth", + "category_id": "music" + }, + { + "place_id": "8e356993-2a57-4165-94db-25963c593081", + "world_name": "ivroom.dcl.eth", + "category_id": "social" + }, + { + "place_id": "d4adf838-b6b9-479c-b776-4eca1bdb3504", + "world_name": "megeve.dcl.eth", + "category_id": "art" + }, + { + "place_id": "5fe933db-4c4b-4384-b444-7a7fcafc77dd", + "world_name": "markcuban4skin.dcl.eth", + "category_id": "art" + }, + { + "place_id": "7343d22a-1f05-4736-a9c3-f45b326fd5f4", + "world_name": "duelarena.dcl.eth", + "category_id": "game" + }, + { + "place_id": "fa8edd97-44f4-43bf-9b58-d2c785cd2df6", + "world_name": "ova.dcl.eth", + "category_id": "art" + }, + { + "place_id": "612bd413-851f-48f7-9389-7fb117b655cd", + "world_name": "artemls.dcl.eth", + "category_id": "social" + }, + { + "place_id": "612bd413-851f-48f7-9389-7fb117b655cd", + "world_name": "artemls.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "a6f93f7c-3072-412a-b7de-4e18994659df", + "world_name": "surz.dcl.eth", + "category_id": "social" + }, + { + "place_id": "36be6770-3bb0-4460-92dd-345d176c2da2", + "world_name": "lauti7.dcl.eth", + "category_id": "game" + }, + { + "place_id": "2024935d-14df-41bc-ae4b-d8b71ddf76de", + "world_name": "vlm.dcl.eth", + "category_id": "art" + }, + { + "place_id": "2024935d-14df-41bc-ae4b-d8b71ddf76de", + "world_name": "vlm.dcl.eth", + "category_id": "fashion" + }, + { + "place_id": "bcfcdee8-2491-401f-8550-af8d980dcc00", + "world_name": "eoneye.dcl.eth", + "category_id": "music" + }, + { + "place_id": "bdda66eb-bdd6-4532-9c88-d669bcd12ec1", + "world_name": "solowallet.dcl.eth", + "category_id": "art" + }, + { + "place_id": "1bd3a9fe-94f8-4a2c-a004-98adb19c964a", + "world_name": "cryptoacademy.dcl.eth", + "category_id": "education" + }, + { + "place_id": "1bd3a9fe-94f8-4a2c-a004-98adb19c964a", + "world_name": "cryptoacademy.dcl.eth", + "category_id": "crypto" + }, + { + "place_id": "1bd3a9fe-94f8-4a2c-a004-98adb19c964a", + "world_name": "cryptoacademy.dcl.eth", + "category_id": "business" + }, + { + "place_id": "284bdea5-3df2-4f0f-b817-182ef66bf4e6", + "world_name": "emgeducation.dcl.eth", + "category_id": "education" + }, + { + "place_id": "d2b9fdd0-81e0-4248-89c8-d03a986dd95b", + "world_name": "marinamarion.dcl.eth", + "category_id": "art" + }, + { + "place_id": "2619db84-5180-448d-bc9a-d7e83ae7ee1d", + "world_name": "rizkgh.dcl.eth", + "category_id": "social" + }, + { + "place_id": "b8c0f12a-f33d-46f9-b8a0-4bfe7892b4cd", + "world_name": "teamwes.dcl.eth", + "category_id": "art" + }, + { + "place_id": "052ad211-1e0b-4352-a8e4-08efa6005d31", + "world_name": "quadrax.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "31a69d3f-57b9-43c6-a808-4265457f6696", + "world_name": "maximocossetti.dcl.eth", + "category_id": "game" + }, + { + "place_id": "1372077f-79dd-4910-ac30-36da565a3c2c", + "world_name": "pko.dcl.eth", + "category_id": "social" + }, + { + "place_id": "1372077f-79dd-4910-ac30-36da565a3c2c", + "world_name": "pko.dcl.eth", + "category_id": "business" + }, + { + "place_id": "c9c07f77-fbb6-48e8-82c7-38337b03fcd6", + "world_name": "dclmvfw.dcl.eth", + "category_id": "fashion" + }, + { + "place_id": "0b2aac85-9542-4bfc-8100-821730047c50", + "world_name": "mgmplanetstage.dcl.eth", + "category_id": "game" + }, + { + "place_id": "0b2aac85-9542-4bfc-8100-821730047c50", + "world_name": "mgmplanetstage.dcl.eth", + "category_id": "social" + }, + { + "place_id": "ef3cf146-23d6-4716-b9e9-3f1b26badd4f", + "world_name": "41510.dcl.eth", + "category_id": "social" + }, + { + "place_id": "ef3cf146-23d6-4716-b9e9-3f1b26badd4f", + "world_name": "41510.dcl.eth", + "category_id": "music" + }, + { + "place_id": "02d20276-ad0d-43de-9703-985a4f70fbb5", + "world_name": "tru.dcl.eth", + "category_id": "music" + }, + { + "place_id": "deacbd35-06c2-41a0-a3de-0826712a4ab2", + "world_name": "baybackner.dcl.eth", + "category_id": "art" + }, + { + "place_id": "deacbd35-06c2-41a0-a3de-0826712a4ab2", + "world_name": "baybackner.dcl.eth", + "category_id": "social" + }, + { + "place_id": "946260af-afb3-4579-bf95-6561f5bd82b8", + "world_name": "fim.dcl.eth", + "category_id": "business" + }, + { + "place_id": "0e3228c6-df54-4f7f-9869-77b5ac40fba0", + "world_name": "ahmadh.dcl.eth", + "category_id": "game" + }, + { + "place_id": "397c76cc-03e6-41c5-819c-a5f4ef6697ef", + "world_name": "artdecentralart.dcl.eth", + "category_id": "art" + }, + { + "place_id": "397c76cc-03e6-41c5-819c-a5f4ef6697ef", + "world_name": "artdecentralart.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "951ac818-ea37-4947-ae11-5b7d737c854c", + "world_name": "metavs.dcl.eth", + "category_id": "game" + }, + { + "place_id": "b3d849d4-b486-4fe1-b7c2-2879f868f5b5", + "world_name": "innoarea.dcl.eth", + "category_id": "business" + }, + { + "place_id": "7e71801f-7e9b-4858-ba82-25b751cc342b", + "world_name": "nikkifuego.dcl.eth", + "category_id": "game" + }, + { + "place_id": "7e71801f-7e9b-4858-ba82-25b751cc342b", + "world_name": "nikkifuego.dcl.eth", + "category_id": "business" + }, + { + "place_id": "922f7e60-5921-4862-ac6c-1ed8682d82b7", + "world_name": "soapbox.dcl.eth", + "category_id": "art" + }, + { + "place_id": "d9171ba6-aa3e-409c-93c3-361f55e9080f", + "world_name": "weronika.dcl.eth", + "category_id": "social" + }, + { + "place_id": "d9171ba6-aa3e-409c-93c3-361f55e9080f", + "world_name": "weronika.dcl.eth", + "category_id": "business" + }, + { + "place_id": "5f2c24f4-cb0b-4b93-bb8b-aebe1070122d", + "world_name": "grins.dcl.eth", + "category_id": "game" + }, + { + "place_id": "5f2c24f4-cb0b-4b93-bb8b-aebe1070122d", + "world_name": "grins.dcl.eth", + "category_id": "social" + }, + { + "place_id": "5f2c24f4-cb0b-4b93-bb8b-aebe1070122d", + "world_name": "grins.dcl.eth", + "category_id": "music" + }, + { + "place_id": "041bbe51-b69d-4409-988d-164bbcfb9326", + "world_name": "alltimehigh.dcl.eth", + "category_id": "art" + }, + { + "place_id": "45460e1f-dca2-4dd4-b753-48bc92685c95", + "world_name": "tracey.dcl.eth", + "category_id": "fashion" + }, + { + "place_id": "236cc6fb-0848-4a38-a3c8-fda6df2ced48", + "world_name": "sonidobaylando.dcl.eth", + "category_id": "social" + }, + { + "place_id": "236cc6fb-0848-4a38-a3c8-fda6df2ced48", + "world_name": "sonidobaylando.dcl.eth", + "category_id": "music" + }, + { + "place_id": "8ac67a97-6955-49f3-956d-da194319d1a5", + "world_name": "lordlike.dcl.eth", + "category_id": "social" + }, + { + "place_id": "2a63165b-c886-4342-83fc-7b9532979a06", + "world_name": "akasya.dcl.eth", + "category_id": "crypto" + }, + { + "place_id": "2a63165b-c886-4342-83fc-7b9532979a06", + "world_name": "akasya.dcl.eth", + "category_id": "art" + }, + { + "place_id": "2a63165b-c886-4342-83fc-7b9532979a06", + "world_name": "akasya.dcl.eth", + "category_id": "business" + }, + { + "place_id": "fcdeb62f-f8fe-4d56-979d-94322b528a01", + "world_name": "spacevin.dcl.eth", + "category_id": "art" + }, + { + "place_id": "fd14a3db-6120-4bd5-a7b4-dada6d5374fe", + "world_name": "e2hereum.eth", + "category_id": "art" + }, + { + "place_id": "a2719675-8493-4e73-a453-ef7ddef29e18", + "world_name": "motherhacker.dcl.eth", + "category_id": "art" + }, + { + "place_id": "45b1d62c-49be-4801-b57d-0875ea22c950", + "world_name": "luciddream.dcl.eth", + "category_id": "game" + }, + { + "place_id": "45b1d62c-49be-4801-b57d-0875ea22c950", + "world_name": "luciddream.dcl.eth", + "category_id": "business" + }, + { + "place_id": "1a9a219e-bcd1-488d-81ee-564a2f24e20f", + "world_name": "hausfuego.dcl.eth", + "category_id": "fashion" + }, + { + "place_id": "0f69a3eb-8bf5-4860-851e-b65bfa82e91c", + "world_name": "beatkoin.dcl.eth", + "category_id": "social" + }, + { + "place_id": "0f69a3eb-8bf5-4860-851e-b65bfa82e91c", + "world_name": "beatkoin.dcl.eth", + "category_id": "music" + }, + { + "place_id": "89e91c7b-05d2-4e35-8ad8-823bb09576f2", + "world_name": "argent.dcl.eth", + "category_id": "art" + }, + { + "place_id": "01d6354c-3668-4f82-ad7f-b8d12d060662", + "world_name": "poko.dcl.eth", + "category_id": "game" + }, + { + "place_id": "01d6354c-3668-4f82-ad7f-b8d12d060662", + "world_name": "poko.dcl.eth", + "category_id": "music" + }, + { + "place_id": "707e87e8-72a0-4dd2-ae23-28c7fcc79bcf", + "world_name": "metalivestudio.dcl.eth", + "category_id": "fashion" + }, + { + "place_id": "0f1cbe85-2cae-42df-aa10-b8b5607b6711", + "world_name": "cryptonico.dcl.eth", + "category_id": "art" + }, + { + "place_id": "fff43637-c0b9-4efa-b616-22b686479abb", + "world_name": "imagineharmony.dcl.eth", + "category_id": "social" + }, + { + "place_id": "fff43637-c0b9-4efa-b616-22b686479abb", + "world_name": "imagineharmony.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "f291b92a-6363-4892-a7b7-fbea4ae1b807", + "world_name": "sinney.eth", + "category_id": "art" + }, + { + "place_id": "7f893bbf-ef23-4dcd-9285-9cb5faf89055", + "world_name": "multinft.dcl.eth", + "category_id": "social" + }, + { + "place_id": "7f893bbf-ef23-4dcd-9285-9cb5faf89055", + "world_name": "multinft.dcl.eth", + "category_id": "music" + }, + { + "place_id": "1c72a33a-96db-4cb0-80f5-ea1df8e8372d", + "world_name": "thegreathall.dcl.eth", + "category_id": "social" + }, + { + "place_id": "1c72a33a-96db-4cb0-80f5-ea1df8e8372d", + "world_name": "thegreathall.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "a642b633-d935-4fbd-98a7-77fd99f56552", + "world_name": "rhagde.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "cea877b6-093f-41d0-baa0-5533e33b1e15", + "world_name": "spacetime.dcl.eth", + "category_id": "social" + }, + { + "place_id": "cea877b6-093f-41d0-baa0-5533e33b1e15", + "world_name": "spacetime.dcl.eth", + "category_id": "music" + }, + { + "place_id": "fd88381c-3126-4838-a66c-05e68ec43650", + "world_name": "ai47patos.dcl.eth", + "category_id": "game" + }, + { + "place_id": "070119c4-30e2-4493-87e2-2c375e8f4f68", + "world_name": "sophiaverse.eth", + "category_id": "art" + }, + { + "place_id": "35e1bf7d-aab7-4f9d-9cfd-4cb24797c17f", + "world_name": "mualabs.dcl.eth", + "category_id": "social" + }, + { + "place_id": "35e1bf7d-aab7-4f9d-9cfd-4cb24797c17f", + "world_name": "mualabs.dcl.eth", + "category_id": "music" + }, + { + "place_id": "2461e526-5498-4c71-8624-62dfb362a597", + "world_name": "twink.dcl.eth", + "category_id": "fashion" + }, + { + "place_id": "e0368a01-d1b2-4279-ac4d-48b30c0e4f6a", + "world_name": "trumpking.eth", + "category_id": "shop" + }, + { + "place_id": "98503079-03cb-4397-9633-fcd2be81f0e8", + "world_name": "mvagents.dcl.eth", + "category_id": "social" + }, + { + "place_id": "98503079-03cb-4397-9633-fcd2be81f0e8", + "world_name": "mvagents.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "5e20870d-3656-400e-bfd0-b0a42de48382", + "world_name": "urbantechnology.dcl.eth", + "category_id": "social" + }, + { + "place_id": "5e20870d-3656-400e-bfd0-b0a42de48382", + "world_name": "urbantechnology.dcl.eth", + "category_id": "shop" + }, + { + "place_id": "92fb09f9-e24c-4db4-a210-1eb0d382804d", + "world_name": "stargate.dcl.eth", + "category_id": "business" + }, + { + "place_id": "7b1f466d-18ef-44b1-b77f-a23aec0fff68", + "world_name": "sirtesla.dcl.eth", + "category_id": "art" + }, + { + "place_id": "ae9ad2c0-89c4-4cb6-addd-e46b0f2d001b", + "world_name": "shenyu.dcl.eth", + "category_id": "social" + }, + { + "place_id": "ae9ad2c0-89c4-4cb6-addd-e46b0f2d001b", + "world_name": "shenyu.dcl.eth", + "category_id": "music" + }, + { + "place_id": "7b6652dd-698f-48a9-a4ca-2af079163318", + "world_name": "ferritto.eth", + "category_id": "social" + }, + { + "place_id": "7b6652dd-698f-48a9-a4ca-2af079163318", + "world_name": "ferritto.eth", + "category_id": "shop" + }, + { + "place_id": "f9359fd7-620d-4a72-a794-40b0555cf841", + "world_name": "adricci.eth", + "category_id": "social" + }, + { + "place_id": "f9359fd7-620d-4a72-a794-40b0555cf841", + "world_name": "adricci.eth", + "category_id": "music" + }, + { + "place_id": "f9359fd7-620d-4a72-a794-40b0555cf841", + "world_name": "adricci.eth", + "category_id": "crypto" + }, + { + "place_id": "770e06e5-2046-4d45-bf31-06039575a1fb", + "world_name": "trumpking.dcl.eth", + "category_id": "social" + }, + { + "place_id": "770e06e5-2046-4d45-bf31-06039575a1fb", + "world_name": "trumpking.dcl.eth", + "category_id": "music" + }, + { + "place_id": "c205e6c5-a839-4027-b7c7-f8bda740d86e", + "world_name": "colleconline.dcl.eth", + "category_id": "art" + }, + { + "place_id": "f66585cc-e295-4455-bbf2-b9acfcec0cd4", + "world_name": "deuceeclipse.dcl.eth", + "category_id": "music" + }, + { + "place_id": "6ebe1ca6-6526-446f-9863-8855860f754a", + "world_name": "thestudio.dcl.eth", + "category_id": "art" + }, + { + "place_id": "c43ab82e-2f7b-482b-a7be-b0fdd2285da1", + "world_name": "rockstlighting.dcl.eth", + "category_id": "art" + }, + { + "place_id": "c43ab82e-2f7b-482b-a7be-b0fdd2285da1", + "world_name": "rockstlighting.dcl.eth", + "category_id": "social" + }, + { + "place_id": "4ff3ee0c-b058-4e27-b1d3-72ab8f70c5f7", + "world_name": "hirotokai.dcl.eth", + "category_id": "art" + }, + { + "place_id": "4ff3ee0c-b058-4e27-b1d3-72ab8f70c5f7", + "world_name": "hirotokai.dcl.eth", + "category_id": "education" + }, + { + "place_id": "4ff3ee0c-b058-4e27-b1d3-72ab8f70c5f7", + "world_name": "hirotokai.dcl.eth", + "category_id": "social" + }, + { + "place_id": "4fce5442-7369-4b41-9b51-c01fc529ab40", + "world_name": "phuturemusic.dcl.eth", + "category_id": "social" + }, + { + "place_id": "1bc61493-7aad-4cb9-9a78-7e305807b7e7", + "world_name": "soundclash.dcl.eth", + "category_id": "art" + }, + { + "place_id": "91aeff15-ae9f-460c-bbf7-bf2330df7f32", + "world_name": "rumraisin.dcl.eth", + "category_id": "social" + } + ] +}