From 2fe3092a7a37d47d87a8319c529577c64134df86 Mon Sep 17 00:00:00 2001 From: lauti7 Date: Fri, 29 Sep 2023 17:16:42 -0300 Subject: [PATCH 01/48] feat: new migration for categories --- .../1695652319062_update-categories-table.ts | 47 ++++++++++++++++ ...695656505396_place-category-pivot-table.ts | 42 ++++++++++++++ ...5755211009_migrate-featured-to-category.ts | 55 +++++++++++++++++++ .../1695755235057_compute-poi-to-category.ts | 38 +++++++++++++ ...16_compute-places-with-categories-by-ai.ts | 8 +++ 5 files changed, 190 insertions(+) create mode 100644 src/migrations/1695652319062_update-categories-table.ts create mode 100644 src/migrations/1695656505396_place-category-pivot-table.ts create mode 100644 src/migrations/1695755211009_migrate-featured-to-category.ts create mode 100644 src/migrations/1695755235057_compute-poi-to-category.ts create mode 100644 src/migrations/1695921904116_compute-places-with-categories-by-ai.ts diff --git a/src/migrations/1695652319062_update-categories-table.ts b/src/migrations/1695652319062_update-categories-table.ts new file mode 100644 index 00000000..1bd4bff8 --- /dev/null +++ b/src/migrations/1695652319062_update-categories-table.ts @@ -0,0 +1,47 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" + +import CategoryModel from "../entities/Category/model" +import PlaceModel from "../entities/Place/model" + +export const shorthands: ColumnDefinitions | undefined = undefined + +const INITIAL_CATEGORIES = [ + "poi", + "featured", + "shop", + "social", + "quests", + "club", +] + +export async function up(pgm: MigrationBuilder): Promise { + pgm.dropColumn(PlaceModel.tableName, "categories") + pgm.dropColumn(CategoryModel.tableName, "places_counter") + + for (const category of INITIAL_CATEGORIES) { + pgm.sql( + `INSERT INTO "${CategoryModel.tableName}" (name, active) VALUES ('${category}', TRUE)` + ) + } +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.addColumn(PlaceModel.tableName, { + categories: { + type: "VARCHAR(50)[]", + notNull: true, + default: "{}", + }, + }) + + pgm.addColumn(CategoryModel.tableName, { + places_counter: { + type: "INTEGER", + notNull: true, + default: 0, + }, + }) + + pgm.sql(`TRUNCATE "${CategoryModel.tableName}"`) +} diff --git a/src/migrations/1695656505396_place-category-pivot-table.ts b/src/migrations/1695656505396_place-category-pivot-table.ts new file mode 100644 index 00000000..f96bbd3e --- /dev/null +++ b/src/migrations/1695656505396_place-category-pivot-table.ts @@ -0,0 +1,42 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { Type } from "decentraland-gatsby/dist/entities/Database/types" +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" + +import CategoryModel from "../entities/Category/model" +import PlaceModel from "../entities/Place/model" +import PlaceCategories from "../entities/PlaceCategories/model" + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise { + pgm.createTable(PlaceCategories.tableName, { + place_id: { + type: Type.UUID, + notNull: true, + }, + category_id: { + type: Type.Varchar(50), + notNull: true, + }, + }) + + pgm.addConstraint(PlaceCategories.tableName, "place_id_fk", { + foreignKeys: { + columns: "place_id", + references: `${PlaceModel.tableName}(id)`, + }, + }) + + pgm.addConstraint(PlaceCategories.tableName, "category_id_fk", { + foreignKeys: { + columns: "category_id", + references: `${CategoryModel.tableName}(name)`, + }, + }) + + pgm.createIndex(PlaceCategories.tableName, ["place_id", "category_id"]) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.dropTable(PlaceCategories.tableName) +} diff --git a/src/migrations/1695755211009_migrate-featured-to-category.ts b/src/migrations/1695755211009_migrate-featured-to-category.ts new file mode 100644 index 00000000..966324eb --- /dev/null +++ b/src/migrations/1695755211009_migrate-featured-to-category.ts @@ -0,0 +1,55 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + +import { Type } from "decentraland-gatsby/dist/entities/Database/types" +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" + +import PlaceModel from "../entities/Place/model" +import PlaceCategories from "../entities/PlaceCategories/model" + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise { + const currentFeaturedPlaces: { id: string }[] = await pgm.db.select( + `SELECT id FROM ${PlaceModel.tableName} WHERE featured IS TRUE` + ) + + const featuredPlaces = currentFeaturedPlaces + .map(({ id }) => `('${id}', 'featured')`) + .join(",") + + pgm.sql( + `INSERT INTO ${PlaceCategories.tableName} (place_id, category_id) VALUES ${featuredPlaces}` + ) + + pgm.dropColumns(PlaceModel.tableName, ["featured", "featured_image"]) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.addColumns(PlaceModel.tableName, { + featured: { + notNull: true, + type: Type.Boolean, + default: false, + }, + featured_image: { + notNull: false, + type: Type.Text, + }, + }) + + const currentFeaturedPlaces = ( + (await pgm.db.select( + `SELECT place_id as id FROM ${PlaceCategories.tableName} WHERE category_id = 'featured'` + )) as { id: string }[] + ) + .map(({ id }) => `'${id}'`) + .join(",") + + pgm.sql( + `UPDATE ${PlaceModel.tableName} SET featured = true WHERE id IN (${currentFeaturedPlaces})` + ) + + pgm.sql( + `DELETE FROM ${PlaceCategories.tableName} WHERE place_id IN (${currentFeaturedPlaces}) AND category_id = 'featured'` + ) +} diff --git a/src/migrations/1695755235057_compute-poi-to-category.ts b/src/migrations/1695755235057_compute-poi-to-category.ts new file mode 100644 index 00000000..78d9f77c --- /dev/null +++ b/src/migrations/1695755235057_compute-poi-to-category.ts @@ -0,0 +1,38 @@ +/* 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" +import { getPois } from "../modules/pois" + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise { + const pois = (await getPois()).map((p) => `'${p}'`).join(",") + + const findEnabledByPositionsSQL = ` + SELECT id FROM ${PlaceModel.tableName} + WHERE "disabled" is false + AND "world" is false + AND "base_position" IN ( + SELECT DISTINCT("base_position") + FROM ${PlacePositionModel.tableName} "pp" + WHERE "pp"."position" IN (${pois}) + ) + ` + + const currentPois: { id: string }[] = await pgm.db.select( + findEnabledByPositionsSQL + ) + + const poiPlaces = currentPois.map(({ id }) => `('${id}', 'poi')`).join(",") + + pgm.sql( + `INSERT INTO ${PlaceCategories.tableName} (place_id, category_id) VALUES ${poiPlaces}` + ) +} + +export async function down(pgm: MigrationBuilder): Promise { + pgm.sql(`DELETE FROM ${PlaceCategories.tableName} WHERE category_id = 'poi'`) +} diff --git a/src/migrations/1695921904116_compute-places-with-categories-by-ai.ts b/src/migrations/1695921904116_compute-places-with-categories-by-ai.ts new file mode 100644 index 00000000..9bd944d6 --- /dev/null +++ b/src/migrations/1695921904116_compute-places-with-categories-by-ai.ts @@ -0,0 +1,8 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { ColumnDefinitions, MigrationBuilder } from "node-pg-migrate" + +export const shorthands: ColumnDefinitions | undefined = undefined + +export async function up(pgm: MigrationBuilder): Promise {} + +export async function down(pgm: MigrationBuilder): Promise {} From d1513bcec8d4148e233f94c3d0e67ba4f79ff9c8 Mon Sep 17 00:00:00 2001 From: lauti7 Date: Fri, 29 Sep 2023 17:50:53 -0300 Subject: [PATCH 02/48] feat: PlaceCategories entity model --- src/entities/PlaceCategories/model.ts | 54 +++++++++++++++++++++++++++ src/entities/PlaceCategories/types.ts | 4 ++ 2 files changed, 58 insertions(+) create mode 100644 src/entities/PlaceCategories/model.ts create mode 100644 src/entities/PlaceCategories/types.ts diff --git a/src/entities/PlaceCategories/model.ts b/src/entities/PlaceCategories/model.ts new file mode 100644 index 00000000..866b28c3 --- /dev/null +++ b/src/entities/PlaceCategories/model.ts @@ -0,0 +1,54 @@ +import { Model } from "decentraland-gatsby/dist/entities/Database/model" +import { + SQL, + join, + table, + values, +} from "decentraland-gatsby/dist/entities/Database/utils" + +import { PlaceCategoriesAttributes } from "./types" + +export default class PlaceCategories extends Model { + static tableName: string = "place_categories" + + static async getCountPerCategory() { + const query = SQL`SELECT category_id, COUNT(place_id) as count FROM ${table( + PlaceCategories + )} GROUP BY category_id` + + const result = await PlaceCategories.namedQuery<{ + category_id: string + count: number + }>("get_count_per_category", query) + + return result + } + + static async removeCategoryFromPlace(placeId: string, category: string) { + const query = SQL`DELETE FROM ${table( + PlaceCategories + )} WHERE place_id = ${placeId} AND category_id = ${category}` + + await PlaceCategories.namedQuery("remove_place_category", query) + } + + static async addCategoryToPlace(placeId: string, category: string) { + const query = SQL`INSERT INTO ${table( + PlaceCategories + )} (place_id, category_id) VALUES (${placeId}, ${category})` + + await PlaceCategories.namedQuery("add_category_to_place", query) + } + + static async addCategoriesToPlaces(bulk: string[][]) { + const mappedValues = bulk.map((val) => values(val)) + + const valuesInBulk = join(mappedValues) + + const query = SQL`INSERT INTO ${table( + PlaceCategories + )} (place_id, category_id) VALUES ${valuesInBulk}` + + await PlaceCategories.namedQuery("add_categories_to_places", query) + } +} diff --git a/src/entities/PlaceCategories/types.ts b/src/entities/PlaceCategories/types.ts new file mode 100644 index 00000000..c4a6a9bb --- /dev/null +++ b/src/entities/PlaceCategories/types.ts @@ -0,0 +1,4 @@ +export type PlaceCategoriesAttributes = { + place_id: string + category_id: string +} From 949f6398c861f05add406058fb55a6445c8d6c08 Mon Sep 17 00:00:00 2001 From: lauti7 Date: Fri, 29 Sep 2023 17:51:58 -0300 Subject: [PATCH 03/48] feat: update Category entity --- src/entities/Category/model.ts | 24 +++++++++++++++--------- src/entities/Category/routes.ts | 7 ++++++- src/entities/Category/types.ts | 5 +++++ 3 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/entities/Category/model.ts b/src/entities/Category/model.ts index cccfa24d..b26591d3 100644 --- a/src/entities/Category/model.ts +++ b/src/entities/Category/model.ts @@ -1,21 +1,27 @@ import { Model } from "decentraland-gatsby/dist/entities/Database/model" import { SQL, table } from "decentraland-gatsby/dist/entities/Database/utils" -import { CategoryAttributes } from "./types" +import PlaceCategories from "../PlaceCategories/model" +import { CategoryAttributes, CategoryWithPlaceCount } from "./types" export default class CategoryModel extends Model { static tableName = "categories" static primaryKey = "name" static findCategoriesWithPlaces = async () => { - const query = SQL`SELECT * FROM ${table(CategoryModel)} - WHERE places_counter != 0 - AND active is true - ORDER BY name ASC` - const categoriesFound = await CategoryModel.namedQuery( - "find_categories_with_places", - query - ) + const query = SQL` + SELECT c.name, count(pc.place_id) FROM ${table(CategoryModel)} c + INNER JOIN ${table(PlaceCategories)} ON pc pc.category_id = c.name + WHERE c.active IS true + GROUP BY pc.category_id + ORDER BY c.name ASC` + + const categoriesFound = + await CategoryModel.namedQuery( + "find_categories_with_places_count", + query + ) + return categoriesFound } } diff --git a/src/entities/Category/routes.ts b/src/entities/Category/routes.ts index 58fd0d8e..cf74984b 100644 --- a/src/entities/Category/routes.ts +++ b/src/entities/Category/routes.ts @@ -10,5 +10,10 @@ export default routes((router) => { export async function getCategoryList() { const categories = await CategoryModel.findCategoriesWithPlaces() - return new ApiResponse(categories) + return new ApiResponse( + categories.map((category) => ({ + ...category, + count: Number(category.count), + })) + ) } diff --git a/src/entities/Category/types.ts b/src/entities/Category/types.ts index e5b13772..95a8f60e 100644 --- a/src/entities/Category/types.ts +++ b/src/entities/Category/types.ts @@ -5,4 +5,9 @@ export type CategoryAttributes = { updated_at: Date } +export type CategoryWithPlaceCount = { + name: string + count: string +} + export const ALL_PLACE_CATEGORY = "all" From d0ac286a206f2c57157e71a77e2719a24d1b332b Mon Sep 17 00:00:00 2001 From: lauti7 Date: Fri, 29 Sep 2023 17:53:15 -0300 Subject: [PATCH 04/48] feat: filter places by categories --- src/entities/Place/model.test.ts | 13 +++++- src/entities/Place/model.ts | 45 ++++++++++++++++--- src/entities/Place/routes/getPlaceList.ts | 4 +- .../Place/routes/getPlaceMostActiveList.ts | 2 + .../Place/routes/getPlaceUserVisitsList.ts | 2 + src/entities/Place/schemas.ts | 6 +++ src/entities/Place/types.ts | 2 + src/entities/Social/routes.ts | 1 + 8 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/entities/Place/model.test.ts b/src/entities/Place/model.test.ts index ff5e710f..e4f1a55b 100644 --- a/src/entities/Place/model.test.ts +++ b/src/entities/Place/model.test.ts @@ -161,6 +161,7 @@ describe(`findWithAggregates`, () => { order_by: "created_at", order: "desc", search: "", + categories: [], }) ).toEqual([placeGenesisPlazaWithAggregatedAttributes]) expect(namedQuery.mock.calls.length).toBe(1) @@ -199,6 +200,7 @@ describe(`findWithAggregates`, () => { order: "desc", user: userLikeTrue.user, search: "", + categories: [], }) ).toEqual([placeGenesisPlazaWithAggregatedAttributes]) expect(namedQuery.mock.calls.length).toBe(1) @@ -243,6 +245,7 @@ describe(`findWithAggregates`, () => { order: "desc", user: userLikeTrue.user, search: "decentraland atlas", + categories: [], }) ).toEqual([placeGenesisPlazaWithAggregatedAttributes]) expect(namedQuery.mock.calls.length).toBe(1) @@ -291,6 +294,7 @@ describe(`findWithAggregates`, () => { order: "desc", user: userLikeTrue.user, search: "de", + categories: [], }) ).toEqual([]) expect(namedQuery.mock.calls.length).toBe(0) @@ -307,6 +311,7 @@ describe(`countPlaces`, () => { only_highlighted: false, positions: ["-9,-9"], search: "", + categories: [], }) ).toEqual(1) expect(namedQuery.mock.calls.length).toBe(1) @@ -316,7 +321,7 @@ describe(`countPlaces`, () => { expect(sql.text.trim().replace(/\s{2,}/gi, " ")).toEqual( ` SELECT - count(*) as "total" + count(DISTINCT p.id) as "total" FROM "places" p WHERE p."disabled" is false @@ -338,6 +343,7 @@ describe(`countPlaces`, () => { only_highlighted: false, positions: ["-9,-9"], search: "decentraland atlas", + categories: [], }) ).toEqual(1) expect(namedQuery.mock.calls.length).toBe(1) @@ -347,7 +353,7 @@ describe(`countPlaces`, () => { expect(sql.text.trim().replace(/\s{2,}/gi, " ")).toEqual( ` SELECT - count(*) as "total" + count(DISTINCT p.id) as "total" FROM "places" p , ts_rank_cd(p.textsearch, to_tsquery($1)) as rank WHERE p."disabled" is false @@ -370,6 +376,7 @@ describe(`countPlaces`, () => { positions: ["-9,-9"], user: "ABC", search: "asdads", + categories: [], }) ).toEqual(0) expect(namedQuery.mock.calls.length).toBe(0) @@ -383,6 +390,7 @@ describe(`countPlaces`, () => { positions: ["-9,-9"], user: "ABC", search: "", + categories: [], }) ).toEqual(0) }) @@ -484,6 +492,7 @@ describe(`findWithHotScenes`, () => { order_by: "created_at", order: "desc", search: "", + categories: [], }, [hotSceneGenesisPlaza] ) diff --git a/src/entities/Place/model.ts b/src/entities/Place/model.ts index 27f5f406..16bfdf55 100644 --- a/src/entities/Place/model.ts +++ b/src/entities/Place/model.ts @@ -19,6 +19,7 @@ import { HotScene } from "decentraland-gatsby/dist/utils/api/Catalyst.types" import { diff, unique } from "radash/dist/array" import isEthereumAddress from "validator/lib/isEthereumAddress" +import PlaceCategories from "../PlaceCategories/model" import PlacePositionModel from "../PlacePosition/model" import UserFavoriteModel from "../UserFavorite/model" import UserLikesModel from "../UserLikes/model" @@ -186,6 +187,15 @@ export default class PlaceModel extends Model { UserLikesModel )} ul on p.id = ul.place_id AND ul."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( @@ -195,8 +205,6 @@ export default class PlaceModel extends Model { WHERE p."disabled" is false AND "world" is false ${conditional(!!options.search, SQL`AND rank > 0`)} - ${conditional(options.only_featured, SQL`AND featured = TRUE`)} - ${conditional(options.only_highlighted, SQL`AND highlighted = TRUE`)} ${conditional( options.positions?.length > 0, SQL`AND p.base_position IN ( @@ -225,18 +233,20 @@ export default class PlaceModel extends Model { | "only_featured" | "only_highlighted" | "search" + | "categories" > ) { const isMissingEthereumAddress = options.user && !isEthereumAddress(options.user) const searchIsEmpty = options.search && options.search.length < 3 + if (isMissingEthereumAddress || searchIsEmpty) { return 0 } const query = SQL` SELECT - count(*) as "total" + count(DISTINCT p.id) as "total" FROM ${table(this)} p ${conditional( !!options.user && options.only_favorites, @@ -244,6 +254,15 @@ 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( @@ -251,10 +270,7 @@ export default class PlaceModel extends Model { )})) as rank` )} WHERE - p."disabled" is false - AND "world" is false - ${conditional(options.only_featured, SQL`AND featured = TRUE`)} - ${conditional(options.only_highlighted, SQL`AND highlighted = TRUE`)} + p."disabled" is false AND "world" is false ${conditional( options.positions?.length > 0, SQL`AND p.base_position IN ( @@ -554,6 +570,21 @@ export default class PlaceModel extends Model { return results[0].total } + static async findEnabledByCategory( + category: string + ): Promise { + const sql = SQL` + SELECT p.* + FROM ${table(this)} p + ${SQL`INNER JOIN ${table( + PlaceCategories + )} pc ON p.id = pc.place_id AND pc.category_id = ${SQL`${category}`}`} + WHERE + p."disabled" is false AND "world" is false + ` + return await this.namedQuery("find_enabled_by_category", sql) + } + /** * For reference: https://www.evanmiller.org/how-not-to-sort-by-average-rating.html * We're calculating the lower bound of a 95% confidence interval diff --git a/src/entities/Place/routes/getPlaceList.ts b/src/entities/Place/routes/getPlaceList.ts index f5dec4ec..c7e469c7 100644 --- a/src/entities/Place/routes/getPlaceList.ts +++ b/src/entities/Place/routes/getPlaceList.ts @@ -51,6 +51,7 @@ export const getPlaceList = Router.memo( oneOf(ctx.url.searchParams.get("order"), ["asc", "desc"]) || "desc", with_realms_detail: ctx.url.searchParams.get("with_realms_detail"), search: ctx.url.searchParams.get("search"), + categories: ctx.url.searchParams.getAll("categories"), }) const userAuth = await withAuthOptional(ctx) @@ -70,6 +71,7 @@ export const getPlaceList = Router.memo( order_by: query.order_by, order: query.order, search: query.search, + categories: query.categories, } const [data, total, hotScenes, sceneStats] = await Promise.all([ @@ -86,6 +88,6 @@ export const getPlaceList = Router.memo( sceneStats ) - return new ApiResponse(response, { total }) + return new ApiResponse(response, { total: Number(total) }) } ) diff --git a/src/entities/Place/routes/getPlaceMostActiveList.ts b/src/entities/Place/routes/getPlaceMostActiveList.ts index cfb6f7b3..37aade40 100644 --- a/src/entities/Place/routes/getPlaceMostActiveList.ts +++ b/src/entities/Place/routes/getPlaceMostActiveList.ts @@ -30,6 +30,7 @@ export const getPlaceMostActiveList = Router.memo( oneOf(ctx.url.searchParams.get("order"), ["asc", "desc"]) || "desc", with_realms_detail: ctx.url.searchParams.get("with_realms_detail"), search: ctx.url.searchParams.get("search"), + categories: ctx.url.searchParams.getAll("categories"), }) const [hotScenes, sceneStats] = await Promise.all([ @@ -67,6 +68,7 @@ export const getPlaceMostActiveList = Router.memo( order_by: PlaceListOrderBy.MOST_ACTIVE, order: query.order, search: query.search, + categories: ctx.url.searchParams.getAll("categories"), } const { offset, limit, order, ...extraOptions } = options diff --git a/src/entities/Place/routes/getPlaceUserVisitsList.ts b/src/entities/Place/routes/getPlaceUserVisitsList.ts index 8354e2f8..5ff6aa6f 100644 --- a/src/entities/Place/routes/getPlaceUserVisitsList.ts +++ b/src/entities/Place/routes/getPlaceUserVisitsList.ts @@ -26,6 +26,7 @@ export const getPlaceUserVisitsList = Router.memo( order: ctx.url.searchParams.get("order") || "desc", with_realms_detail: ctx.url.searchParams.get("with_realms_detail"), search: ctx.url.searchParams.get("search"), + categories: ctx.url.searchParams.getAll("categories"), }) const [hotScenes, sceneStats] = await Promise.all([ @@ -53,6 +54,7 @@ export const getPlaceUserVisitsList = Router.memo( order_by: PlaceListOrderBy.MOST_ACTIVE, order: query.order, search: query.order, + categories: ctx.url.searchParams.getAll("categories"), } const { offset, limit, order, ...extraOptions } = options diff --git a/src/entities/Place/schemas.ts b/src/entities/Place/schemas.ts index 3c4eebaa..683dd9ba 100644 --- a/src/entities/Place/schemas.ts +++ b/src/entities/Place/schemas.ts @@ -74,6 +74,12 @@ export const getPlaceListQuerySchema = schema({ "Filter places 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 places by their category", + nullable: true as any, + }, }, }) diff --git a/src/entities/Place/types.ts b/src/entities/Place/types.ts index 2fd39bc4..288bc8bb 100644 --- a/src/entities/Place/types.ts +++ b/src/entities/Place/types.ts @@ -68,6 +68,7 @@ export type GetPlaceListQuery = { order: string with_realms_detail: string search: string + categories: string[] } export type PlaceListOptions = { @@ -80,6 +81,7 @@ export type PlaceListOptions = { order_by: string order: string search: string + categories: string[] } export type FindWithAggregatesOptions = PlaceListOptions & { diff --git a/src/entities/Social/routes.ts b/src/entities/Social/routes.ts index 6b8f31c0..9f1dbce9 100644 --- a/src/entities/Social/routes.ts +++ b/src/entities/Social/routes.ts @@ -51,6 +51,7 @@ export async function injectPlaceMetadata(req: Request, res: Response) { order_by: PlaceListOrderBy.LIKE_SCORE_BEST, order: "asc", search: "", + categories: [], }) )[0] } From defc3fddfebf23506abdc32c3a18c2ebe6dff3da Mon Sep 17 00:00:00 2001 From: lauti7 Date: Fri, 29 Sep 2023 17:54:01 -0300 Subject: [PATCH 05/48] feat: fetch categories --- src/api/Places.ts | 9 +++++++++ src/hooks/usePlaceCategories.ts | 23 +++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 src/hooks/usePlaceCategories.ts diff --git a/src/api/Places.ts b/src/api/Places.ts index 33fb9976..9884e178 100644 --- a/src/api/Places.ts +++ b/src/api/Places.ts @@ -192,4 +192,13 @@ export default class Places extends API { this.options({ method: "PUT" }).json(params).authorization({ sign: true }) ) } + + async getCategories() { + const result = await super.fetch<{ + ok: boolean + data: { name: string; count: number }[] + }>("/categories") + + return result.data + } } diff --git a/src/hooks/usePlaceCategories.ts b/src/hooks/usePlaceCategories.ts new file mode 100644 index 00000000..3881be2e --- /dev/null +++ b/src/hooks/usePlaceCategories.ts @@ -0,0 +1,23 @@ +import { useEffect, useState } from "react" + +import useAsyncTask from "decentraland-gatsby/dist/hooks/useAsyncTask" + +import Places from "../api/Places" + +export default function usePlaceCategories() { + const [categories, setCategories] = useState< + { name: string; count: number }[] + >([]) + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const [_, getCategories] = useAsyncTask(async () => { + const categories = await Places.get().getCategories() + setCategories([...categories]) + }, []) + + useEffect(() => { + getCategories() + }, []) + + return categories +} From 18b8d1611801fc0969a8d6b032fabd5002b30add Mon Sep 17 00:00:00 2001 From: lauti7 Date: Fri, 29 Sep 2023 17:56:25 -0300 Subject: [PATCH 06/48] feat: categorie component --- src/components/Categories/CategoriesList.css | 40 ++++++++++++++++ src/components/Categories/CategoriesList.tsx | 35 ++++++++++++++ .../Categories/CategoriesSection.css | 40 ++++++++++++++++ .../Categories/CategoriesSection.tsx | 27 +++++++++++ src/components/Categories/CategoryButton.css | 18 ++++++++ src/components/Categories/CategoryButton.tsx | 41 +++++++++++++++++ src/components/Categories/CategoryFilter.css | 38 +++++++++++++++ src/components/Categories/CategoryFilter.tsx | 46 +++++++++++++++++++ src/components/Icon/Close.tsx | 22 +++++++++ src/components/Icon/Trash.tsx | 15 ++++++ 10 files changed, 322 insertions(+) create mode 100644 src/components/Categories/CategoriesList.css create mode 100644 src/components/Categories/CategoriesList.tsx create mode 100644 src/components/Categories/CategoriesSection.css create mode 100644 src/components/Categories/CategoriesSection.tsx create mode 100644 src/components/Categories/CategoryButton.css create mode 100644 src/components/Categories/CategoryButton.tsx create mode 100644 src/components/Categories/CategoryFilter.css create mode 100644 src/components/Categories/CategoryFilter.tsx create mode 100644 src/components/Icon/Close.tsx create mode 100644 src/components/Icon/Trash.tsx diff --git a/src/components/Categories/CategoriesList.css b/src/components/Categories/CategoriesList.css new file mode 100644 index 00000000..53c2e4ee --- /dev/null +++ b/src/components/Categories/CategoriesList.css @@ -0,0 +1,40 @@ +.categories-list__box { + background-color: var(--secondary-on-modal-hover); + width: 248px; + padding: 16px; + border-radius: 10px; +} + +.categories-list__title { + margin-bottom: 15px; + display: flex; + align-items: center; +} + +.categories-list__title > p { + color: var(--secondary-text); + letter-spacing: 1px; + font-weight: 700; + font-size: 14px; + margin: 0 16px 0 0; + text-transform: uppercase; +} + +.categories-list__title > .ui.label { + font-weight: 700; + font-size: 8px; + background-color: var(--primary); + padding: 2px 5px; + line-height: normal; + border-radius: 3px; + color: var(--text-on-primary); +} + +.categories-list__listing { + display: flex; + flex-direction: column; +} + +.categories-list__listing > .category-btn { + margin-bottom: 8px; +} diff --git a/src/components/Categories/CategoriesList.tsx b/src/components/Categories/CategoriesList.tsx new file mode 100644 index 00000000..f2e47932 --- /dev/null +++ b/src/components/Categories/CategoriesList.tsx @@ -0,0 +1,35 @@ +import React from "react" + +import Label from "semantic-ui-react/dist/commonjs/elements/Label" + +import { CategoryButton } from "./CategoryButton" + +import "./CategoriesList.css" + +type CategoriesListProps = { + onSelect: (id: string) => void + categories: string[] +} + +export const CategoriesList = ({ + categories, + onSelect, +}: CategoriesListProps) => { + return ( +
+
+

Categories

+ +
+
+ {categories.map((category) => ( + onSelect(category)} + rounded + /> + ))} +
+
+ ) +} diff --git a/src/components/Categories/CategoriesSection.css b/src/components/Categories/CategoriesSection.css new file mode 100644 index 00000000..f3898844 --- /dev/null +++ b/src/components/Categories/CategoriesSection.css @@ -0,0 +1,40 @@ +.categories-section__box { + background-color: var(--secondary-on-modal-hover); + width: 100%; + padding: 35px; +} + +.categories-section__title { + margin-bottom: 25px; + display: flex; + align-items: center; +} + +.categories-section__title > p { + color: var(--secondary-text); + letter-spacing: 1px; + font-weight: 700; + font-size: 14px; + margin: 0 16px 0 0; + text-transform: uppercase; +} + +.categories-section__title > .ui.label { + font-weight: 700; + font-size: 8px; + background-color: var(--primary); + line-height: normal; + padding: 2px 5px; + border-radius: 3px; + color: var(--text-on-primary); +} + +.categories-section__slider { + display: flex; + flex-wrap: wrap; + width: 100%; +} + +.categories-section__slider > .category-btn { + margin: 0 12px 12px 0; +} diff --git a/src/components/Categories/CategoriesSection.tsx b/src/components/Categories/CategoriesSection.tsx new file mode 100644 index 00000000..561c2065 --- /dev/null +++ b/src/components/Categories/CategoriesSection.tsx @@ -0,0 +1,27 @@ +import React from "react" + +import Label from "semantic-ui-react/dist/commonjs/elements/Label" + +import { CategoryButton } from "./CategoryButton" + +import "./CategoriesSection.css" + +type CategoriesProps = { + categories: string[] +} + +export const CategoriesSection = ({ categories }: CategoriesProps) => { + return ( +
+
+

Explore Categories

+ +
+
+ {categories.map((category) => ( + + ))} +
+
+ ) +} diff --git a/src/components/Categories/CategoryButton.css b/src/components/Categories/CategoryButton.css new file mode 100644 index 00000000..55f1f866 --- /dev/null +++ b/src/components/Categories/CategoryButton.css @@ -0,0 +1,18 @@ +.ui.button.category-btn { + background-color: var(--text-on-primary); + font-weight: normal; +} + +.ui.button.category-btn.category-btn--rounded { + border-radius: 24px; +} + +.ui.button.category-btn.category-btn--active { + background-color: var(--primary); + color: var(--text-on-primary); +} + +.ui.button.category-btn.category-btn--disabled { + cursor: default; + background-color: var(--secondary-on-modal-hover); +} diff --git a/src/components/Categories/CategoryButton.tsx b/src/components/Categories/CategoryButton.tsx new file mode 100644 index 00000000..c694a402 --- /dev/null +++ b/src/components/Categories/CategoryButton.tsx @@ -0,0 +1,41 @@ +import React from "react" + +import useFormatMessage from "decentraland-gatsby/dist/hooks/useFormatMessage" +import { + Button, + ButtonProps, +} from "decentraland-ui/dist/components/Button/Button" + +import "./CategoryButton.css" + +type CategoryButtonProps = ButtonProps & { + onClick?: () => void + rounded?: boolean + active?: boolean + disabled?: boolean + category: string +} + +export const CategoryButton = ({ + category, + rounded, + active, + disabled, + ...rest +}: CategoryButtonProps) => { + const l = useFormatMessage() + + return ( +