diff --git a/.gitignore b/.gitignore index af5148e..7ddeb19 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ packages/* node_modules/ yarn-error.log package-lock.json +*.tsbuildinfo diff --git a/README.md b/README.md index 1204652..2994263 100644 --- a/README.md +++ b/README.md @@ -4,22 +4,40 @@ ## Introduction -Hostelworld on Roids (H.O.R) is a browser extension designed to enhance your experience while searching and selecting -hostels. +Hostelworld on Roids (H.O.R) is an open source browser extension designed to enhance your experience +while searching and selecting hostels. -It eliminates obstructions from featured/promoted properties and unlocks hidden native features on hostelworld, such as -displaying guest-origin stats per property and searched cities. Moreover, it lists some properties which may be -unavailable due to scheduling conflicts, but would have been more ideal for you if your travel dates were more flexible. +It eliminates obstructions from featured/promoted properties and unlocks hidden native features on Hostelworld. Moreover, +it lists some properties which may be unavailable due to scheduling conflicts, but would have been more ideal for you if +your travel dates were more flexible. -In addition, the extension provides extra metrics by analyzing the availability and reviews per property, presenting +In a nutshell, the extension provides extra metrics by analyzing the availability and reviews per property, presenting this data directly on the property card. This might help to facilitate your decision-making process when selecting a hostel. +Here's what it does: + +* Removes featured/promoted property statue obstructions from search results. +* Surfaces mobile-exclusive deals that are normally reserved only for the mobile app. +* Displays guest-origin stats per property, showing where upcoming guests are arriving from. +* Shows the gender split (male, female, solo travelers) and age group distribution of past guests. +* Checks room availability by type (mixed dorms, female-only dorms, private rooms) and their capacity. +* Assigns demographic badges like "Great for Solo", "Female Friendly", "Young Crowd", and more. +* Offers a custom filter system to narrow down properties by badge, demographics, and age groups. + H.O.R was developed as a personal side project to simplify the process of selecting the "best" hostel from an extensive range of good ones. It proved & continues to prove useful during my travels, assisting me in finding the most suitable place to stay depending on my mood, whether I was looking for some busy or quieter place to stay at. :) +## Support + +If you find this extension useful, consider buying me a coffee! It helps keep the project alive and motivates +further development. + +[![Buy Me a Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-ffdd00?style=for-the-badge&logo=buy-me-a-coffee&logoColor=black)](https://buymeacoffee.com/karamanisdev) + + ## Installation [![Chrome Web Store Version](https://img.shields.io/chrome-web-store/v/dfilmjjmeegkakfmnadkimgflocnnnbg.svg?style=for-the-badge)](https://chrome.google.com/webstore/detail/dfilmjjmeegkakfmnadkimgflocnnnbg) diff --git a/babel.config.js b/babel.config.js index 131a72c..426b8f2 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,14 +1,24 @@ 'use strict' -module.exports = { - presets: [ - [ - '@babel/preset-env', +module.exports = (api) => { + api.cache(true) + + return { + presets: [ + [ + '@babel/preset-env', + { + useBuiltIns: 'usage', + corejs: '3', + targets: '> 1%, not dead' + } + ] + ], + overrides: [ { - useBuiltIns: 'usage', - corejs: '3', - targets: '> 1%, not dead' + test: /\.tsx$/, + presets: ['babel-preset-solid'] } ] - ] + } } diff --git a/eslint.config.mjs b/eslint.config.mjs index b1c23a5..3a4170c 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -24,17 +24,23 @@ export default [ 'plugin:@typescript-eslint/recommended' ), { - files: ['**/*.{ts,js,mjs}'], + files: ['**/*.{ts,tsx,js,mjs}'], plugins: { unicorn }, languageOptions: { globals: { - ...globals.node + ...globals.node, + ...globals.browser }, parser: tsParser, ecmaVersion: 2023, - sourceType: 'module' + sourceType: 'module', + parserOptions: { + ecmaFeatures: { + jsx: true + } + } }, rules: { 'max-len': ['error', { code: 120 }], @@ -55,6 +61,10 @@ export default [ }], 'import/no-useless-path-segments': ['error', { noUselessIndex: true + }], + 'import/no-cycle': ['error', { + maxDepth: Infinity, + ignoreExternal: true }] } } diff --git a/package.json b/package.json index f0a6a47..769c2ee 100644 --- a/package.json +++ b/package.json @@ -2,17 +2,21 @@ "name": "hostelworld-on-roids-extension", "engines": { "npm": "^11.4.2", - "yarn": "^4.9.2", + "yarn": "^4.12.0", "node": "^24.3.0" }, - "packageManager": "yarn@4.9.2", + "packageManager": "yarn@4.12.0", + "sideEffects": [ + "*.css", + "*.scss" + ], "scripts": { "watch": "yarn build:dev --watch --mode=development", "build:prod": "webpack --config webpack.config.mjs --mode=production", "build:dev": "webpack --config webpack.config.mjs --mode=development", "lint": "yarn lint:tsc && yarn lint:eslint && yarn lint:scss && yarn lint:commit", "lint:tsc": "tsc --noEmit --incremental false", - "lint:eslint": "eslint \"{src,scripts}/**/*.{ts,js,mjs}\"", + "lint:eslint": "eslint \"{src,scripts}/**/*.{ts,tsx,js,mjs}\"", "lint:scss": "stylelint \"src/**/*.scss\"", "lint:commit": "node scripts/commitlint.mjs", "crx:key": "node scripts/crx-key.mjs", @@ -37,6 +41,7 @@ "@typescript-eslint/parser": "^8.54.0", "archiver": "^7.0.1", "babel-loader": "^10.0.0", + "babel-preset-solid": "^1.9.0", "chalk": "^5.6.2", "clean-webpack-plugin": "^4.0.0", "copy-webpack-plugin": "^13.0.1", @@ -70,6 +75,7 @@ }, "dependencies": { "ky": "^1.14.3", - "serialize-anything": "^1.2.3" + "serialize-anything": "^1.2.3", + "solid-js": "^1.9.0" } } diff --git a/src/app/Core/ContentInitializer.ts b/src/app/Core/ContentInitializer.ts index 6c714db..f130670 100644 --- a/src/app/Core/ContentInitializer.ts +++ b/src/app/Core/ContentInitializer.ts @@ -1,11 +1,23 @@ import { ScriptLoader } from 'Utils/ScriptLoader' import { ExtensionRuntime } from 'Utils/ExtensionRuntime' +import { ExtensionConfig } from 'Utils/ExtensionConfig' import { WorkerRPCEndpoint } from 'Communication/WorkerRPCEndpoint' export class ContentInitializer { public static async init (): Promise { WorkerRPCEndpoint.listen() + ExtensionConfig.init( + { + name: ExtensionRuntime.manifestName(), + version: ExtensionRuntime.manifestVersion(), + homepage: ExtensionRuntime.manifestHomepage(), + assets: { + icon: ExtensionRuntime.assetUrl('icons/icon32.png') + } + } + ) + /* Why? you wonder? Well in order to have access to the window attributes to be able to do things like intercepting XHR requests etc... diff --git a/src/app/Core/WorkerInitializer.ts b/src/app/Core/WorkerInitializer.ts index 3424016..112eed9 100644 --- a/src/app/Core/WorkerInitializer.ts +++ b/src/app/Core/WorkerInitializer.ts @@ -1,7 +1,9 @@ import { WorkerRPCEndpoint } from 'Communication/WorkerRPCEndpoint' import { WorkerTaskDispatcher } from 'WorkerTasks/WorkerTaskDispatcher' import type { WorkerTaskResult } from 'WorkerTasks/WorkerTaskDispatcher' -import { ComposePropertyTask } from 'WorkerTasks/Tasks/ComposePropertyTask' +import { FetchReviewsTask } from 'WorkerTasks/Tasks/FetchReviewsTask' +import { FetchAvailabilityTask } from 'WorkerTasks/Tasks/FetchAvailabilityTask' +import { FetchCountriesTask } from 'WorkerTasks/Tasks/FetchCountriesTask' export class WorkerInitializer { public static init (): void { @@ -13,6 +15,8 @@ export class WorkerInitializer { } private static registerTasks (): void { - WorkerTaskDispatcher.register('compose:property', new ComposePropertyTask()) + WorkerTaskDispatcher.register('fetch:reviews', new FetchReviewsTask()) + WorkerTaskDispatcher.register('fetch:availability', new FetchAvailabilityTask()) + WorkerTaskDispatcher.register('fetch:countries', new FetchCountriesTask()) } } diff --git a/src/app/DTOs/AvailabilityMetrics.ts b/src/app/DTOs/AvailabilityMetrics.ts index 58f9b11..56636c7 100644 --- a/src/app/DTOs/AvailabilityMetrics.ts +++ b/src/app/DTOs/AvailabilityMetrics.ts @@ -5,7 +5,7 @@ export class AvailabilityMetrics { private max!: Metrics private current!: Metrics - constructor (attributes: Record) { + constructor (attributes: { max: Metrics; current: Metrics }) { Object.assign(this, attributes) } diff --git a/src/app/DTOs/Property.ts b/src/app/DTOs/Property.ts index d03d545..66dde65 100644 --- a/src/app/DTOs/Property.ts +++ b/src/app/DTOs/Property.ts @@ -1,6 +1,16 @@ import type { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' import type { ReviewMetrics } from 'DTOs/ReviewMetrics' import type { BookedCountry } from 'DTOs/BookedCountry' +import type { PropertyBadge } from 'Services/PropertyBadgeService' + +type PropertyAttributes = { + id: number + name: string + reviewMetrics: ReviewMetrics + availabilityMetrics: AvailabilityMetrics + bookedCountries: BookedCountry[] + badges: PropertyBadge[] +} export class Property { private id!: number @@ -8,8 +18,9 @@ export class Property { private reviewMetrics!: ReviewMetrics private availabilityMetrics!: AvailabilityMetrics private bookedCountries!: BookedCountry[] + private badges!: PropertyBadge[] - constructor (attributes: Record) { + constructor (attributes: PropertyAttributes) { Object.assign(this, attributes) } @@ -32,4 +43,8 @@ export class Property { public getBookedCountries (): BookedCountry[] { return this.bookedCountries } + + public getBadges (): PropertyBadge[] { + return this.badges + } } diff --git a/src/app/DTOs/ReviewMetrics.ts b/src/app/DTOs/ReviewMetrics.ts index 9a9aa0c..ca158d3 100644 --- a/src/app/DTOs/ReviewMetrics.ts +++ b/src/app/DTOs/ReviewMetrics.ts @@ -1,3 +1,4 @@ +import type { PropertyReviews } from 'Services/Hostelworld/Api/ReviewsClient' import { toPercent } from 'Utils' export class ReviewMetrics { @@ -6,8 +7,9 @@ export class ReviewMetrics { private other!: number private solo!: number private total!: number + private ages!: Record - constructor (attributes: Record) { + constructor (attributes: PropertyReviews) { Object.assign(this, attributes) } @@ -46,4 +48,12 @@ export class ReviewMetrics { public getTotal (): number { return this.total } + + public getAges (): Record { + return this.ages + } + + public getAgePercentage (age: string): number { + return toPercent(this.ages[age] ?? 0, this.getTotal()) + } } diff --git a/src/app/DTOs/Search.ts b/src/app/DTOs/Search.ts index 59a1fbc..da55220 100644 --- a/src/app/DTOs/Search.ts +++ b/src/app/DTOs/Search.ts @@ -1,11 +1,17 @@ import { dateAddDays } from 'Utils' +type SearchAttributes = { + to: Date + from: Date + cityId: string +} + export class Search { private to!: Date private from!: Date private cityId!: string - constructor (attributes: Record) { + constructor (attributes: SearchAttributes) { Object.assign(this, attributes) } @@ -21,7 +27,7 @@ export class Search { return this.cityId } - public static createFromHostelworldSearchUrl (url: URL): typeof this.prototype { + public static createFromHostelworldSearchUrl (url: URL): Search { const parameters: URLSearchParams = url.searchParams if (!parameters.has('date-start') || !parameters.has('num-nights')) { throw new Error('Not a hostelworld search url.') @@ -29,7 +35,12 @@ export class Search { const from: Date = new Date(parameters.get('date-start')) const daysToAdd: number = Number(parameters.get('num-nights')) - const [, cityId]: [string, string] = url.toString().match(/cities\/(\d+)\/properties\//) as [string, string] + + const match: RegExpMatchArray | null = url.toString().match(/cities\/(\d+)\/properties\//) + if (!match) { + throw new Error('Not a hostelworld search url.') + } + const [, cityId]: [string, string] = match as [string, string] return new this({ cityId, from, to: dateAddDays(from, daysToAdd) diff --git a/src/app/Factories/PropertyFactory.ts b/src/app/Factories/PropertyFactory.ts index d944344..447db61 100644 --- a/src/app/Factories/PropertyFactory.ts +++ b/src/app/Factories/PropertyFactory.ts @@ -1,39 +1,33 @@ -import type { Search } from 'DTOs/Search' -import type { Property as HostelworldProperty } from 'Types/HostelworldSearch' import { Property } from 'DTOs/Property' import { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' import type { PropertyAvailability } from 'Services/Hostelworld/Api/AvailabilityClient' import type { PropertyReviews } from 'Services/Hostelworld/Api/ReviewsClient' import type { PropertyGuestsCountries } from 'Services/Hostelworld/Api/VisitorsCountryClient' import { ReviewMetrics } from 'DTOs/ReviewMetrics' -import { ReviewsClient } from 'Services/Hostelworld/Api/ReviewsClient' -import { VisitorsCountryClient } from 'Services/Hostelworld/Api/VisitorsCountryClient' import { BookedCountry } from 'DTOs/BookedCountry' -import { AvailabilityClient } from 'Services/Hostelworld/Api/AvailabilityClient' +import { PropertyBadgeService, type PropertyBadge, type PropertyMetrics } from 'Services/PropertyBadgeService' export class PropertyFactory { - public static async create (property: HostelworldProperty, search: Search): Promise { - const { id, name } = property - - const [reviews, availability, countries]: [PropertyReviews, PropertyAvailability, PropertyGuestsCountries] = - await Promise.all([ - ReviewsClient.fetch(id), - AvailabilityClient.fetch(id, search.getFrom(), search.getTo()), - VisitorsCountryClient.fetch(id, search.getFrom(), search.getTo()) - ]) - + public static create ( + id: number, + name: string, + reviews: PropertyReviews, + availability: PropertyAvailability, + countries: PropertyGuestsCountries + ): Property { const reviewMetrics: ReviewMetrics = new ReviewMetrics(reviews) const availabilityMetrics: AvailabilityMetrics = new AvailabilityMetrics(availability) - const bookedCountries: BookedCountry[] = countries.map( - country => new BookedCountry(country) - ) + const bookedCountries: BookedCountry[] = countries.map(country => new BookedCountry(country)) + const metrics: PropertyMetrics = { reviews: reviewMetrics, availability: availabilityMetrics } + const badges: PropertyBadge[] = PropertyBadgeService.badgesFor(metrics) return new Property({ id, name, reviewMetrics, + availabilityMetrics, bookedCountries, - availabilityMetrics + badges }) } } diff --git a/src/app/Listeners/AppInitedListener.ts b/src/app/Listeners/AppInitedListener.ts index 9ef8219..ec094d6 100644 --- a/src/app/Listeners/AppInitedListener.ts +++ b/src/app/Listeners/AppInitedListener.ts @@ -3,21 +3,26 @@ import { Subscribe } from 'Core/EventBus' import { AbstractListener } from './AbstractListener' import { SearchDataAdapter } from 'Services/Hostelworld/SearchDataAdapter' import { SearchPropertyListComponentPatcher } from 'Services/Hostelworld/Patchers/SearchPropertyListComponentPatcher' +import { DevicePatcher } from 'Services/Hostelworld/Patchers/DevicePatcher' import { SearchApiRequestsInterceptor } from 'Services/Hostelworld/SearchApiRequestsInterceptor' +import { AppDiscountInterceptor } from 'Services/Hostelworld/AppDiscountInterceptor' import { VuexDataHook } from 'Services/Hostelworld/VuexDataHook' import type { HostelworldSearch } from 'Types/HostelworldSearch' +import { FilterModalRenderer } from 'UI/Renderers/FilterModal' @Subscribe('app:inited') export class AppInitedListener extends AbstractListener { public async handle (): Promise { this.applyRequestInterceptors() - await Promise.all([ + await Promise.allSettled([ + FilterModalRenderer.render(), + DevicePatcher.enforceMobile(), SearchPropertyListComponentPatcher.disableFeatured(), - SearchPropertyListComponentPatcher.disablePagination() + SearchPropertyListComponentPatcher.installPropertiesFilter() ]) - const renderProperties: Function = (propertyIds: number[]) => { + const renderProperties: (propertyIds: number[]) => void = (propertyIds: number[]) => { for (const propertyId of propertyIds) { this.emit('property:render', propertyId) } @@ -28,6 +33,8 @@ export class AppInitedListener extends AbstractListener { } private applyRequestInterceptors (): void { + AppDiscountInterceptor.enableAppDiscounts() + SearchApiRequestsInterceptor .interceptSearch( this.persistLatestSearch.bind(this), @@ -48,7 +55,7 @@ export class AppInitedListener extends AbstractListener { } private onSearchProperties (search: HostelworldSearch): HostelworldSearch { - const adapted: HostelworldSearch = SearchDataAdapter.stripPromotions(search) + const adapted: HostelworldSearch = SearchDataAdapter.withoutPromotions(search) this.emit('hostelworld:search:intercepted', adapted.properties) diff --git a/src/app/Listeners/AvailabilityReadyListener.ts b/src/app/Listeners/AvailabilityReadyListener.ts new file mode 100644 index 0000000..ecb081c --- /dev/null +++ b/src/app/Listeners/AvailabilityReadyListener.ts @@ -0,0 +1,21 @@ +import { Subscribe } from 'Core/EventBus' +import { AbstractListener } from './AbstractListener' +import { PropertyCardRenderer } from 'UI/Renderers/PropertyCard' +import { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' +import type { PropertyAvailability } from 'Services/Hostelworld/Api/AvailabilityClient' + +type AvailabilityPayload = { + propertyId: number + propertyName: string + data: PropertyAvailability +} + +@Subscribe('worker:result:fetch:availability') +export class AvailabilityReadyListener extends AbstractListener { + public handle (payload: AvailabilityPayload): void { + const metrics: AvailabilityMetrics = new AvailabilityMetrics(payload.data) + PropertyCardRenderer.updateAvailabilityMetrics(payload.propertyId, metrics) + + this.emit('property:metric:collected', 'availability', payload) + } +} diff --git a/src/app/Listeners/CountriesReadyListener.ts b/src/app/Listeners/CountriesReadyListener.ts new file mode 100644 index 0000000..2418a47 --- /dev/null +++ b/src/app/Listeners/CountriesReadyListener.ts @@ -0,0 +1,21 @@ +import { Subscribe } from 'Core/EventBus' +import { AbstractListener } from './AbstractListener' +import { PropertyCardRenderer } from 'UI/Renderers/PropertyCard' +import type { PropertyGuestsCountries } from 'Services/Hostelworld/Api/VisitorsCountryClient' + +type CountriesPayload = { + propertyId: number + propertyName: string + data: PropertyGuestsCountries +} + +@Subscribe('worker:result:fetch:countries') +export class CountriesReadyListener extends AbstractListener { + public handle (payload: CountriesPayload): void { + void PropertyCardRenderer.updateCountries( + payload.propertyId, payload.propertyName, payload.data + ) + + this.emit('property:metric:collected', 'countries', payload) + } +} diff --git a/src/app/Listeners/FilterAppliedListener.ts b/src/app/Listeners/FilterAppliedListener.ts new file mode 100644 index 0000000..05646c8 --- /dev/null +++ b/src/app/Listeners/FilterAppliedListener.ts @@ -0,0 +1,41 @@ +import type { Property } from 'DTOs/Property' +import type { FilterCriteria } from 'UI/Renderers/FilterModal/ViewDTOs' +import { Subscribe } from 'Core/EventBus' +import { AbstractListener } from './AbstractListener' +import { SearchPropertyListComponentPatcher } from 'Services/Hostelworld/Patchers/SearchPropertyListComponentPatcher' +import { PropertyFilterService } from 'Services/PropertyFilterService' + +@Subscribe('filter:applied property:composed') +export class FilterAppliedListener extends AbstractListener { + public handle (...args: unknown[]): void { + const criteria: FilterCriteria | undefined = this.parsedFilterCriteria(args) + + if (!criteria) { + void SearchPropertyListComponentPatcher.refreshProperties() + return + } + + const hasNoCriteria: boolean = !Object.keys(criteria.badges).length && !Object.keys(criteria.ranges).length + if (hasNoCriteria) { + SearchPropertyListComponentPatcher.removePropertyFilter() + return + } + + SearchPropertyListComponentPatcher.applyPropertyFilter( + (propertyId: number): boolean => { + const property: Property | undefined = this.propertyInSession(propertyId) + if (!property) return true + + return PropertyFilterService.matchesCriteria(property, criteria) + } + ) + } + + private parsedFilterCriteria (args: unknown[]): FilterCriteria | undefined { + const candidate: unknown = args[0] + if (!candidate || typeof candidate !== 'object') return + if (!('badges' in candidate) || !('ranges' in candidate)) return + + return candidate as FilterCriteria + } +} diff --git a/src/app/Listeners/PropertiesInterceptedListener.ts b/src/app/Listeners/PropertiesInterceptedListener.ts index 8045e09..4719483 100644 --- a/src/app/Listeners/PropertiesInterceptedListener.ts +++ b/src/app/Listeners/PropertiesInterceptedListener.ts @@ -1,16 +1,26 @@ import type { Property } from 'Types/HostelworldSearch' -import { Search } from 'DTOs/Search' +import type { Search } from 'DTOs/Search' import { Subscribe } from 'Core/EventBus' import { AbstractListener } from './AbstractListener' +import { PropertyCardRenderer } from 'UI/Renderers/PropertyCard/PropertyCardRenderer' @Subscribe('hostelworld:search:intercepted') export class PropertiesInterceptedListener extends AbstractListener { public async handle (properties: Property[]): Promise { + this.emit('property:composition:reset') + const search: Search | undefined = this.latestSearchInSession() if (!search) throw new Error('There is no search in session to properly compose the properties.') + const from: string = search.getFrom().toISOString() + const to: string = search.getTo().toISOString() + for (const property of properties) { - this.emit('worker:task:dispatch', 'compose:property', property, search) + await PropertyCardRenderer.render(property.id, property.name) + + this.emit('worker:task:dispatch', 'fetch:reviews', property.id, property.name) + this.emit('worker:task:dispatch', 'fetch:availability', property.id, property.name, from, to) + this.emit('worker:task:dispatch', 'fetch:countries', property.id, property.name, from, to) } } } diff --git a/src/app/Listeners/PropertyComposedListener.ts b/src/app/Listeners/PropertyComposedListener.ts index 48b0151..c267dc8 100644 --- a/src/app/Listeners/PropertyComposedListener.ts +++ b/src/app/Listeners/PropertyComposedListener.ts @@ -2,7 +2,7 @@ import type { Property } from 'DTOs/Property' import { AbstractListener } from './AbstractListener' import { Subscribe } from 'Core/EventBus' -@Subscribe('worker:result:compose:property') +@Subscribe('property:composed') export class PropertyComposedListener extends AbstractListener { public async handle (property: Property): Promise { this.persistPropertyInSession(property) diff --git a/src/app/Listeners/PropertyComposerListener.ts b/src/app/Listeners/PropertyComposerListener.ts new file mode 100644 index 0000000..ea25227 --- /dev/null +++ b/src/app/Listeners/PropertyComposerListener.ts @@ -0,0 +1,35 @@ +import { Subscribe } from 'Core/EventBus' +import { AbstractListener } from './AbstractListener' +import { PropertyFactory } from 'Factories/PropertyFactory' +import { PropertyCompositionBuffer } from 'Services/PropertyCompositionBuffer' +import type { CompletedProperty, Metric, MetricData } from 'Services/PropertyCompositionBuffer' +import type { Property } from 'DTOs/Property' + +type MetricPayload = { + propertyId: number + propertyName: string + data: MetricData +} + +@Subscribe('property:metric:collected') +export class PropertyComposerListener extends AbstractListener { + public handle (metric: Metric, payload: MetricPayload): void { + PropertyCompositionBuffer.register(payload.propertyId, payload.propertyName) + PropertyCompositionBuffer.collect(payload.propertyId, metric, payload.data) + + const entry: CompletedProperty | undefined = PropertyCompositionBuffer.completedEntry(payload.propertyId) + if (!entry) return + + PropertyCompositionBuffer.remove(entry.id) + + const property: Property = PropertyFactory.create( + entry.id, + entry.name, + entry.reviews, + entry.availability, + entry.countries + ) + + this.emit('property:composed', property) + } +} diff --git a/src/app/Listeners/PropertyCompositionResetListener.ts b/src/app/Listeners/PropertyCompositionResetListener.ts new file mode 100644 index 0000000..0a29d75 --- /dev/null +++ b/src/app/Listeners/PropertyCompositionResetListener.ts @@ -0,0 +1,10 @@ +import { Subscribe } from 'Core/EventBus' +import { AbstractListener } from './AbstractListener' +import { PropertyCompositionBuffer } from 'Services/PropertyCompositionBuffer' + +@Subscribe('property:composition:reset') +export class PropertyCompositionResetListener extends AbstractListener { + public handle (): void { + PropertyCompositionBuffer.clear() + } +} diff --git a/src/app/Listeners/PropertyRenderListener.ts b/src/app/Listeners/PropertyRenderListener.ts index 524c96a..168b7ea 100644 --- a/src/app/Listeners/PropertyRenderListener.ts +++ b/src/app/Listeners/PropertyRenderListener.ts @@ -1,7 +1,7 @@ import type { Property } from 'DTOs/Property' import { Subscribe } from 'Core/EventBus' import { AbstractListener } from 'Listeners/AbstractListener' -import { PropertyCardRenderer } from 'Services/PropertyCardRenderer' +import { PropertyCardRenderer } from 'UI/Renderers/PropertyCard' @Subscribe('property:render') export class PropertyRenderListener extends AbstractListener { @@ -11,12 +11,13 @@ export class PropertyRenderListener extends AbstractListener { : propertyOrId if (!propertyToRender && typeof propertyOrId === 'number') { - await PropertyCardRenderer.renderProcessingMessage(propertyOrId) + await PropertyCardRenderer.render(propertyOrId) + return } if (!propertyToRender) return - await PropertyCardRenderer.render(propertyToRender) + await PropertyCardRenderer.renderWithData(propertyToRender) } } diff --git a/src/app/Listeners/ReviewsReadyListener.ts b/src/app/Listeners/ReviewsReadyListener.ts new file mode 100644 index 0000000..25ecc50 --- /dev/null +++ b/src/app/Listeners/ReviewsReadyListener.ts @@ -0,0 +1,21 @@ +import { Subscribe } from 'Core/EventBus' +import { AbstractListener } from './AbstractListener' +import { PropertyCardRenderer } from 'UI/Renderers/PropertyCard' +import { ReviewMetrics } from 'DTOs/ReviewMetrics' +import type { PropertyReviews } from 'Services/Hostelworld/Api/ReviewsClient' + +type ReviewsPayload = { + propertyId: number + propertyName: string + data: PropertyReviews +} + +@Subscribe('worker:result:fetch:reviews') +export class ReviewsReadyListener extends AbstractListener { + public handle (payload: ReviewsPayload): void { + const metrics: ReviewMetrics = new ReviewMetrics(payload.data) + PropertyCardRenderer.updateReviewMetrics(payload.propertyId, metrics) + + this.emit('property:metric:collected', 'reviews', payload) + } +} diff --git a/src/app/Listeners/WorkerTaskDispatchListener.ts b/src/app/Listeners/WorkerTaskDispatchListener.ts index 5656a6a..ef698b8 100644 --- a/src/app/Listeners/WorkerTaskDispatchListener.ts +++ b/src/app/Listeners/WorkerTaskDispatchListener.ts @@ -5,6 +5,6 @@ import { WorkerRPCProxy } from 'Communication/WorkerRPCProxy' @Subscribe('worker:task:dispatch') export class WorkerTaskDispatchListener extends AbstractListener { public handle (task: string, ...args: unknown[]): void { - void WorkerRPCProxy.call(task, args) + WorkerRPCProxy.call(task, args) } } diff --git a/src/app/Services/Hostelworld/Api/AvailabilityClient.ts b/src/app/Services/Hostelworld/Api/AvailabilityClient.ts index a4c19ae..b2e279f 100644 --- a/src/app/Services/Hostelworld/Api/AvailabilityClient.ts +++ b/src/app/Services/Hostelworld/Api/AvailabilityClient.ts @@ -19,8 +19,12 @@ export class AvailabilityClient { 'properties/{property}/availability/?date-start={from}&num-nights={nights}' public static async fetch (propertyId: number, from: Date, to: Date): Promise { - const current: Metrics = await this.currentCapacity(propertyId, from, to) - const max: Metrics = await this.possibleMaxCapacity(propertyId, from, current) + const [current, sampledMax]: [Metrics, Metrics] = await Promise.all([ + this.currentCapacity(propertyId, from, to), + this.possibleMaxCapacity(propertyId, from) + ]) + + const max: Metrics = this.highestMetrics(current, sampledMax) return { current, max } } @@ -31,9 +35,12 @@ export class AvailabilityClient { return this.toMetrics(availability) } - private static async possibleMaxCapacity (propertyId: number, from: Date, current: Metrics): Promise { + private static async possibleMaxCapacity (propertyId: number, from: Date): Promise { const maxMetrics: Metrics = { - ...current + mixed: 0, + female: 0, + private: 0, + total: 0 } const daysAfterToCheck: number[] = [10, 20, 30, 45, 60, 70, randomNumber(80, 90)] @@ -42,13 +49,13 @@ export class AvailabilityClient { const fromWithDaysAdded: Date = dateAddDays(from, days) const toWithFromPlus3Days: Date = dateAddDays(fromWithDaysAdded, 2) - await delay(randomNumber(0, 5) * 100) + await delay(randomNumber(0, 3) * 100) - const metrics: Metrics = this.toMetrics( - this.adaptDormBedToMaxCapacity( - await this.request(propertyId, fromWithDaysAdded, toWithFromPlus3Days, 24 * 60) - ) + const availability: HostelworldPropertyAvailability = await this.request( + propertyId, fromWithDaysAdded, toWithFromPlus3Days, 24 * 60 ) + this.adaptDormBedToMaxCapacity(availability) + const metrics: Metrics = this.toMetrics(availability) for (const key in metrics) { const metricsKey: keyof Metrics = key as keyof Metrics @@ -86,17 +93,29 @@ export class AvailabilityClient { return metrics } + private static highestMetrics (...sources: Metrics[]): Metrics { + const result: Metrics = { mixed: 0, female: 0, private: 0, total: 0 } + + for (const source of sources) { + for (const key in result) { + const metricsKey: keyof Metrics = key as keyof Metrics + + if (source[metricsKey] <= result[metricsKey]) continue + result[metricsKey] = source[metricsKey] + } + } + + return result + } + private static adaptDormBedToMaxCapacity ( availability: HostelworldPropertyAvailability - ): HostelworldPropertyAvailability { - availability.rooms.dorms = availability.rooms.dorms.map(dorm => { + ): void { + for (const dorm of availability.rooms.dorms) { dorm.totalBedsAvailable = Math.ceil( dorm.totalBedsAvailable / Number(dorm.capacity) ) * Number(dorm.capacity) - - return dorm - }) - return availability + } } private static async request ( diff --git a/src/app/Services/Hostelworld/Api/ReviewsClient.ts b/src/app/Services/Hostelworld/Api/ReviewsClient.ts index a245f4e..5c7bb1e 100644 --- a/src/app/Services/Hostelworld/Api/ReviewsClient.ts +++ b/src/app/Services/Hostelworld/Api/ReviewsClient.ts @@ -8,14 +8,26 @@ export type PropertyReviews = { other: number solo: number total: number + ages: Record } export class ReviewsClient { private static readonly endpoint: string = 'https://prod.apigee.hostelworld.com/legacy-hwapi-service/2.2/' + 'properties/{property}/reviews/?page={page}&sort=newest&allLanguages=true&monthCount=72&per-page=50' + private static readonly ageBrackets: string[] = ['18-24', '25-30', '31-40', '41+'] + public static async fetch (propertyId: number): Promise { - const metrics: PropertyReviews = { male: 0, female: 0, other: 0, solo: 0, total: 0 } + const metrics: PropertyReviews = { + male: 0, + female: 0, + other: 0, + solo: 0, + total: 0, + ages: Object.fromEntries( + this.ageBrackets.map(bracket => [bracket, 0]) + ) + } const { reviews: firstPageReviews, reviewStatistics, pagination } = await this.request(propertyId, 1) @@ -27,7 +39,7 @@ export class ReviewsClient { Array .from({ length: leftOverPages }, (_, index) => index + 2) .map(async page => { - await delay(randomNumber(1, 8) * 100) + await delay(randomNumber(1, 5) * 100) const { reviews } = await this.request(propertyId, page) return reviews @@ -40,6 +52,9 @@ export class ReviewsClient { metrics.male += Number(['MALE', 'ALLMALEGROUP'].includes(review.groupInformation.groupTypeCode)) metrics.female += Number(['FEMALE', 'ALLFEMALEGROUP'].includes(review.groupInformation.groupTypeCode)) metrics.other += Number(['COUPLE', 'MIXEDGROUP'].includes(review.groupInformation.groupTypeCode)) + + const age: string = review.groupInformation.age + metrics.ages[age] = (metrics.ages[age] ?? 0) + 1 } return metrics diff --git a/src/app/Services/Hostelworld/AppDiscountInterceptor.ts b/src/app/Services/Hostelworld/AppDiscountInterceptor.ts new file mode 100644 index 0000000..1f60039 --- /dev/null +++ b/src/app/Services/Hostelworld/AppDiscountInterceptor.ts @@ -0,0 +1,16 @@ +import { XHRRequestInterceptor } from 'Utils/XHRRequestInterceptor' + +export class AppDiscountInterceptor { + private static applicationParamRegex: RegExp = /application=(?!mobile)\w+/ + + public static enableAppDiscounts (): void { + XHRRequestInterceptor + .intercept({ url: this.applicationParamRegex }) + .withUrl((url: string): string => { + const parsed: URL = new URL(url) + parsed.searchParams.set('application', 'mobile') + + return parsed.toString() + }) + } +} diff --git a/src/app/Services/Hostelworld/Patchers/DevicePatcher.ts b/src/app/Services/Hostelworld/Patchers/DevicePatcher.ts new file mode 100644 index 0000000..dd208d2 --- /dev/null +++ b/src/app/Services/Hostelworld/Patchers/DevicePatcher.ts @@ -0,0 +1,25 @@ +import { waitForProperty } from 'Utils' + +type NuxtDevice = { + isMobile: boolean + isMobileOrTablet: boolean + [key: string]: unknown +} + +export class DevicePatcher { + public static async enforceMobile (): Promise { + const device: NuxtDevice = await waitForProperty(window, '$nuxt.$device', 60_000) + + Object.defineProperty(device, 'isMobile', { + configurable: true, + get: () => true, + set: () => undefined + }) + + Object.defineProperty(device, 'isMobileOrTablet', { + configurable: true, + get: () => true, + set: () => undefined + }) + } +} diff --git a/src/app/Services/Hostelworld/Patchers/SearchPropertyListComponentPatcher.ts b/src/app/Services/Hostelworld/Patchers/SearchPropertyListComponentPatcher.ts index 2c5b529..a6a4ae3 100644 --- a/src/app/Services/Hostelworld/Patchers/SearchPropertyListComponentPatcher.ts +++ b/src/app/Services/Hostelworld/Patchers/SearchPropertyListComponentPatcher.ts @@ -1,48 +1,19 @@ import type { Property } from 'Types/HostelworldSearch' import { VuexDataHook } from 'Services/Hostelworld/VuexDataHook' -import { emptyFunction, pluck, promiseFallback, waitForElement, waitForProperty } from 'Utils' +import { VueComponentAccessor } from 'Services/Hostelworld/VueComponentAccessor' +import type { VuePropertyListComponent, VuexStore } from 'Services/Hostelworld/VueComponentAccessor' +import { emptyFunction, pluck, promiseFallback, waitForElement } from 'Utils' -type VuePropertyListComponent = { - propertiesPerPage: number - displayFeaturedProperties: boolean -} - -type HostelworldSearchServiceResult = { - properties: Property[], - location: unknown -} - -interface HostelworldSearchService { - search ( - cityId: string, - fromDate: string | null, - toDate: string | null, - guests: number, - options: Record - ): Promise -} - -type HostelworldState = { - search: { - city: number | null, - properties: Property[] - } -} - -type VuexStoreCommit = (type: string, payload: unknown) => Promise - -type VuexStore = { - state: HostelworldState, - commit: VuexStoreCommit, - $services: { - search: () => Promise - } -} +export type PropertyFilterPredicate = (propertyId: number) => boolean export class SearchPropertyListComponentPatcher { + private static filterPredicate: PropertyFilterPredicate | null = null + public static async disablePagination (): Promise { const showAllPropertiesInSearch: () => Promise = async (): Promise => { - const component: VuePropertyListComponent | undefined = await promiseFallback(this.propertyListComponent()) + const component: VuePropertyListComponent | undefined = await promiseFallback( + VueComponentAccessor.propertyListComponent() + ) if (!component) return const maxPossiblePropertiesFromRequest: number = 1100 @@ -60,7 +31,9 @@ export class SearchPropertyListComponentPatcher { public static async disableFeatured (): Promise { const disableFeaturedProperties: () => Promise = async (): Promise => { - const component: VuePropertyListComponent | undefined = await promiseFallback(this.propertyListComponent()) + const component: VuePropertyListComponent | undefined = await promiseFallback( + VueComponentAccessor.propertyListComponent() + ) if (!component) return const displayFeaturedProperties: boolean = false @@ -76,11 +49,29 @@ export class SearchPropertyListComponentPatcher { ) } + public static async installPropertiesFilter (): Promise { + const hijackFilteredProperties: () => Promise = async (): Promise => { + const component: VuePropertyListComponent | undefined = await promiseFallback( + VueComponentAccessor.propertyListComponent() + ) + if (!component) return + + this.setReactiveTrigger(component) + this.hijackPropertyGetter(component, 'filteredProperties') + this.hijackPropertyGetter(component, 'filteredHWProperties') + this.observePropertyGetter(component, 'displayedProperties') + } + + return VuexDataHook.onRouteChanged( + hijackFilteredProperties.bind(this) + ) + } + public static async loadAllForCity (cityId: string): Promise { - const store: VuexStore | undefined = await promiseFallback(this.hostelworldStore()) + const store: VuexStore | undefined = await promiseFallback(VueComponentAccessor.hostelworldStore()) if (!store) return - const service: HostelworldSearchService = await store.$services.search() + const service: Awaited> = await store.$services.search() const { properties } = await service.search(cityId, null, null, 1, {}) await waitForElement('.property-card .property-card-container') @@ -97,13 +88,104 @@ export class SearchPropertyListComponentPatcher { await store.commit('search/setProperties', allProperties) } - private static async hostelworldStore (): Promise { - return await waitForProperty(window, '$nuxt.$store', 60 * 1000) + public static applyPropertyFilter (predicate: PropertyFilterPredicate): void { + this.filterPredicate = predicate + + void this.triggerFilterChange() } - private static async propertyListComponent (): Promise { - const propertyListElement: HTMLElement = await waitForElement('.search .property-list >div', 60 * 1000) + public static removePropertyFilter (): void { + this.filterPredicate = null + + void this.triggerFilterChange() + } + + public static async refreshProperties (): Promise { + const component: VuePropertyListComponent | undefined = await promiseFallback( + VueComponentAccessor.propertyListComponent() + ) + if (!component) return + + if (this.filterPredicate && component._filterVersion) { + component._filterVersion++ + + return + } + + const computedWatchers: VuePropertyListComponent['_computedWatchers'] = component._computedWatchers + if (computedWatchers) { + for (const watcher of Object.values(computedWatchers)) { + watcher.dirty = true + } + } + + component.$forceUpdate() + } + + private static setReactiveTrigger (component: VuePropertyListComponent): void { + component.$options._base.util.defineReactive(component, '_filterVersion', 1) + } + + private static hijackPropertyGetter (component: VuePropertyListComponent, propertyName: string): void { + const descriptor: PropertyDescriptor | undefined = this.capturedDescriptor(component, propertyName) + if (!descriptor?.get) return + + Object.defineProperty(component, propertyName, { + configurable: true, + enumerable: true, + get: (): Property[] => { + void component._filterVersion + const original: Property[] = descriptor.get!.call(component) ?? [] + + if (!this.filterPredicate) return original + + return original.filter( + (entry: Property) => this.filterPredicate!(entry.id) + ) + }, + set: descriptor.set + ? (value: unknown) => descriptor.set!.call(component, value) + : undefined + }) + } + + private static observePropertyGetter (component: VuePropertyListComponent, propertyName: string): void { + const descriptor: PropertyDescriptor | undefined = this.capturedDescriptor(component, propertyName) + if (!descriptor?.get) return + + Object.defineProperty(component, propertyName, { + configurable: true, + enumerable: true, + get: (): Property[] => { + void component._filterVersion + + return descriptor.get!.call(component) ?? [] + }, + set: descriptor.set + ? (value: unknown) => descriptor.set!.call(component, value) + : undefined + }) + } + + private static capturedDescriptor ( + component: VuePropertyListComponent, propertyName: string + ): PropertyDescriptor | undefined { + return Object.getOwnPropertyDescriptor(component, propertyName) ?? + Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(component), propertyName) + } + + private static async triggerFilterChange (): Promise { + const component: VuePropertyListComponent | undefined = await promiseFallback( + VueComponentAccessor.propertyListComponent() + ) + if (!component || !component._filterVersion) return + + const store: VuexStore | undefined = await promiseFallback(VueComponentAccessor.hostelworldStore()) + if (store) { + void store.commit('search/setPage', 1) + } - return waitForProperty(propertyListElement, '__vue__', 60 * 1000) + component._filterVersion++ } } diff --git a/src/app/Services/Hostelworld/SearchDataAdapter.ts b/src/app/Services/Hostelworld/SearchDataAdapter.ts index 3361f92..78e7555 100644 --- a/src/app/Services/Hostelworld/SearchDataAdapter.ts +++ b/src/app/Services/Hostelworld/SearchDataAdapter.ts @@ -1,23 +1,18 @@ import type { HostelworldSearch } from 'Types/HostelworldSearch' export class SearchDataAdapter { - public static stripPromotions (search: HostelworldSearch): HostelworldSearch { - search.properties = search.properties.map(property => { - property.isElevate = false - property.isFeatured = false - property.isPromoted = false - - property.promotions = property.promotions.map( - promotion => { - delete promotion.campaign - - return promotion - } - ) - - return property - }) - - return search + public static withoutPromotions (search: HostelworldSearch): HostelworldSearch { + return { + ...search, + properties: search.properties.map(property => ({ + ...property, + isElevate: false, + isFeatured: false, + isPromoted: false, + promotions: property.promotions.map( + promotion => ({ ...promotion, campaign: undefined }) + ) + })) + } } } diff --git a/src/app/Services/Hostelworld/VueComponentAccessor.ts b/src/app/Services/Hostelworld/VueComponentAccessor.ts new file mode 100644 index 0000000..e61c77e --- /dev/null +++ b/src/app/Services/Hostelworld/VueComponentAccessor.ts @@ -0,0 +1,71 @@ +import type { Property } from 'Types/HostelworldSearch' +import { waitForElement, waitForProperty } from 'Utils' + +type VueConstructor = { + util: { + defineReactive: (object: object, key: string, value: unknown) => void + } +} + +type VueComputedWatcher = { + dirty: boolean +} + +type HostelworldSearchServiceResult = { + properties: Property[] + location: unknown +} + +interface HostelworldSearchService { + search ( + cityId: string, + fromDate: string | null, + toDate: string | null, + guests: number, + options: Record + ): Promise +} + +type HostelworldState = { + search: { + city: number | null + properties: Property[] + } +} + +export type VuePropertyListComponent = { + properties: Property[] + filteredProperties: Property[] + filteredHWProperties: Property[] + propertiesPerPage: number + displayFeaturedProperties: boolean + displayedProperties: Property[] + _filterVersion?: number + _computedWatchers?: Record + isDisplayedPropertiesWatched?: boolean + $watch: (property: string, callback: (properties: Property[]) => void) => void + $forceUpdate: () => void + $options: { + _base: VueConstructor + } +} + +export type VuexStore = { + state: HostelworldState + commit: (type: string, payload: unknown) => Promise + $services: { + search: () => Promise + } +} + +export class VueComponentAccessor { + public static async propertyListComponent (): Promise { + const propertyListElement: HTMLElement = await waitForElement('.search .property-list >div', 60 * 1000) + + return waitForProperty(propertyListElement, '__vue__', 60 * 1000) + } + + public static async hostelworldStore (): Promise { + return await waitForProperty(window, '$nuxt.$store', 60 * 1000) + } +} diff --git a/src/app/Services/Hostelworld/VuexDataHook.ts b/src/app/Services/Hostelworld/VuexDataHook.ts index b37ee6e..3bb1a2e 100644 --- a/src/app/Services/Hostelworld/VuexDataHook.ts +++ b/src/app/Services/Hostelworld/VuexDataHook.ts @@ -1,27 +1,24 @@ -import type { Property } from 'Types/HostelworldSearch' -import { promiseFallback, waitForElement, waitForProperty } from 'Utils' +import { VueComponentAccessor } from 'Services/Hostelworld/VueComponentAccessor' +import type { VuePropertyListComponent } from 'Services/Hostelworld/VueComponentAccessor' +import { promiseFallback, waitForProperty } from 'Utils' type VuexRouter = { - onReady: Function - afterEach: Function -} - -type VuePropertyListComponent = { - $watch: Function - displayedProperties: Property[] - isDisplayedPropertiesWatched?: boolean + onReady: (callback: () => void) => void + afterEach: (callback: () => void) => void } export class VuexDataHook { public static async onPropertiesDisplayed (callback: (propertyIds: number[]) => void): Promise { const onDisplayedPropertiesUpdate: () => Promise = async (): Promise => { - const component: VuePropertyListComponent | undefined = await promiseFallback(this.propertyListComponent()) + const component: VuePropertyListComponent | undefined = await promiseFallback( + VueComponentAccessor.propertyListComponent() + ) if (!component) return if (component.isDisplayedPropertiesWatched) return component.isDisplayedPropertiesWatched = true - component.$watch('displayedProperties', (properties: Property[]) => { + component.$watch('displayedProperties', (properties) => { if (!properties[0]) return callback( @@ -41,10 +38,4 @@ export class VuexDataHook { router.onReady(callback.bind(this)) router.afterEach(callback.bind(this)) } - - private static async propertyListComponent (): Promise { - const propertyListElement: HTMLElement = await waitForElement('.search .property-list >div', 60 * 1000) - - return waitForProperty(propertyListElement, '__vue__', 60 * 1000) - } } diff --git a/src/app/Services/PropertyBadgeService.ts b/src/app/Services/PropertyBadgeService.ts new file mode 100644 index 0000000..5361d86 --- /dev/null +++ b/src/app/Services/PropertyBadgeService.ts @@ -0,0 +1,97 @@ +import type { ReviewMetrics } from 'DTOs/ReviewMetrics' +import type { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' + +export type BadgeColor = 'purple' | 'pink' | 'orange' | 'blue' | 'red' | 'teal' + +export type BadgeId = 'greatForSolo' | 'femaleFriendly' | 'youngCrowd' | 'midAgeCrowd' | 'matureCrowd' | 'closedDown' + +export type PropertyBadge = { + id: BadgeId + label: string + color: BadgeColor +} + +export type PropertyMetrics = { + reviews: ReviewMetrics + availability: AvailabilityMetrics +} + +export type BadgeMetadata = { + id: BadgeId + label: string + color: BadgeColor +} + +export type BadgeDefinition = BadgeMetadata & { + condition: (metrics: PropertyMetrics) => boolean +} + +export class PropertyBadgeService { + private static readonly badges: BadgeDefinition[] = [ + { + id: 'greatForSolo', + label: 'Great for Solo', + color: 'purple', + condition: (metrics: PropertyMetrics): boolean => metrics.reviews.getSoloPercentage() > 90 + }, + { + id: 'femaleFriendly', + label: 'Female Friendly', + color: 'pink', + condition: (metrics: PropertyMetrics): boolean => { + const hasFemaleReviews: boolean = metrics.reviews.getFemalePercentage() > 60 + const hasFemaleBeds: boolean = metrics.availability.getMaxFemaleBeds() > 0 + + return hasFemaleReviews && hasFemaleBeds + } + }, + { + id: 'youngCrowd', + label: 'Young Crowd', + color: 'orange', + condition: (metrics: PropertyMetrics): boolean => metrics.reviews.getAgePercentage('18-24') > 40 + }, + { + id: 'midAgeCrowd', + label: 'Mid-Age Crowd', + color: 'teal', + condition: (metrics: PropertyMetrics): boolean => metrics.reviews.getAgePercentage('18-24') < 30 + }, + { + id: 'matureCrowd', + label: 'Mature Crowd', + color: 'blue', + condition: (metrics: PropertyMetrics): boolean => { + const percentage: number = metrics.reviews.getAgePercentage('31-40') + metrics.reviews.getAgePercentage('41+') + + return percentage > 40 + } + }, + { + id: 'closedDown', + label: 'Closed Down', + color: 'red', + condition: (metrics: PropertyMetrics): boolean => !metrics.availability.getMaxGuests() + } + ] + + public static badgeMetadata (): BadgeMetadata[] { + return this.badges.map(badge => ({ + id: badge.id, + label: badge.label, + color: badge.color + })) + } + + public static badgesFor (metrics: PropertyMetrics): PropertyBadge[] { + const result: PropertyBadge[] = [] + + for (const badge of this.badges) { + if (!badge.condition(metrics)) continue + + result.push({ id: badge.id, label: badge.label, color: badge.color }) + } + + return result + } +} diff --git a/src/app/Services/PropertyCardRenderer.ts b/src/app/Services/PropertyCardRenderer.ts deleted file mode 100644 index 4eb9edd..0000000 --- a/src/app/Services/PropertyCardRenderer.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { Property } from 'DTOs/Property' -import { waitForElement } from 'Utils' -import type { ReviewMetrics } from 'DTOs/ReviewMetrics' -import type { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' -import { PropertyCardComponentPatcher } from 'Services/Hostelworld/Patchers/PropertyCardComponentPatcher' - -type MetricItem = { - label: string, - value: string -} - -export class PropertyCardRenderer { - public static async render (property: Property): Promise { - await waitForElement('.property-card .property-card-container') - - const propertyCards: NodeListOf = document.querySelectorAll('.property-card') - const propertyCard: Element | undefined = [...propertyCards].find( - card => - card.innerHTML.includes(String(property.getId())) && - card.innerHTML.includes(this.htmlEncode(property.getName())) - ) - - if (!propertyCard) return - this.cleanUpRenderedElementsOf(propertyCard) - - const metrics: HTMLElement = this.elementWith('div', 'metrics-grid') - metrics.append( - this.reviewMetrics(property.getReviewMetrics()) - ) - metrics.append( - this.availabilityMetrics(property.getAvailabilityMetrics()) - ) - propertyCard.append(metrics) - - const updateNote: HTMLElement = this.elementWith( - 'div', 'note', 'ℹ️ Data displayed here can take up to an hour to be refreshed.' - ) - propertyCard.append(updateNote) - - await PropertyCardComponentPatcher.injectBookedCountries( - propertyCard, property.getBookedCountries() - ) - } - - public static async renderProcessingMessage (propertyId: number): Promise { - await waitForElement('.property-card .property-card-container') - - const propertyCards: NodeListOf = document.querySelectorAll('.property-card') - const propertyCard: Element | undefined = [...propertyCards].find( - card => card.innerHTML.includes(String(propertyId)) - ) - - if (!propertyCard) return - - this.cleanUpRenderedElementsOf(propertyCard) - const note: HTMLElement = this.elementWith( - 'div', 'note', '🔄 Property data are being processed...' - ) - propertyCard.append(note) - } - - private static cleanUpRenderedElementsOf (propertyCard: Element): void { - const metricsElement: HTMLDivElement | null = propertyCard.querySelector('.metrics-grid') - if (metricsElement) metricsElement.remove() - - const note: HTMLDivElement | null = propertyCard.querySelector('.note') - if (note) note.remove() - } - - private static reviewMetrics (metrics: ReviewMetrics): HTMLElement { - const items: MetricItem[] = [ - { label: 'Male', value: `${metrics.getMale()} (${metrics.getMalePercentage()}%)` }, - { label: 'Female', value: `${metrics.getFemale()} (${metrics.getFemalePercentage()}%)` }, - { label: 'Other', value: `${metrics.getOther()} (${metrics.getOtherPercentage()}%)` }, - { label: 'Solo', value: `${metrics.getSolo()} (${metrics.getSoloPercentage()}%)` }, - { label: 'Total', value: String(metrics.getTotal()) } - ] - - return this.metricsRow('Reviews', items) - } - - private static availabilityMetrics (metrics: AvailabilityMetrics): HTMLElement { - const items: MetricItem[] = [ - { - label: 'Mixed', - value: `${metrics.getMixedBeds()}/${metrics.getMaxMixedBeds()} (${metrics.getMixedBedsPercentage()}%)` - }, - { - label: 'Female', - value: `${metrics.getFemaleBeds()}/${metrics.getMaxFemaleBeds()} (${metrics.getFemaleBedsPercentage()}%)` - }, - { - label: 'Private', - value: `${metrics.getPrivateRooms()}/${metrics.getMaxPrivateRooms()} (${metrics.getPrivateRoomsPercentage()}%)` - }, - { - label: 'Guests', - value: `${metrics.getGuests()}/${metrics.getMaxGuests()} (${metrics.getGuestsPercentage()}%)` - } - ] - - return this.metricsRow('Availability', items) - } - - private static metricsRow (title: string, items: MetricItem[]): HTMLElement { - const rowElement: HTMLElement = this.elementWith('div', 'row') - - const titleElement: HTMLElement = this.elementWith('div', 'title') - titleElement.append(this.elementWith('span', undefined, title)) - titleElement.append(this.elementWith('span', undefined, '→')) - - rowElement.append(titleElement) - - for (const { label, value } of items) { - const item: HTMLElement = this.elementWith('div', 'item') - item.append(this.elementWith('div', 'label', label)) - item.append(this.elementWith('div', 'value', value)) - - rowElement.append(item) - } - - return rowElement - } - - private static elementWith (tagName: string, className?: string, textContent?: string): HTMLElement { - const element: HTMLElement = document.createElement(tagName) - if (className) element.className = className - if (textContent) element.textContent = textContent - - return element - } - - private static htmlEncode (value: string): string { - const element: HTMLDivElement = document.createElement('div') - element.textContent = value - return element.innerHTML - } -} diff --git a/src/app/Services/PropertyCompositionBuffer.ts b/src/app/Services/PropertyCompositionBuffer.ts new file mode 100644 index 0000000..8eb1809 --- /dev/null +++ b/src/app/Services/PropertyCompositionBuffer.ts @@ -0,0 +1,57 @@ +import type { PropertyReviews } from 'Services/Hostelworld/Api/ReviewsClient' +import type { PropertyAvailability } from 'Services/Hostelworld/Api/AvailabilityClient' +import type { PropertyGuestsCountries } from 'Services/Hostelworld/Api/VisitorsCountryClient' + +type PendingProperty = { + id: number + name: string + reviews?: PropertyReviews + availability?: PropertyAvailability + countries?: PropertyGuestsCountries +} + +export type CompletedProperty = { + id: number + name: string + reviews: PropertyReviews + availability: PropertyAvailability + countries: PropertyGuestsCountries +} + +export type MetricData = PropertyReviews | PropertyAvailability | PropertyGuestsCountries + +export type Metric = 'reviews' | 'availability' | 'countries' + +export class PropertyCompositionBuffer { + private static entries: Map = new Map() + + public static register (id: number, name: string): void { + if (this.entries.has(id)) return + + this.entries.set(id, { id, name }) + } + + public static collect (id: number, metric: Metric, data: MetricData): void { + const entry: PendingProperty | undefined = this.entries.get(id) + if (!entry) return + + if (metric === 'reviews') entry.reviews = data as PropertyReviews + if (metric === 'availability') entry.availability = data as PropertyAvailability + if (metric === 'countries') entry.countries = data as PropertyGuestsCountries + } + + public static completedEntry (id: number): CompletedProperty | undefined { + const entry: PendingProperty | undefined = this.entries.get(id) + if (!entry?.reviews || !entry?.availability || !entry?.countries) return undefined + + return entry as CompletedProperty + } + + public static remove (id: number): void { + this.entries.delete(id) + } + + public static clear (): void { + this.entries.clear() + } +} diff --git a/src/app/Services/PropertyFilterService.ts b/src/app/Services/PropertyFilterService.ts new file mode 100644 index 0000000..06a33cf --- /dev/null +++ b/src/app/Services/PropertyFilterService.ts @@ -0,0 +1,57 @@ +import type { Property } from 'DTOs/Property' +import type { ReviewMetrics } from 'DTOs/ReviewMetrics' +import type { FilterCriteria } from 'UI/Renderers/FilterModal/ViewDTOs' +import type { BadgeId } from 'Services/PropertyBadgeService' + +export class PropertyFilterService { + public static matchesCriteria (property: Property, criteria: FilterCriteria): boolean { + return this.matchesBadges(property, criteria) && + this.matchesRanges(property, criteria) + } + + private static matchesBadges (property: Property, criteria: FilterCriteria): boolean { + const badgeKeys: BadgeId[] = Object.keys(criteria.badges) as BadgeId[] + const enabledBadges: BadgeId[] = badgeKeys.filter( + key => criteria.badges[key] + ) + + if (!enabledBadges.length) return true + + const propertyBadgeIds: BadgeId[] = property.getBadges().map(badge => badge.id) + + for (const badgeKey of enabledBadges) { + if (!propertyBadgeIds.includes(badgeKey)) return false + } + + return true + } + + private static matchesRanges (property: Property, criteria: FilterCriteria): boolean { + for (const [key, range] of Object.entries(criteria.ranges)) { + const percentage: number | undefined = this.percentageForKey(property, key) + if (percentage === undefined) continue + + if (percentage < range.min || percentage > range.max) return false + } + + return true + } + + private static percentageForKey (property: Property, key: string): number | undefined { + const reviews: ReviewMetrics = property.getReviewMetrics() + + const percentageMap: Record number> = { + male: () => reviews.getMalePercentage(), + female: () => reviews.getFemalePercentage(), + solo: () => reviews.getSoloPercentage(), + age18to24: () => reviews.getAgePercentage('18-24'), + age25to30: () => reviews.getAgePercentage('25-30'), + age31to40: () => reviews.getAgePercentage('31-40'), + age41plus: () => reviews.getAgePercentage('41+') + } + + const getter: (() => number) | undefined = percentageMap[key] + + return getter ? getter() : undefined + } +} diff --git a/src/app/Types/HostelworldPropertyAvailability.ts b/src/app/Types/HostelworldPropertyAvailability.ts index fd92e9e..bd32bbe 100644 --- a/src/app/Types/HostelworldPropertyAvailability.ts +++ b/src/app/Types/HostelworldPropertyAvailability.ts @@ -1,7 +1,4 @@ -type FreeCancellation = { - label: string - description: string -} +import type { Image, FreeCancellation, Promotions } from './HostelworldShared' type LowestAverageDormPricePerNight = { value: string @@ -26,34 +23,24 @@ type BasicType = 'Mixed Dorm' | 'Female Dorm' | 'Private' | 'Dbl Private' type Grade = 'Standard' | 'Superior' | 'Basic' | '' -type Image = { - prefix: string - suffix: string -} - type PriceBreakdown = { ratePlan: number date: Date price: LowestAverageDormPricePerNight } -type ID = 'depositPayable' | 'nonRefundable' +type PaymentProcedureId = 'depositPayable' | 'nonRefundable' type Label = 'Deposit only' | 'Non-refundable' type RatePlanType = 'STANDARD' | 'BED_AND_BREAKFAST' type PaymentProcedure = { - id: ID + id: PaymentProcedureId label: Label description: string } -type Promotions = { - promotionsIds: number[] - totalDiscount: string -} - type RatePlan = { id: number paymentProcedure: PaymentProcedure @@ -74,11 +61,11 @@ type Dorm = { basicType: BasicType extendedType: string grade: Grade - bathroomFacilities: never[] + bathroomFacilities: unknown[] mealPlan: string view: string - bedTypes: never[] - facilities: never[] + bedTypes: unknown[] + facilities: unknown[] images: Image[] totalBedsAvailable: number totalRoomsAvailable: number | null @@ -87,7 +74,7 @@ type Dorm = { averagePricePerNight: AveragePricePerNight[] lowestPricePerNight: LowestAverageDormPricePerNight stp: LowestAverageDormPricePerNight | null - conditions: never[] + conditions: unknown[] totalPrice: AveragePricePerNight[] priceBreakdown: PriceBreakdown[] } @@ -113,5 +100,5 @@ export type HostelworldPropertyAvailability = { freeCancellationAvailable: boolean freeCancellationAvailableUntil: Date promotions: Promotion[] - stayRuleViolations: never[] + stayRuleViolations: unknown[] } diff --git a/src/app/Types/HostelworldPropertyReviews.ts b/src/app/Types/HostelworldPropertyReviews.ts index 4d48a91..be1d668 100644 --- a/src/app/Types/HostelworldPropertyReviews.ts +++ b/src/app/Types/HostelworldPropertyReviews.ts @@ -1,19 +1,16 @@ +import type { Image } from './HostelworldShared' + type Age = '18-24' | '25-30' | '31-40' | '41+' type GroupTypeCode = 'FEMALE' | 'MALE' | 'COUPLE' | 'ALLMALEGROUP' | 'ALLFEMALEGROUP' | 'MIXEDGROUP' type TripTypeCode = 'RTWTRIP' | 'GAPYEAR' | 'REGULARVACATION' | 'WEEKENDAWAY' | 'COLLEGEBREAK' | 'OTHER' -type ID = 'Female' | 'Male' +type GenderId = 'Female' | 'Male' type Gender = { - value: ID - id: ID -} - -type Image = { - prefix: string - suffix: string + value: GenderId + id: GenderId } type Nationality = { @@ -49,7 +46,7 @@ type ReviewStatistics = { groupsPercentage: number } -type Pagination = { +type ReviewPagination = { prev?: null next: string numberOfPages: number @@ -80,5 +77,5 @@ export type Review = { export type HostelworldPropertyReviews = { reviews: Review[] reviewStatistics: ReviewStatistics | null - pagination: Pagination + pagination: ReviewPagination } diff --git a/src/app/Types/HostelworldSearch.ts b/src/app/Types/HostelworldSearch.ts index 42babbd..12ca2bc 100644 --- a/src/app/Types/HostelworldSearch.ts +++ b/src/app/Types/HostelworldSearch.ts @@ -1,3 +1,5 @@ +import type { Image, FreeCancellation, Promotions } from './HostelworldShared' + type Currency = string type City = { @@ -17,7 +19,7 @@ type Location = { region: Region } -type Pagination = { +type SearchPagination = { next: string prev: string numberOfPages: number @@ -36,7 +38,7 @@ type District = { name: string } -type ID = +type FacilityCategoryId = 'FACILITYCATEGORYFREE' | 'FACILITYCATEGORYGENERAL' | 'FACILITYCATEGORYSERVICES' @@ -47,25 +49,10 @@ type Name = 'Free' | 'General' | 'Services' | 'Food & Drink' | 'Entertainment' type Facility = { name: Name - id: ID + id: FacilityCategoryId facilities: Region[] } -type FreeCancellation = { - label: string - description: string -} - -type Image = { - prefix: string - suffix: string -} - -type Promotions = { - promotionsIds: number[] - totalDiscount: string -} - type OverallRating = { overall: number numberOfRatings: string @@ -137,7 +124,7 @@ type Dorm = { extendedType: ExtendedType averagePrice: HighestPricePerNight stp: null - conditions: never[] + conditions: unknown[] } type Rooms = { @@ -179,7 +166,7 @@ export type Property = { hwExtra: null fabSort: { [key: string]: number } promotions: Promotion[] - stayRuleViolations: never[] + stayRuleViolations: unknown[] veryPopular?: boolean rooms: Rooms images: Image[] @@ -193,5 +180,5 @@ export type HostelworldSearch = { locationEn: Location filterData: FilterData sortOrder: null - pagination: Pagination + pagination: SearchPagination } diff --git a/src/app/Types/HostelworldShared.ts b/src/app/Types/HostelworldShared.ts new file mode 100644 index 0000000..fc3541e --- /dev/null +++ b/src/app/Types/HostelworldShared.ts @@ -0,0 +1,14 @@ +export type Image = { + prefix: string + suffix: string +} + +export type FreeCancellation = { + label: string + description: string +} + +export type Promotions = { + promotionsIds: number[] + totalDiscount: string +} diff --git a/src/app/UI/Renderers/FilterModal/FilterModalRenderer.ts b/src/app/UI/Renderers/FilterModal/FilterModalRenderer.ts new file mode 100644 index 0000000..01bf9c7 --- /dev/null +++ b/src/app/UI/Renderers/FilterModal/FilterModalRenderer.ts @@ -0,0 +1,60 @@ +import { FilterModalView } from 'UI/SolidJS/FilterModal/FilterModalView' +import type { FilterModalViewDTO } from './ViewDTOs' +import { FilterModalViewDTOFactory } from './ViewDTOs' +import { waitForElement } from 'Utils' +import { EventBus } from 'Core/EventBus' + +export class FilterModalRenderer { + private static readonly wrapperClassName: string = 'hor-filter-modal-wrapper' + private static readonly shareButtonSelector: string = '.search .property-share.share-button-container' + + private static wrapper: HTMLDivElement | null = null + private static observer: MutationObserver | null = null + private static readonly view: FilterModalView = new FilterModalView() + + public static async render (): Promise { + if (this.observer) return + + this.view.onFilter(criteria => EventBus.emit('filter:applied', criteria)) + + await waitForElement(this.shareButtonSelector) + + this.injectWrapper() + this.watchForRemoval() + } + + private static injectWrapper (): void { + const shareButton: HTMLElement | null = document.querySelector(this.shareButtonSelector) + if (!shareButton) return + + const parent: HTMLElement | null = shareButton.parentElement + if (!parent) return + + const existing: HTMLElement | null = parent.querySelector(`.${this.wrapperClassName}`) + if (existing) return + + this.wrapper = document.createElement('div') + this.wrapper.className = this.wrapperClassName + parent.insertBefore(this.wrapper, shareButton) + + const viewDto: FilterModalViewDTO = FilterModalViewDTOFactory.create() + this.view.mount(this.wrapper, viewDto) + } + + private static watchForRemoval (): void { + const shareButton: HTMLElement | null = document.querySelector(this.shareButtonSelector) + if (!shareButton) return + + const observeTarget: HTMLElement | null = shareButton.parentElement + if (!observeTarget) return + + this.observer = new MutationObserver(() => { + const existing: HTMLElement | null = observeTarget.querySelector(`.${this.wrapperClassName}`) + if (existing) return + + this.injectWrapper() + }) + + this.observer.observe(observeTarget, { childList: true, subtree: true }) + } +} diff --git a/src/app/UI/Renderers/FilterModal/ViewDTOs.ts b/src/app/UI/Renderers/FilterModal/ViewDTOs.ts new file mode 100644 index 0000000..d41667f --- /dev/null +++ b/src/app/UI/Renderers/FilterModal/ViewDTOs.ts @@ -0,0 +1,125 @@ +import type { BadgeColor, BadgeId, BadgeMetadata } from 'Services/PropertyBadgeService' +import { PropertyBadgeService } from 'Services/PropertyBadgeService' +import { ExtensionConfig } from 'Utils/ExtensionConfig' + +export type BadgeFilterViewDTO = { + key: BadgeId + label: string + color: BadgeColor + enabled: boolean +} + +export type RangeFilterViewDTO = { + key: string + label: string + description: string + min: number + max: number + rangeMin: number + rangeMax: number +} + +export type FilterSectionViewDTO = { + title: string + badgeFilters?: BadgeFilterViewDTO[] + rangeFilters?: RangeFilterViewDTO[] +} + +export type FilterModalViewDTO = { + isOpen: boolean + logo: string + extensionName: string + title: string + version: string + homepage: string + donateUrl: string + sections: FilterSectionViewDTO[] +} + +export type FilterCriteria = { + badges: Partial> + ranges: Record +} + +type RangeDefinition = { + key: string + label: string + description: string +} + +export class FilterModalViewDTOFactory { + private static readonly badgeDefinitions: BadgeMetadata[] = + PropertyBadgeService.badgeMetadata().filter(badge => badge.id !== 'closedDown') + + private static readonly demographicDefinitions: RangeDefinition[] = [ + { key: 'male', label: 'Male %', description: 'Filter by percentage of male reviewers' }, + { key: 'female', label: 'Female %', description: 'Filter by percentage of female reviewers' }, + { key: 'solo', label: 'Solo %', description: 'Filter by percentage of solo travelers' } + ] + + private static readonly ageGroupDefinitions: RangeDefinition[] = [ + { key: 'age18to24', label: '18-24 %', description: 'Filter by percentage of 18-24 year olds' }, + { key: 'age25to30', label: '25-30 %', description: 'Filter by percentage of 25-30 year olds' }, + { key: 'age31to40', label: '31-40 %', description: 'Filter by percentage of 31-40 year olds' }, + { key: 'age41plus', label: '41+ %', description: 'Filter by percentage of 41+ year olds' } + ] + + public static create (): FilterModalViewDTO { + return { + isOpen: false, + title: 'Filter Properties', + version: ExtensionConfig.version(), + homepage: ExtensionConfig.homepage(), + donateUrl: 'https://buymeacoffee.com/karamanisdev', + logo: ExtensionConfig.asset('icon'), + extensionName: ExtensionConfig.extensionName(), + sections: [ + this.badgesSection(), + this.demographicsSection(), + this.ageGroupsSection() + ] + } + } + + private static badgesSection (): FilterSectionViewDTO { + return { + title: 'Badges', + badgeFilters: this.badgeDefinitions.map(definition => ({ + key: definition.id, + label: definition.label, + color: definition.color, + enabled: false + })) + } + } + + private static demographicsSection (): FilterSectionViewDTO { + return { + title: 'Demographics', + rangeFilters: this.demographicDefinitions.map(definition => ({ + key: definition.key, + label: definition.label, + description: definition.description, + min: 0, + max: 100, + rangeMin: 0, + rangeMax: 100 + })) + } + } + + private static ageGroupsSection (): FilterSectionViewDTO { + return { + title: 'Age Groups', + rangeFilters: this.ageGroupDefinitions.map(definition => ({ + key: definition.key, + label: definition.label, + description: definition.description, + min: 0, + max: 100, + rangeMin: 0, + rangeMax: 100 + })) + } + } +} diff --git a/src/app/UI/Renderers/FilterModal/index.ts b/src/app/UI/Renderers/FilterModal/index.ts new file mode 100644 index 0000000..e460077 --- /dev/null +++ b/src/app/UI/Renderers/FilterModal/index.ts @@ -0,0 +1 @@ +export { FilterModalRenderer } from './FilterModalRenderer' diff --git a/src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts b/src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts new file mode 100644 index 0000000..b2f309a --- /dev/null +++ b/src/app/UI/Renderers/PropertyCard/PropertyCardRenderer.ts @@ -0,0 +1,94 @@ +import type { Property } from 'DTOs/Property' +import type { ReviewMetrics } from 'DTOs/ReviewMetrics' +import type { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' +import type { ViewAdapterInterface } from 'UI/ViewAdapterInterface' +import { PropertyCardView } from 'UI/SolidJS/PropertyCard/PropertyCardView' +import type { PropertyCardViewDTO } from './ViewDTOs' +import { PropertyCardViewDTOFactory } from './ViewDTOs' +import { waitForElement } from 'Utils' +import { PropertyCardComponentPatcher } from 'Services/Hostelworld/Patchers/PropertyCardComponentPatcher' +import { BadgeTagsView } from 'UI/SolidJS/BadgeTags/BadgeTagsView' +import { BookedCountry } from 'DTOs/BookedCountry' +import type { PropertyGuestsCountries } from 'Services/Hostelworld/Api/VisitorsCountryClient' + +export class PropertyCardRenderer { + private static readonly view: ViewAdapterInterface = new PropertyCardView() + private static readonly badgeTagsView: BadgeTagsView = new BadgeTagsView() + + public static async render (propertyId: number, propertyName?: string): Promise { + await waitForElement('.property-card .property-card-container') + + const container: HTMLElement | null = this.findContainer(propertyId, propertyName) + if (!container) return + + const viewDto: PropertyCardViewDTO = PropertyCardViewDTOFactory.loading(propertyId) + this.view.mount(container, viewDto) + } + + public static async renderWithData (property: Property): Promise { + await waitForElement('.property-card .property-card-container') + + const container: HTMLElement | null = this.findContainer(property.getId(), property.getName()) + if (!container) return + + const viewDto: PropertyCardViewDTO = PropertyCardViewDTOFactory.loaded(property) + this.view.mount(container, viewDto) + + await PropertyCardComponentPatcher.injectBookedCountries( + container, + property.getBookedCountries() + ) + + this.badgeTagsView.mount(container, { propertyId: property.getId(), badges: property.getBadges() }) + } + + public static updateReviewMetrics (propertyId: number, metrics: ReviewMetrics): void { + this.view.update?.({ + propertyId, + reviews: PropertyCardViewDTOFactory.reviewsRow(metrics), + ageGroups: PropertyCardViewDTOFactory.ageGroupsRow(metrics) + }) + } + + public static updateAvailabilityMetrics (propertyId: number, metrics: AvailabilityMetrics): void { + this.view.update?.({ + propertyId, + availability: PropertyCardViewDTOFactory.availabilityRow(metrics) + }) + } + + public static async updateCountries ( + propertyId: number, + propertyName: string, + countries: PropertyGuestsCountries + ): Promise { + const container: HTMLElement | null = this.findContainer(propertyId, propertyName) + if (!container) return + + const bookedCountries: BookedCountry[] = countries.map(country => new BookedCountry(country)) + await PropertyCardComponentPatcher.injectBookedCountries(container, bookedCountries) + } + + private static findContainer (propertyId: number, propertyName?: string): HTMLElement | null { + const propertyCards: NodeListOf = document.querySelectorAll('.property-card') + + for (const card of propertyCards) { + const containsId: boolean = new RegExp('\\b' + propertyId + '\\b').test(card.innerHTML) + if (!containsId) continue + + if (!propertyName) return card as HTMLElement + + const containsName: boolean = card.innerHTML.includes(this.htmlEncode(propertyName)) + if (containsName) return card as HTMLElement + } + + return null + } + + private static htmlEncode (value: string): string { + const element: HTMLDivElement = document.createElement('div') + element.textContent = value + + return element.innerHTML + } +} diff --git a/src/app/UI/Renderers/PropertyCard/ViewDTOs.ts b/src/app/UI/Renderers/PropertyCard/ViewDTOs.ts new file mode 100644 index 0000000..82fbfb5 --- /dev/null +++ b/src/app/UI/Renderers/PropertyCard/ViewDTOs.ts @@ -0,0 +1,119 @@ +import type { Property } from 'DTOs/Property' +import type { ReviewMetrics } from 'DTOs/ReviewMetrics' +import type { AvailabilityMetrics } from 'DTOs/AvailabilityMetrics' + +export type MetricType = 'reviews' | 'availability' | 'ageGroups' + +export type MetricValueViewDTO = { + label: string + value: string +} + +export type MetricRowViewDTO = { + title: string + items: MetricValueViewDTO[] +} + +export type PropertyCardViewDTO = { + propertyId: number + reviews?: MetricRowViewDTO | null + availability?: MetricRowViewDTO | null + ageGroups?: MetricRowViewDTO | null +} + +export class PropertyCardLabels { + public static readonly reviews: ReadonlyArray = ['Male', 'Female', 'Other', 'Solo'] + public static readonly availability: ReadonlyArray = ['Mixed', 'Female', 'Private', 'Guests'] + public static readonly ageGroups: ReadonlyArray = ['18-24', '25-30', '31-40', '41+'] + + public static forType (metricType: MetricType): ReadonlyArray { + switch (metricType) { + case 'reviews': return this.reviews + case 'availability': return this.availability + case 'ageGroups': return this.ageGroups + } + } + + public static titleForType (metricType: MetricType): string { + switch (metricType) { + case 'reviews': return 'Reviews' + case 'availability': return 'Availability' + case 'ageGroups': return 'Age Groups' + } + } +} + +export class PropertyCardNotes { + public static readonly loading: string = '🔄 Property data are being processed...' + public static readonly finalized: string = 'ℹ️ Data displayed here can take up to an hour to be refreshed.' +} + +export class PropertyCardViewDTOFactory { + public static loading (propertyId: number): PropertyCardViewDTO { + return { + propertyId, + reviews: undefined, + availability: undefined, + ageGroups: undefined + } + } + + public static loaded (property: Property): PropertyCardViewDTO { + return { + propertyId: property.getId(), + reviews: this.reviewsRow(property.getReviewMetrics()), + availability: this.availabilityRow(property.getAvailabilityMetrics()), + ageGroups: this.ageGroupsRow(property.getReviewMetrics()) + } + } + + public static reviewsRow (metrics: ReviewMetrics): MetricRowViewDTO { + return { + title: 'Reviews', + items: [ + { label: 'Male', value: `${metrics.getMale()}/${metrics.getTotal()} (${metrics.getMalePercentage()}%)` }, + { label: 'Female', value: `${metrics.getFemale()}/${metrics.getTotal()} (${metrics.getFemalePercentage()}%)` }, + { label: 'Other', value: `${metrics.getOther()}/${metrics.getTotal()} (${metrics.getOtherPercentage()}%)` }, + { label: 'Solo', value: `${metrics.getSolo()}/${metrics.getTotal()} (${metrics.getSoloPercentage()}%)` } + ] + } + } + + public static availabilityRow (metrics: AvailabilityMetrics): MetricRowViewDTO { + return { + title: 'Availability', + items: [ + { + label: 'Mixed', + value: `${metrics.getMixedBeds()}/${metrics.getMaxMixedBeds()} (${metrics.getMixedBedsPercentage()}%)` + }, + { + label: 'Female', + value: `${metrics.getFemaleBeds()}/${metrics.getMaxFemaleBeds()} (${metrics.getFemaleBedsPercentage()}%)` + }, + { + label: 'Private', + value: + `${metrics.getPrivateRooms()}/${metrics.getMaxPrivateRooms()} (${metrics.getPrivateRoomsPercentage()}%)` + }, + { + label: 'Guests', + value: `${metrics.getGuests()}/${metrics.getMaxGuests()} (${metrics.getGuestsPercentage()}%)` + } + ] + } + } + + public static ageGroupsRow (metrics: ReviewMetrics): MetricRowViewDTO { + const ages: Record = metrics.getAges() + const total: number = metrics.getTotal() + + return { + title: 'Age Groups', + items: PropertyCardLabels.ageGroups.map(bracket => ({ + label: bracket, + value: `${ages[bracket] ?? 0}/${total} (${metrics.getAgePercentage(bracket)}%)` + })) + } + } +} diff --git a/src/app/UI/Renderers/PropertyCard/index.ts b/src/app/UI/Renderers/PropertyCard/index.ts new file mode 100644 index 0000000..1a93a6f --- /dev/null +++ b/src/app/UI/Renderers/PropertyCard/index.ts @@ -0,0 +1 @@ +export { PropertyCardRenderer } from './PropertyCardRenderer' diff --git a/src/app/UI/SolidJS/BadgeTags/BadgeTagsView.ts b/src/app/UI/SolidJS/BadgeTags/BadgeTagsView.ts new file mode 100644 index 0000000..bd76764 --- /dev/null +++ b/src/app/UI/SolidJS/BadgeTags/BadgeTagsView.ts @@ -0,0 +1,55 @@ +import { render } from 'solid-js/web' +import type { PropertyBadge } from 'Services/PropertyBadgeService' +import type { ViewAdapterInterface } from 'UI/ViewAdapterInterface' +import { BadgeTagsContainer } from './Components/BadgeTagsContainer' + +export type BadgeTagsViewDTO = { + propertyId: number + badges: PropertyBadge[] +} + +type BadgeEntry = { + disposer: () => void + wrapper: HTMLElement +} + +export class BadgeTagsView implements ViewAdapterInterface { + private readonly entries: Map = new Map() + private readonly wrapperClass: string = 'extension-badge-tags-wrapper' + + public mount (container: HTMLElement, viewDto: BadgeTagsViewDTO): void { + const tagsContainer: HTMLElement | null = container.querySelector('.tags-container') + if (!tagsContainer) return + + this.disposeEntry(viewDto.propertyId) + + if (!viewDto.badges.length) return + + const wrapper: HTMLSpanElement = document.createElement('span') + wrapper.className = this.wrapperClass + wrapper.dataset.propertyId = String(viewDto.propertyId) + tagsContainer.appendChild(wrapper) + + const disposer: () => void = render( + () => BadgeTagsContainer({ badges: viewDto.badges }), + wrapper + ) + + this.entries.set(viewDto.propertyId, { disposer, wrapper }) + } + + public dispose (): void { + for (const propertyId of this.entries.keys()) { + this.disposeEntry(propertyId) + } + } + + private disposeEntry (propertyId: number): void { + const entry: BadgeEntry | undefined = this.entries.get(propertyId) + if (!entry) return + + entry.disposer() + entry.wrapper.remove() + this.entries.delete(propertyId) + } +} diff --git a/src/app/UI/SolidJS/BadgeTags/Components/BadgeTag.tsx b/src/app/UI/SolidJS/BadgeTags/Components/BadgeTag.tsx new file mode 100644 index 0000000..e6f7e92 --- /dev/null +++ b/src/app/UI/SolidJS/BadgeTags/Components/BadgeTag.tsx @@ -0,0 +1,22 @@ +import type { JSX } from 'solid-js' +import type { PropertyBadge } from 'Services/PropertyBadgeService' + +type Properties = { + badge: PropertyBadge +} + +export function BadgeTag (properties: Properties): JSX.Element { + return ( +
+
+ {properties.badge.label} +
+
+ ) +} diff --git a/src/app/UI/SolidJS/BadgeTags/Components/BadgeTagsContainer.tsx b/src/app/UI/SolidJS/BadgeTags/Components/BadgeTagsContainer.tsx new file mode 100644 index 0000000..73b09de --- /dev/null +++ b/src/app/UI/SolidJS/BadgeTags/Components/BadgeTagsContainer.tsx @@ -0,0 +1,15 @@ +import { For, type JSX } from 'solid-js' +import type { PropertyBadge } from 'Services/PropertyBadgeService' +import { BadgeTag } from './BadgeTag' + +type Properties = { + badges: PropertyBadge[] +} + +export function BadgeTagsContainer (properties: Properties): JSX.Element { + return ( + + {(badge) => } + + ) +} diff --git a/src/app/UI/SolidJS/FilterModal/Components/BadgeFilter.tsx b/src/app/UI/SolidJS/FilterModal/Components/BadgeFilter.tsx new file mode 100644 index 0000000..469bc54 --- /dev/null +++ b/src/app/UI/SolidJS/FilterModal/Components/BadgeFilter.tsx @@ -0,0 +1,40 @@ +import type { JSX } from 'solid-js' +import type { BadgeFilterViewDTO } from 'UI/Renderers/FilterModal/ViewDTOs' +import type { BadgeColor } from 'Services/PropertyBadgeService' + +type Properties = { + filter: BadgeFilterViewDTO + onChange: (key: string, enabled: boolean) => void +} + +type BadgeStyle = { + color: string + 'background-color': string +} + +export function BadgeFilter (properties: Properties): JSX.Element { + function badgeStyle (color: BadgeColor): BadgeStyle { + return { + color: `var(--wds-color-${color})`, + 'background-color': `var(--wds-color-${color}-lightest)` + } + } + + function handleChange (event: Event): void { + const target: HTMLInputElement = event.target as HTMLInputElement + properties.onChange(properties.filter.key, target.checked) + } + + return ( + + ) +} diff --git a/src/app/UI/SolidJS/FilterModal/Components/FilterButton.tsx b/src/app/UI/SolidJS/FilterModal/Components/FilterButton.tsx new file mode 100644 index 0000000..fb78696 --- /dev/null +++ b/src/app/UI/SolidJS/FilterModal/Components/FilterButton.tsx @@ -0,0 +1,19 @@ +import type { JSX } from 'solid-js' + +type Properties = { + onClick: () => void + logo: string +} + +export function FilterButton (properties: Properties): JSX.Element { + return ( + + ) +} diff --git a/src/app/UI/SolidJS/FilterModal/Components/FilterModal.tsx b/src/app/UI/SolidJS/FilterModal/Components/FilterModal.tsx new file mode 100644 index 0000000..5fa06cc --- /dev/null +++ b/src/app/UI/SolidJS/FilterModal/Components/FilterModal.tsx @@ -0,0 +1,148 @@ +import { For, type JSX } from 'solid-js' +import type { FilterSectionViewDTO } from 'UI/Renderers/FilterModal/ViewDTOs' +import { FilterSection } from './FilterSection' + +type Properties = { + title: string + extensionName: string + version: string + homepage: string + donateUrl: string + sections: FilterSectionViewDTO[] + onClose: () => void + onReset: () => void + onApply: () => void + onBadgeChange: (key: string, enabled: boolean) => void + onRangeMinChange: (key: string, value: number) => void + onRangeMaxChange: (key: string, value: number) => void +} + +export function FilterModal (properties: Properties): JSX.Element { + function handleOverlayClick (event: MouseEvent): void { + if (event.target !== event.currentTarget) return + + properties.onClose() + } + + return ( +
+
+
+

{properties.title}

+ +
+
+ + + + + + + + + Enjoying H.O.R? Buy me a coffee! + + + + + + {section => ( + + )} + +
+ +
+
+ ) +} diff --git a/src/app/UI/SolidJS/FilterModal/Components/FilterModalApp.tsx b/src/app/UI/SolidJS/FilterModal/Components/FilterModalApp.tsx new file mode 100644 index 0000000..54b37e1 --- /dev/null +++ b/src/app/UI/SolidJS/FilterModal/Components/FilterModalApp.tsx @@ -0,0 +1,224 @@ +import { createSignal, Show, type JSX, type Setter } from 'solid-js' +import type { FilterModalViewDTO, FilterCriteria } from 'UI/Renderers/FilterModal/ViewDTOs' +import { FilterModalViewDTOFactory } from 'UI/Renderers/FilterModal/ViewDTOs' +import { FilterButton } from './FilterButton' +import { FilterModal } from './FilterModal' + +export type FilterModalStateSetters = { + setViewDto: Setter +} + +type Properties = { + viewDto: FilterModalViewDTO + onFilterApplied: (criteria: FilterCriteria) => void + onStateReady: (setters: FilterModalStateSetters) => void +} + +export function FilterModalApp (properties: Properties): JSX.Element { + const [viewDto, setViewDto] = createSignal(properties.viewDto) + let stateBeforeOpen: FilterModalViewDTO | null = null + + properties.onStateReady({ setViewDto }) + + function saveStateBeforeOpen (): void { + stateBeforeOpen = viewDto() + } + + function restoreStateBeforeOpen (): void { + if (stateBeforeOpen) { + setViewDto({ ...stateBeforeOpen, isOpen: false }) + stateBeforeOpen = null + return + } + + setViewDto(previous => ({ ...previous, isOpen: false })) + } + + function commitChanges (): void { + stateBeforeOpen = null + } + + function handleOpen (): void { + saveStateBeforeOpen() + setViewDto(previous => ({ ...previous, isOpen: true })) + } + + function handleClose (): void { + restoreStateBeforeOpen() + } + + function enabledBadges (sections: FilterModalViewDTO['sections']): FilterCriteria['badges'] { + const badges: FilterCriteria['badges'] = {} + + for (const section of sections) { + if (!section.badgeFilters) continue + + for (const filter of section.badgeFilters) { + if (!filter.enabled) continue + badges[filter.key] = true + } + } + + return badges + } + + function modifiedRanges (sections: FilterModalViewDTO['sections']): FilterCriteria['ranges'] { + const ranges: FilterCriteria['ranges'] = {} + + for (const section of sections) { + if (!section.rangeFilters) continue + + for (const filter of section.rangeFilters) { + if (filter.rangeMin === filter.min && filter.rangeMax === filter.max) continue + ranges[filter.key] = { min: filter.rangeMin, max: filter.rangeMax } + } + } + + return ranges + } + + function filterCriteria (): FilterCriteria { + const sections: FilterModalViewDTO['sections'] = viewDto().sections + + return { + badges: enabledBadges(sections), + ranges: modifiedRanges(sections) + } + } + + function handleReset (): void { + commitChanges() + setViewDto(() => { + const reset: FilterModalViewDTO = FilterModalViewDTOFactory.create() + return { ...reset, isOpen: false } + }) + properties.onFilterApplied({ badges: {}, ranges: {} }) + } + + function handleApply (): void { + commitChanges() + setViewDto(previous => ({ ...previous, isOpen: false })) + + const criteria: FilterCriteria = filterCriteria() + properties.onFilterApplied(criteria) + } + + function handleBadgeChange (key: string, enabled: boolean): void { + setViewDto(previous => ({ + ...previous, + sections: previous.sections.map(section => { + if (!section.badgeFilters) return section + + return { + ...section, + badgeFilters: section.badgeFilters.map(filter => + filter.key === key ? { ...filter, enabled } : filter + ) + } + }) + })) + } + + function handleRangeChange (key: string, field: 'rangeMin' | 'rangeMax', value: number): void { + setViewDto(previous => ({ + ...previous, + sections: previous.sections.map(section => { + if (!section.rangeFilters) return section + + return { + ...section, + rangeFilters: section.rangeFilters.map(filter => + filter.key === key ? { ...filter, [field]: value } : filter + ) + } + }) + })) + } + + function handleRangeMinChange (key: string, value: number): void { + handleRangeChange(key, 'rangeMin', value) + } + + function handleRangeMaxChange (key: string, value: number): void { + handleRangeChange(key, 'rangeMax', value) + } + + return ( + <> + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/app/UI/SolidJS/FilterModal/Components/FilterSection.tsx b/src/app/UI/SolidJS/FilterModal/Components/FilterSection.tsx new file mode 100644 index 0000000..3a469ab --- /dev/null +++ b/src/app/UI/SolidJS/FilterModal/Components/FilterSection.tsx @@ -0,0 +1,46 @@ +import { For, Show, type JSX } from 'solid-js' +import type { FilterSectionViewDTO } from 'UI/Renderers/FilterModal/ViewDTOs' +import { BadgeFilter } from './BadgeFilter' +import { RangeFilter } from './RangeFilter' + +type Properties = { + section: FilterSectionViewDTO + onBadgeChange: (key: string, enabled: boolean) => void + onRangeMinChange: (key: string, value: number) => void + onRangeMaxChange: (key: string, value: number) => void +} + +export function FilterSection (properties: Properties): JSX.Element { + return ( +
+

{properties.section.title}

+
+ +
+ + {filter => ( + + )} + +
+
+ +
+ + {filter => ( + + )} + +
+
+
+
+ ) +} diff --git a/src/app/UI/SolidJS/FilterModal/Components/RangeFilter.tsx b/src/app/UI/SolidJS/FilterModal/Components/RangeFilter.tsx new file mode 100644 index 0000000..c4e94ea --- /dev/null +++ b/src/app/UI/SolidJS/FilterModal/Components/RangeFilter.tsx @@ -0,0 +1,86 @@ +import { createSignal, createEffect, type JSX } from 'solid-js' +import type { RangeFilterViewDTO } from 'UI/Renderers/FilterModal/ViewDTOs' + +type Properties = { + filter: RangeFilterViewDTO + onMinChange: (key: string, value: number) => void + onMaxChange: (key: string, value: number) => void +} + +export function RangeFilter (properties: Properties): JSX.Element { + const minimumGap: number = 10 + + const [localMin, setLocalMin] = createSignal(properties.filter.rangeMin) + const [localMax, setLocalMax] = createSignal(properties.filter.rangeMax) + + createEffect(() => { + setLocalMin(properties.filter.rangeMin) + }) + + createEffect(() => { + setLocalMax(properties.filter.rangeMax) + }) + + function clampedMinValue (value: number): number { + const upperBound: number = localMax() - minimumGap + return Math.min(value, upperBound) + } + + function clampedMaxValue (value: number): number { + const lowerBound: number = localMin() + minimumGap + return Math.max(value, lowerBound) + } + + function handleMinInput (event: Event): void { + const target: HTMLInputElement = event.target as HTMLInputElement + const clamped: number = clampedMinValue(Number(target.value)) + setLocalMin(clamped) + target.value = String(clamped) + } + + function handleMaxInput (event: Event): void { + const target: HTMLInputElement = event.target as HTMLInputElement + const clamped: number = clampedMaxValue(Number(target.value)) + setLocalMax(clamped) + target.value = String(clamped) + } + + function handleMinCommit (): void { + properties.onMinChange(properties.filter.key, localMin()) + } + + function handleMaxCommit (): void { + properties.onMaxChange(properties.filter.key, localMax()) + } + + return ( +
+
+ + + {localMin()}% - {localMax()}% + +
+
+ + +
+
+ ) +} diff --git a/src/app/UI/SolidJS/FilterModal/FilterModalView.ts b/src/app/UI/SolidJS/FilterModal/FilterModalView.ts new file mode 100644 index 0000000..0119f38 --- /dev/null +++ b/src/app/UI/SolidJS/FilterModal/FilterModalView.ts @@ -0,0 +1,50 @@ +import { render } from 'solid-js/web' +import type { ViewAdapterInterface } from 'UI/ViewAdapterInterface' +import type { FilterModalViewDTO, FilterCriteria } from 'UI/Renderers/FilterModal/ViewDTOs' +import { FilterModalApp, type FilterModalStateSetters } from './Components/FilterModalApp' + +type MountedComponent = { + disposer: () => void + setters: FilterModalStateSetters | null +} + +export class FilterModalView implements ViewAdapterInterface { + private mounted: MountedComponent | null = null + private onFilterApplied: ((criteria: FilterCriteria) => void) | null = null + + public onFilter (callback: (criteria: FilterCriteria) => void): void { + this.onFilterApplied = callback + } + + public mount (container: HTMLElement, viewDto: FilterModalViewDTO): void { + this.dispose() + + let capturedSetters: FilterModalStateSetters | null = null + + const disposer: () => void = render( + () => FilterModalApp({ + viewDto, + onFilterApplied: (criteria: FilterCriteria) => this.onFilterApplied?.(criteria), + onStateReady: (setters: FilterModalStateSetters) => { + capturedSetters = setters + } + }), + container + ) + + this.mounted = { disposer, setters: capturedSetters } + } + + public update (viewDto: Partial): void { + if (!this.mounted?.setters) return + + this.mounted.setters.setViewDto(previous => ({ ...previous, ...viewDto })) + } + + public dispose (): void { + if (!this.mounted) return + + this.mounted.disposer() + this.mounted = null + } +} diff --git a/src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx b/src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx new file mode 100644 index 0000000..066574a --- /dev/null +++ b/src/app/UI/SolidJS/PropertyCard/Components/MetricsRow.tsx @@ -0,0 +1,38 @@ +import { Show, For, type JSX } from 'solid-js' +import type { MetricRowViewDTO, MetricType } from 'UI/Renderers/PropertyCard/ViewDTOs' +import { PropertyCardLabels } from 'UI/Renderers/PropertyCard/ViewDTOs' + +type Properties = { + metricType: MetricType + data?: MetricRowViewDTO | null +} + +export function MetricsRow (properties: Properties): JSX.Element { + const title: string = PropertyCardLabels.titleForType(properties.metricType) + const labels: ReadonlyArray = PropertyCardLabels.forType(properties.metricType) + function isDisabled (): boolean { return properties.data === null } + function isLoaded (): boolean { return properties.data !== null && properties.data !== undefined } + + return ( + +
+
+ {title} + +
+ + {(label, index) => ( +
+
{label}
+
+ }> + {properties.data?.items[index()]?.value ?? ''} + +
+
+ )} +
+
+
+ ) +} diff --git a/src/app/UI/SolidJS/PropertyCard/Components/Note.tsx b/src/app/UI/SolidJS/PropertyCard/Components/Note.tsx new file mode 100644 index 0000000..9022a75 --- /dev/null +++ b/src/app/UI/SolidJS/PropertyCard/Components/Note.tsx @@ -0,0 +1,17 @@ +import type { JSX } from 'solid-js' + +type Properties = { + message: string + isLoading?: boolean +} + +export function Note (properties: Properties): JSX.Element { + const className: string = properties.isLoading ? 'hor-note hor-loading' : 'hor-note' + const dataAttributes: Record = properties.isLoading ? { 'data-note-type': 'loading' } : {} + + return ( +
+ {properties.message} +
+ ) +} diff --git a/src/app/UI/SolidJS/PropertyCard/Components/PropertyCardContainer.tsx b/src/app/UI/SolidJS/PropertyCard/Components/PropertyCardContainer.tsx new file mode 100644 index 0000000..f19f65c --- /dev/null +++ b/src/app/UI/SolidJS/PropertyCard/Components/PropertyCardContainer.tsx @@ -0,0 +1,55 @@ +import { createSignal, createMemo, type Setter, type JSX } from 'solid-js' +import type { MetricRowViewDTO } from 'UI/Renderers/PropertyCard/ViewDTOs' +import { PropertyCardNotes } from 'UI/Renderers/PropertyCard/ViewDTOs' +import { MetricsRow } from './MetricsRow' +import { Note } from './Note' + +export type CardState = { + reviews?: MetricRowViewDTO | null + availability?: MetricRowViewDTO | null + ageGroups?: MetricRowViewDTO | null +} + +export type CardStateSetters = { + setReviews: Setter + setAvailability: Setter + setAgeGroups: Setter +} + +type Properties = { + propertyId: number + initialState: CardState + onStateReady: (setters: CardStateSetters) => void +} + +export function PropertyCardContainer (properties: Properties): JSX.Element { + const [reviews, setReviews] = createSignal( + properties.initialState.reviews + ) + const [availability, setAvailability] = createSignal( + properties.initialState.availability + ) + const [ageGroups, setAgeGroups] = createSignal( + properties.initialState.ageGroups + ) + + properties.onStateReady({ setReviews, setAvailability, setAgeGroups }) + + const isFinalized = createMemo(() => + reviews() !== undefined && availability() !== undefined && ageGroups() !== undefined + ) + const isLoading = createMemo(() => !isFinalized()) + const note = createMemo(() => isFinalized() ? PropertyCardNotes.finalized : PropertyCardNotes.loading) + + return ( + <> +
+ + + +
+ + + + ) +} diff --git a/src/app/UI/SolidJS/PropertyCard/PropertyCardView.ts b/src/app/UI/SolidJS/PropertyCard/PropertyCardView.ts new file mode 100644 index 0000000..13c0d2e --- /dev/null +++ b/src/app/UI/SolidJS/PropertyCard/PropertyCardView.ts @@ -0,0 +1,80 @@ +import { render } from 'solid-js/web' +import type { ViewAdapterInterface } from 'UI/ViewAdapterInterface' +import type { PropertyCardViewDTO } from 'UI/Renderers/PropertyCard/ViewDTOs' +import { PropertyCardContainer, type CardState, type CardStateSetters } from './Components/PropertyCardContainer' + +type CardEntry = { + disposer: () => void + wrapper: HTMLElement + setters: CardStateSetters | null +} + +export class PropertyCardView implements ViewAdapterInterface { + private readonly entries: Map = new Map() + + public mount (container: HTMLElement, viewDto: PropertyCardViewDTO): void { + this.mountContainer(container, viewDto.propertyId, { + reviews: viewDto.reviews, + availability: viewDto.availability, + ageGroups: viewDto.ageGroups + }) + } + + public update (viewDto: Partial): void { + if (!viewDto.propertyId) return + + const entry: CardEntry | undefined = this.entries.get(viewDto.propertyId) + if (!entry?.setters) return + + if ('reviews' in viewDto) { + entry.setters.setReviews(viewDto.reviews) + } + + if ('availability' in viewDto) { + entry.setters.setAvailability(viewDto.availability) + } + + if ('ageGroups' in viewDto) { + entry.setters.setAgeGroups(viewDto.ageGroups) + } + } + + public dispose (): void { + for (const propertyId of this.entries.keys()) { + this.disposeEntry(propertyId) + } + } + + private disposeEntry (propertyId: number): void { + const entry: CardEntry | undefined = this.entries.get(propertyId) + if (!entry) return + + entry.disposer() + entry.wrapper.remove() + this.entries.delete(propertyId) + } + + private mountContainer (container: HTMLElement, propertyId: number, initialState: CardState): void { + this.disposeEntry(propertyId) + + const wrapper: HTMLDivElement = document.createElement('div') + wrapper.className = 'property-card-wrapper' + wrapper.dataset.propertyId = String(propertyId) + container.appendChild(wrapper) + + let capturedSetters: CardStateSetters | null = null + + const disposer: () => void = render( + () => PropertyCardContainer({ + propertyId, + initialState, + onStateReady: (setters: CardStateSetters) => { + capturedSetters = setters + } + }), + wrapper + ) + + this.entries.set(propertyId, { disposer, wrapper, setters: capturedSetters }) + } +} diff --git a/src/app/UI/ViewAdapterInterface.ts b/src/app/UI/ViewAdapterInterface.ts new file mode 100644 index 0000000..975474d --- /dev/null +++ b/src/app/UI/ViewAdapterInterface.ts @@ -0,0 +1,5 @@ +export interface ViewAdapterInterface { + mount (container: HTMLElement, viewDto: TDto): void + update? (viewDto: Partial): void + dispose (): void +} diff --git a/src/app/Utils/ExtensionConfig.ts b/src/app/Utils/ExtensionConfig.ts new file mode 100644 index 0000000..d70e4ea --- /dev/null +++ b/src/app/Utils/ExtensionConfig.ts @@ -0,0 +1,46 @@ +type ConfigPayload = { + name: string + version: string + homepage: string + assets: { + icon: string + } +} + +export class ExtensionConfig { + private static cachedConfig: ConfigPayload | null = null + private static readonly configAttribute: string = 'horConfig' + + public static init (payload: ConfigPayload): void { + document.documentElement.dataset[this.configAttribute] = JSON.stringify(payload) + } + + public static version (): string { + return this.config().version + } + + public static homepage (): string { + return this.config().homepage + } + + public static extensionName (): string { + return this.config().name + } + + public static asset (key: keyof ConfigPayload['assets']): string { + return this.config().assets[key] + } + + private static config (): ConfigPayload { + if (this.cachedConfig) return this.cachedConfig + + const raw: string | undefined = document.documentElement.dataset[this.configAttribute] + if (!raw) { + throw new Error('Extension config was not initialized.') + } + + this.cachedConfig = JSON.parse(raw) as ConfigPayload + + return this.cachedConfig + } +} diff --git a/src/app/Utils/ExtensionRuntime.ts b/src/app/Utils/ExtensionRuntime.ts index 35a848d..146d766 100644 --- a/src/app/Utils/ExtensionRuntime.ts +++ b/src/app/Utils/ExtensionRuntime.ts @@ -10,12 +10,24 @@ type MessageSender = Runtime.MessageSender type OnMessageHandler = (event: string, payload: TPayload) => void export class ExtensionRuntime { - static contentScriptTabs: Set = new Set() + private static contentScriptTabs: Set = new Set() public static assetUrl (filename: string): string { return Extension.runtime.getURL(filename) } + public static manifestVersion (): string { + return Extension.runtime.getManifest().version + } + + public static manifestHomepage (): string { + return Extension.runtime.getManifest().homepage_url as string + } + + public static manifestName (): string { + return Extension.runtime.getManifest().name + } + public static isWithinServiceWorker (): boolean { return typeof window === 'undefined' && typeof self !== 'undefined' } diff --git a/src/app/Utils/ScriptLoader.ts b/src/app/Utils/ScriptLoader.ts index 91e8469..a8b0e79 100644 --- a/src/app/Utils/ScriptLoader.ts +++ b/src/app/Utils/ScriptLoader.ts @@ -2,7 +2,7 @@ import { hash } from './Utils' export class ScriptLoader { public static async inject (url: string): Promise { - return new Promise((resolve: Function, reject: Function) => { + return new Promise((resolve: () => void, reject: (error: Error) => void) => { if (this.isLoaded(url)) return resolve() const script: HTMLScriptElement = this.createScriptElement(url) diff --git a/src/app/Utils/Storage/Adapters/CacheApiAdapter.ts b/src/app/Utils/Storage/Adapters/CacheApiAdapter.ts index da555f6..390079c 100644 --- a/src/app/Utils/Storage/Adapters/CacheApiAdapter.ts +++ b/src/app/Utils/Storage/Adapters/CacheApiAdapter.ts @@ -16,7 +16,7 @@ export class CacheApiAdapter implements StorageAdapterInterface { const cache: Cache = await this.getCache() const response: Response | undefined = await cache.match(this.key(key)) - if (!response) return undefined + if (!response) return return await response.json() } diff --git a/src/app/Utils/Utils.ts b/src/app/Utils/Utils.ts index a40e13a..5f351cb 100644 --- a/src/app/Utils/Utils.ts +++ b/src/app/Utils/Utils.ts @@ -55,18 +55,18 @@ export async function waitForElement ( } await delay(100) - return waitForElement(selector, maxTimeout - 100) + return waitForElement(selector, maxTimeout - 100, onElement) } -export function objectPick (object: object, keys: string[]): object { +export function objectPick, K extends keyof T> (object: T, keys: K[]): Pick { return Object.fromEntries( Object .entries(object) - .filter(([key]) => keys.includes(key)) - ) + .filter(([key]) => (keys as string[]).includes(key)) + ) as Pick } -export function objectsAreEqual (object1: Record, object2: Record): boolean { +export function shallowEqual (object1: Record, object2: Record): boolean { const object1Keys: string[] = Object.keys(object1) const object2Keys: string[] = Object.keys(object2) @@ -114,16 +114,18 @@ export function dateAddDays (date: Date, days: number): Date { return newDate } -export async function promiseFallback (promise: Promise, fallback?: T): Promise { +export async function promiseFallback (promise: Promise, fallback: T): Promise +export async function promiseFallback (promise: Promise): Promise +export async function promiseFallback (promise: Promise, fallback?: T): Promise { try { return await promise } catch { - return fallback as T + return fallback } } -export async function promisesFulfillSequentially (promiseFactories: (() => Promise)[]): Promise { - const outputs: unknown[] = [] +export async function promisesFulfillSequentially (promiseFactories: (() => Promise)[]): Promise { + const outputs: void[] = [] for (const factory of promiseFactories) { outputs.push(await factory()) diff --git a/src/app/Utils/XHRRequestInterceptor/CustomXMLHttpRequest.ts b/src/app/Utils/XHRRequestInterceptor/CustomXMLHttpRequest.ts index b44c03e..027dded 100644 --- a/src/app/Utils/XHRRequestInterceptor/CustomXMLHttpRequest.ts +++ b/src/app/Utils/XHRRequestInterceptor/CustomXMLHttpRequest.ts @@ -1,3 +1,7 @@ +import type { InterceptionStage } from './RequestModifier' + +type InterceptCallback = (request: CustomXMLHttpRequest, stage: InterceptionStage | 'construct') => void + type BackingProperties = { -readonly [K in keyof T]?: unknown } @@ -6,7 +10,7 @@ export class CustomXMLHttpRequest extends XMLHttpRequest { public url: string = '' public method: string = '' public backing: BackingProperties = {} - private static interceptCallback?: Function + private static interceptCallback?: InterceptCallback constructor () { super() @@ -26,7 +30,7 @@ export class CustomXMLHttpRequest extends XMLHttpRequest { : super.open(this.method, this.url, async, username, password) } - public static setInterceptCallback (callback: Function): void { + public static setInterceptCallback (callback: InterceptCallback): void { this.interceptCallback = callback } diff --git a/src/app/Utils/XHRRequestInterceptor/RequestModifier.ts b/src/app/Utils/XHRRequestInterceptor/RequestModifier.ts index 3f83084..47d53b7 100644 --- a/src/app/Utils/XHRRequestInterceptor/RequestModifier.ts +++ b/src/app/Utils/XHRRequestInterceptor/RequestModifier.ts @@ -97,7 +97,7 @@ export class RequestModifier { Object.defineProperty(request, property, { get: () => { - request.backing[property] = value || request.backing[property] + request.backing[property] = value ?? request.backing[property] return request.backing[property] }, diff --git a/src/app/Utils/XHRRequestInterceptor/XHRRequestInterceptor.ts b/src/app/Utils/XHRRequestInterceptor/XHRRequestInterceptor.ts index e800c17..1c20555 100644 --- a/src/app/Utils/XHRRequestInterceptor/XHRRequestInterceptor.ts +++ b/src/app/Utils/XHRRequestInterceptor/XHRRequestInterceptor.ts @@ -1,5 +1,5 @@ import type { InterceptionStage } from './RequestModifier' -import { objectsAreEqual } from 'Utils' +import { shallowEqual } from 'Utils' import { RequestModifier } from './RequestModifier' import { CustomXMLHttpRequest } from './CustomXMLHttpRequest' @@ -31,7 +31,7 @@ export class XHRRequestInterceptor { public static intercept (query: RequestQuery): RequestModifier { const modifier: RequestModifier = new RequestModifier() const interception: Interception | undefined = this.interceptions.find( - interception => objectsAreEqual(interception.query, query) + interception => shallowEqual(interception.query, query) ) if (interception) { @@ -43,7 +43,9 @@ export class XHRRequestInterceptor { return modifier } - private static interceptOn (request: CustomXMLHttpRequest, stage: InterceptionStage): void { + private static interceptOn (request: CustomXMLHttpRequest, stage: InterceptionStage | 'construct'): void { + if (stage === 'construct') return + const interceptions: Interception[] = this.matchedInterceptionsFor(request) for (const { modifiers } of interceptions) { diff --git a/src/app/WorkerTasks/Tasks/AbstractQueuedTask.ts b/src/app/WorkerTasks/Tasks/AbstractQueuedTask.ts new file mode 100644 index 0000000..7b6673d --- /dev/null +++ b/src/app/WorkerTasks/Tasks/AbstractQueuedTask.ts @@ -0,0 +1,72 @@ +import type { WorkerTask, WorkerTaskResult } from './WorkerTask' +import { delay, promiseFallback } from 'Utils' + +type JobId = string | number + +type Job = { + id: JobId + args: TArgs + promise: Promise + resolve: (value: TResult) => void + reject: (reason?: unknown) => void +} + +export abstract class AbstractQueuedTask implements WorkerTask { + private isProcessing: boolean = false + private readonly maxConcurrency: number = 4 + private readonly delayBetweenBatches: number = 200 + private readonly queue: Map> = new Map() + + protected abstract jobId (args: TArgs): JobId + protected abstract execute (args: TArgs): Promise + + public handle (...args: unknown[]): WorkerTaskResult { + const typedArgs: TArgs = args as unknown as TArgs + const id: JobId = this.jobId(typedArgs) + + const existing: Job | undefined = this.queue.get(id) + if (existing) return existing.promise + + let resolvePromise!: (value: TResult) => void + let rejectPromise!: (reason?: unknown) => void + const promise: Promise = new Promise((resolve, reject) => { + resolvePromise = resolve + rejectPromise = reject + }) + + this.queue.set(id, { id, args: typedArgs, promise, resolve: resolvePromise, reject: rejectPromise }) + void promiseFallback(this.processQueue()) + + return promise + } + + private async processQueue (): Promise { + if (this.isProcessing) return + this.isProcessing = true + + try { + while (this.queue.size > 0) { + const batch: Job[] = [] + + for (let index: number = 0; index < this.maxConcurrency && this.queue.size > 0; index++) { + const [id, job]: [JobId, Job] = this.queue.entries().next().value! + this.queue.delete(id) + batch.push(job) + } + + await Promise.all(batch.map(async (job: Job): Promise => { + try { + const result: TResult = await this.execute(job.args) + job.resolve(result) + } catch (error) { + job.reject(error) + } + })) + + await delay(this.delayBetweenBatches) + } + } finally { + this.isProcessing = false + } + } +} diff --git a/src/app/WorkerTasks/Tasks/ComposePropertyTask.ts b/src/app/WorkerTasks/Tasks/ComposePropertyTask.ts deleted file mode 100644 index 5aecccd..0000000 --- a/src/app/WorkerTasks/Tasks/ComposePropertyTask.ts +++ /dev/null @@ -1,70 +0,0 @@ -import type { WorkerTask, WorkerTaskResult } from './WorkerTask' -import { Search } from 'DTOs/Search' -import type { Property } from 'DTOs/Property' -import type { Property as HostelworldProperty } from 'Types/HostelworldSearch' -import { PropertyFactory } from 'Factories/PropertyFactory' -import { delay, promiseFallback } from 'Utils' - -type PropertyId = number -type ComposePropertyJob = { - search: Search - property: HostelworldProperty - promise: Promise - resolve: (value: Property) => void; - reject: (reason?: unknown) => void; -} - -export class ComposePropertyTask implements WorkerTask { - private isProcessing: boolean = false - private readonly maxConcurrency: number = 3 - private readonly queue: Map = new Map() - - public handle (property: HostelworldProperty, search: Search): WorkerTaskResult { - const { id } = property - - const queued: ComposePropertyJob | undefined = this.queue.get(id) - if (queued) return queued.promise - - let resolvePromise!: (value: Property) => void - let rejectPromise!: (reason?: unknown) => void - const promiseToReturn: Promise = new Promise((resolve, reject) => { - rejectPromise = reject - resolvePromise = resolve - }) - - this.queue.set(id, { search, property, promise: promiseToReturn, resolve: resolvePromise, reject: rejectPromise }) - void promiseFallback(this.processQueue()) - - return promiseToReturn - } - - private async processQueue (): Promise { - if (this.isProcessing) return - this.isProcessing = true - - try { - while (this.queue.size > 0) { - const batch: ComposePropertyJob[] = [] - - for (let index: number = 0; index < this.maxConcurrency && this.queue.size > 0; index++) { - const [id, job]: [PropertyId, ComposePropertyJob] = this.queue.entries().next().value! - this.queue.delete(id) - - batch.push(job) - } - - await Promise.all(batch.map(async (job: ComposePropertyJob): Promise => { - const { search, property, resolve, reject } = job - - await PropertyFactory.create(property, search) - .then(composed => resolve(composed)) - .catch(error => reject(error)) - })) - - await delay(360) - } - } finally { - this.isProcessing = false - } - } -} diff --git a/src/app/WorkerTasks/Tasks/FetchAvailabilityTask.ts b/src/app/WorkerTasks/Tasks/FetchAvailabilityTask.ts new file mode 100644 index 0000000..9585531 --- /dev/null +++ b/src/app/WorkerTasks/Tasks/FetchAvailabilityTask.ts @@ -0,0 +1,25 @@ +import { AbstractQueuedTask } from './AbstractQueuedTask' +import { AvailabilityClient, type PropertyAvailability } from 'Services/Hostelworld/Api/AvailabilityClient' + +type Args = [propertyId: number, propertyName: string, from: string, to: string] +type Result = { propertyId: number; propertyName: string; data: PropertyAvailability } + +export class FetchAvailabilityTask extends AbstractQueuedTask { + protected jobId (args: Args): number { + const [propertyId]: Args = args + + return propertyId + } + + protected async execute (args: Args): Promise { + const [propertyId, propertyName, from, to]: Args = args + + const data: PropertyAvailability = await AvailabilityClient.fetch( + propertyId, + new Date(from), + new Date(to) + ) + + return { propertyId, propertyName, data } + } +} diff --git a/src/app/WorkerTasks/Tasks/FetchCountriesTask.ts b/src/app/WorkerTasks/Tasks/FetchCountriesTask.ts new file mode 100644 index 0000000..054f3de --- /dev/null +++ b/src/app/WorkerTasks/Tasks/FetchCountriesTask.ts @@ -0,0 +1,24 @@ +import { AbstractQueuedTask } from './AbstractQueuedTask' +import { VisitorsCountryClient, type PropertyGuestsCountries } from 'Services/Hostelworld/Api/VisitorsCountryClient' + +type Args = [propertyId: number, propertyName: string, from: string, to: string] +type Result = { propertyId: number; propertyName: string; data: PropertyGuestsCountries } + +export class FetchCountriesTask extends AbstractQueuedTask { + protected jobId (args: Args): number { + const [propertyId]: Args = args + + return propertyId + } + + protected async execute (args: Args): Promise { + const [propertyId, propertyName, from, to]: Args = args + const data: PropertyGuestsCountries = await VisitorsCountryClient.fetch( + propertyId, + new Date(from), + new Date(to) + ) + + return { propertyId, propertyName, data } + } +} diff --git a/src/app/WorkerTasks/Tasks/FetchReviewsTask.ts b/src/app/WorkerTasks/Tasks/FetchReviewsTask.ts new file mode 100644 index 0000000..3953040 --- /dev/null +++ b/src/app/WorkerTasks/Tasks/FetchReviewsTask.ts @@ -0,0 +1,20 @@ +import { AbstractQueuedTask } from './AbstractQueuedTask' +import { ReviewsClient, type PropertyReviews } from 'Services/Hostelworld/Api/ReviewsClient' + +type Args = [propertyId: number, propertyName: string] +type Result = { propertyId: number; propertyName: string; data: PropertyReviews } + +export class FetchReviewsTask extends AbstractQueuedTask { + protected jobId (args: Args): number { + const [propertyId]: Args = args + + return propertyId + } + + protected async execute (args: Args): Promise { + const [propertyId, propertyName]: Args = args + const data: PropertyReviews = await ReviewsClient.fetch(propertyId) + + return { propertyId, propertyName, data } + } +} diff --git a/src/app/global.d.ts b/src/app/global.d.ts index 670b138..f967eff 100644 --- a/src/app/global.d.ts +++ b/src/app/global.d.ts @@ -1,5 +1,5 @@ type Callback = (value: T) => T -type ClassConstructor = new (...args?: unknown[]) => T +type ClassConstructor = new (...args: unknown[]) => T interface Window { $nuxt: object diff --git a/src/assets/static/manifest.json b/src/assets/static/manifest.json index 543b752..6746f09 100644 --- a/src/assets/static/manifest.json +++ b/src/assets/static/manifest.json @@ -2,6 +2,7 @@ "name": "Hostelworld on Roids (H.O.R)", "version": "0.0.0", "description": "Take your Hostelworld hunt from “meh” to “marvelous” — your secret weapon for hostel-hunting!", + "homepage_url": "https://github.com/KaramanisDev/Hostelworld-On-Roids", "manifest_version": 3, "icons": { "16": "icons/icon16.png", @@ -34,7 +35,8 @@ "web_accessible_resources": [ { "resources": [ - "roids.js" + "roids.js", + "icons/icon32.png" ], "matches": [ "https://hostelworld.com/*", diff --git a/src/assets/styles/app.scss b/src/assets/styles/app.scss index 931f955..794a8c2 100644 --- a/src/assets/styles/app.scss +++ b/src/assets/styles/app.scss @@ -3,6 +3,26 @@ box-shadow: 0 0 0.625rem var(--wds-color-ink-lighter); border: 0.0625rem solid var(--wds-color-ink-lighter); + .tags-container { + flex-direction: column; + + .hor-tag-badge { + margin-bottom: 0.5rem; + display: flex; + width: fit-content; + clip-path: polygon(0 0, 100% 0, calc(100% - 4px) 100%, 0 100%); + + .hor-tag-text { + font-family: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Helvetica, Arial, sans-serif; + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.0125rem; + line-height: 1rem; + padding: 0.125rem 0.5rem; + } + } + } + .property-card-container { box-shadow: none; border-radius: 0.5rem 0.5rem 0 0 !important; @@ -37,37 +57,41 @@ } } - .metrics-grid { + .hor-metrics-grid { width: 100%; display: grid; + line-height: 1.2rem; grid-column: 1 / -1; - grid-template-columns: fit-content(100%) repeat(5, auto); + grid-template-columns: fit-content(100%) repeat(4, auto); + contain: layout; + content-visibility: auto; + contain-intrinsic-size: auto 80px; - .row { + .hor-row { display: contents; - .title, - .item { + .hor-title, + .hor-item { padding: 5px; display: flex; align-items: center; flex-direction: column; justify-content: space-between; - &.title { + &.hor-title { font-size: 0.85rem; grid-column: 1; color: var(--wds-color-ink-darker); } - &.item { + &.hor-item { font-size: 0.8rem; - .label { + .hor-label { color: var(--wds-color-ink-darker); } - .value { + .hor-value { white-space: nowrap; color: var(--wds-color-ink); } @@ -75,28 +99,497 @@ } &:not(:last-child) { - .title, - .item { + .hor-title, + .hor-item { border-bottom: 0.0625rem solid var(--wds-color-ink-lighter); } } } } - .note { + .hor-note { text-align: center; font-size: 0.8rem; + padding: 0.2rem; color: var(--wds-color-ink); border-top: 0.0625rem solid var(--wds-color-ink-lighter); + + &.hor-loading { + color: var(--wds-color-ink-light); + } + } + + .hor-skeleton-pulse { + height: 1rem; + width: 3rem; + margin: 0 auto; + background: linear-gradient(90deg, var(--wds-color-ink-lighter) 25%, rgba(255, 255, 255, 0.5) 50%, var(--wds-color-ink-lighter) 75%); + background-size: 200% 100%; + animation: skeletonPulse 1.5s infinite ease-in-out; + border-radius: 4px; + } + + .hor-row.hor-loaded { + animation: fadeInSlide 0.3s ease-out; + } + + contain: layout style; + +} + +@keyframes skeletonPulse { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } +} + +@keyframes fadeInSlide { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); } } @media (max-width: 520px) { - .property-card .metrics-grid * { + .property-card .hor-metrics-grid .hor-row .hor-title, + .property-card .hor-metrics-grid .hor-row .hor-item { font-size: calc(100% - 5%); + } + + .property-card .hor-metrics-grid .hor-row .hor-item .hor-value { + font-size: calc(100% - 40%); + } +} + +.hor-filter-modal-wrapper { + display: inline-flex; + align-items: center; + margin-right: 8px; +} + +.hor-filter-button { + align-items: center; + background: #fff; + border: 0; + border-radius: 20px; + box-shadow: inset 0 0 0 2px #dddfe4; + box-sizing: border-box; + cursor: pointer; + display: flex; + justify-content: center; + height: 40px; + width: 40px; + outline: none; + padding: 0; + position: relative; + + img { + display: block; + } + + &:hover { + box-shadow: inset 0 0 0 2px var(--wds-color-orange); + } +} + +.hor-filter-modal-overlay { + position: fixed; + inset: 0; + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} + +.hor-filter-modal { + width: 560px; + max-width: calc(100vw - 32px); + max-height: calc(100vh - 64px); + display: flex; + flex-direction: column; + background: #fff; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.2); + overflow: hidden; +} + +.hor-filter-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px; + background: #1a1a2e; + color: #fff; +} + +.hor-filter-modal-title { + font-size: 16px; + font-weight: 600; + margin: 0; +} + +.hor-filter-modal-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: #fff; + cursor: pointer; + transition: background 0.2s ease; + + &:hover { + background: rgba(255, 255, 255, 0.2); + } +} + +.hor-filter-modal-content { + flex: 1; + padding: 12px; + overflow-y: auto; +} + +.hor-filter-section { + background: #f9fafb; + border-radius: 8px; + margin-bottom: 12px; + + &:last-child { + margin-bottom: 0; + } +} + +.hor-filter-section-title { + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: #6b7280; + padding: 10px 12px 6px; + margin: 0; +} + +.hor-filter-section-content { + padding: 0 12px 12px; +} + +.hor-badge-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.hor-badge-filter { + display: flex; + align-items: center; + cursor: pointer; + + input { + display: none; + } + + .hor-badge-indicator { + padding: 4px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 600; + border: 2px solid transparent; + transition: border-color 0.2s ease, opacity 0.2s ease; + opacity: 0.6; + } + + input:checked + .hor-badge-indicator { + border-color: currentColor; + opacity: 1; + } + + &:hover .hor-badge-indicator { + opacity: 1; + } +} + +.hor-range-filters { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hor-range-filter { + display: flex; + flex-direction: column; + gap: 4px; +} + +.hor-range-filter-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.hor-range-filter-label { + font-size: 13px; + font-weight: 500; + color: #1a1a2e; +} + +.hor-range-filter-values { + font-size: 12px; + font-weight: 600; + color: var(--wds-color-orange); +} + +.hor-range-filter-sliders { + position: relative; + height: 20px; + + .hor-range-slider { + position: absolute; + width: 100%; + height: 4px; + top: 50%; + transform: translateY(-50%); + appearance: none; + background: transparent; + pointer-events: none; + + &::-webkit-slider-runnable-track { + height: 4px; + background: #e5e7eb; + border-radius: 2px; + } + + &::-webkit-slider-thumb { + appearance: none; + width: 16px; + height: 16px; + background: var(--wds-color-orange); + border-radius: 50%; + cursor: pointer; + pointer-events: auto; + margin-top: -6px; + transition: transform 0.1s ease; + + &:hover { + transform: scale(1.15); + } + } + + &.hor-range-slider-min { + z-index: 1; + } + + &.hor-range-slider-max { + z-index: 2; - .row .item .value { - font-size: calc(100% - 40%); + &::-webkit-slider-runnable-track { + background: transparent; + } + } + } +} + +.hor-filter-modal-footer { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 12px 16px; + border-top: 1px solid #e5e7eb; +} + +.hor-filter-modal-links { + display: flex; + align-items: center; + gap: 8px; + font-size: 12px; + + a { + color: #6b7280; + text-decoration: none; + transition: color 0.2s ease; + + &:hover { + color: var(--wds-color-orange); } } } + +.hor-filter-modal-separator { + color: #d1d5db; +} + +.hor-donate-banner { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 12px; + margin-bottom: 12px; + border-radius: 8px; + background: #fff8f0; + border: 1px solid #ffe0b2; + color: #6b7280; + font-size: 13px; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: background 0.2s ease, border-color 0.2s ease; + + svg:first-of-type { + color: var(--wds-color-orange); + } + + &:hover { + background: #fff0e0; + border-color: var(--wds-color-orange); + color: #1a1a2e; + } +} + +.hor-donate-button { + align-items: center; + background: #fff; + border: 0; + border-radius: 20px; + box-shadow: inset 0 0 0 2px #dddfe4; + box-sizing: border-box; + cursor: pointer; + display: flex; + justify-content: center; + height: 40px; + width: 40px; + outline: none; + padding: 0; + position: relative; + margin-right: 8px !important; + color: #e74c6f; + text-decoration: none; + + &:hover { + box-shadow: inset 0 0 0 2px var(--wds-color-orange); + } +} + +.hor-filter-modal-actions { + display: flex; + gap: 8px; +} + +.hor-filter-modal-button { + padding: 8px 16px; + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: background 0.2s ease, color 0.2s ease; +} + +.hor-filter-modal-button-reset { + background: transparent; + border: 1px solid #e5e7eb; + color: #6b7280; + + &:hover { + background: #f3f4f6; + color: #1a1a2e; + } +} + +.hor-filter-modal-button-apply { + background: var(--wds-color-orange); + border: 0; + color: #fff; + + &:hover { + background: var(--wds-color-orange-dark); + } +} + +.hor-filter-modal-info { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: #9ca3af; +} + +.hor-filter-modal-info-name { + font-weight: 500; +} + +.hor-filter-modal-info-version { + font-weight: 400; + opacity: 0.8; +} + +@media (max-width: 600px) { + .filter-modal { + width: 100%; + max-width: calc(100vw - 16px); + max-height: calc(100vh - 32px); + } + + .hor-filter-modal-header { + padding: 12px; + } + + .hor-filter-modal-title { + font-size: 14px; + } + + .hor-filter-modal-content { + padding: 8px; + } + + .hor-filter-section-content { + padding: 0 8px 8px; + } + + .hor-badge-filters { + gap: 6px; + } + + .badge-filter .hor-badge-indicator { + padding: 3px 8px; + font-size: 11px; + } + + .hor-range-filters { + gap: 10px; + } + + .hor-filter-modal-footer { + flex-direction: column; + gap: 10px; + padding: 10px 12px; + } + + .hor-filter-modal-links { + order: 2; + } + + .hor-filter-modal-actions { + order: 1; + width: 100%; + justify-content: stretch; + + .hor-filter-modal-button { + flex: 1; + } + } + + .hor-filter-modal-info { + order: 3; + justify-content: center; + width: 100%; + } +} diff --git a/tsconfig.json b/tsconfig.json index 41bf0a5..2c5d224 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,8 @@ "paths": { "*": ["./src/app/*"] }, "rootDir": "src", "outDir": "dist", + "jsx": "preserve", + "jsxImportSource": "solid-js", "strict": true, "allowJs": true, "sourceMap": true, @@ -20,7 +22,7 @@ "esModuleInterop": true, "isolatedModules": true, "resolveJsonModule": true, - "moduleResolution": "node", + "moduleResolution": "bundler", "experimentalDecorators": true, "forceConsistentCasingInFileNames": true }, diff --git a/webpack.config.mjs b/webpack.config.mjs index 270fe3d..9ebb897 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -31,7 +31,7 @@ export default { basePath('src/assets/styles/app.scss') ], worker: basePath('src/entries/worker.ts'), - content: basePath('src/entries/content.ts') + content: basePath('src/entries/content.ts'), }, output: { filename: '[name].js', @@ -39,7 +39,7 @@ export default { }, resolve: { alias: aliases(), - extensions: ['.ts', '.js'] + extensions: ['.tsx', '.ts', '.js'] }, cache: { type: 'filesystem' @@ -67,7 +67,12 @@ export default { use: [ MiniCssExtractPlugin.loader, 'css-loader', - 'sass-loader' + { + loader: 'sass-loader', + options: { + api: 'modern-compiler' + } + } ] } ] diff --git a/yarn.lock b/yarn.lock index a08490a..2fb82f6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -225,6 +225,15 @@ __metadata: languageName: node linkType: hard +"@babel/helper-module-imports@npm:7.18.6": + version: 7.18.6 + resolution: "@babel/helper-module-imports@npm:7.18.6" + dependencies: + "@babel/types": "npm:^7.18.6" + checksum: 10c0/a92e28fc4b5dbb0d0afd4a313efc0cf5b26ce1adc0c01fc22724c997789ac7d7f4f30bc9143d94a6ba8b0a035933cf63a727a365ce1c57dbca0935f48de96244 + languageName: node + linkType: hard + "@babel/helper-module-imports@npm:^7.27.1": version: 7.27.1 resolution: "@babel/helper-module-imports@npm:7.27.1" @@ -504,6 +513,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-jsx@npm:^7.18.6": + version: 7.28.6 + resolution: "@babel/plugin-syntax-jsx@npm:7.28.6" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.28.6" + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 10c0/b98fc3cd75e4ca3d5ca1162f610c286e14ede1486e0d297c13a5eb0ac85680ac9656d17d348bddd9160a54d797a08cea5eaac02b9330ddebb7b26732b7b99fb5 + languageName: node + linkType: hard + "@babel/plugin-syntax-unicode-sets-regex@npm:^7.18.6": version: 7.18.6 resolution: "@babel/plugin-syntax-unicode-sets-regex@npm:7.18.6" @@ -1274,6 +1294,16 @@ __metadata: languageName: node linkType: hard +"@babel/types@npm:^7.18.6, @babel/types@npm:^7.20.7": + version: 7.29.0 + resolution: "@babel/types@npm:7.29.0" + dependencies: + "@babel/helper-string-parser": "npm:^7.27.1" + "@babel/helper-validator-identifier": "npm:^7.28.5" + checksum: 10c0/23cc3466e83bcbfab8b9bd0edaafdb5d4efdb88b82b3be6728bbade5ba2f0996f84f63b1c5f7a8c0d67efded28300898a5f930b171bb40b311bca2029c4e9b4f + languageName: node + linkType: hard + "@babel/types@npm:^7.27.1, @babel/types@npm:^7.27.3, @babel/types@npm:^7.27.7, @babel/types@npm:^7.4.4": version: 7.27.7 resolution: "@babel/types@npm:7.27.7" @@ -2904,6 +2934,21 @@ __metadata: languageName: node linkType: hard +"babel-plugin-jsx-dom-expressions@npm:^0.40.3": + version: 0.40.3 + resolution: "babel-plugin-jsx-dom-expressions@npm:0.40.3" + dependencies: + "@babel/helper-module-imports": "npm:7.18.6" + "@babel/plugin-syntax-jsx": "npm:^7.18.6" + "@babel/types": "npm:^7.20.7" + html-entities: "npm:2.3.3" + parse5: "npm:^7.1.2" + peerDependencies: + "@babel/core": ^7.20.12 + checksum: 10c0/a3ad3cd4571e4927736dd722fcabc6c2bfeede28aac8196a8a5a754e22c2ec597690736b2f4413c46b33d8dd5a06db4b55d2a741868ae5937565cb71c1dc8d04 + languageName: node + linkType: hard + "babel-plugin-polyfill-corejs2@npm:^0.4.14": version: 0.4.15 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.15" @@ -2940,6 +2985,21 @@ __metadata: languageName: node linkType: hard +"babel-preset-solid@npm:^1.9.0": + version: 1.9.10 + resolution: "babel-preset-solid@npm:1.9.10" + dependencies: + babel-plugin-jsx-dom-expressions: "npm:^0.40.3" + peerDependencies: + "@babel/core": ^7.0.0 + solid-js: ^1.9.10 + peerDependenciesMeta: + solid-js: + optional: true + checksum: 10c0/92dc369533919052d40f1b9032f0c38a83378efcdfa2aa106276572239a920a4b14af0d70115a6e144940a47513a3a9eeff074d824f5d944e371abf444eef30b + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -3564,6 +3624,13 @@ __metadata: languageName: node linkType: hard +"csstype@npm:^3.1.0": + version: 3.2.3 + resolution: "csstype@npm:3.2.3" + checksum: 10c0/cd29c51e70fa822f1cecd8641a1445bed7063697469d35633b516e60fe8c1bde04b08f6c5b6022136bb669b64c63d4173af54864510fbb4ee23281801841a3ce + languageName: node + linkType: hard + "dargs@npm:^8.0.0": version: 8.1.0 resolution: "dargs@npm:8.1.0" @@ -3830,6 +3897,13 @@ __metadata: languageName: node linkType: hard +"entities@npm:^6.0.0": + version: 6.0.1 + resolution: "entities@npm:6.0.1" + checksum: 10c0/ed836ddac5acb34341094eb495185d527bd70e8632b6c0d59548cbfa23defdbae70b96f9a405c82904efa421230b5b3fd2283752447d737beffd3f3e6ee74414 + languageName: node + linkType: hard + "env-paths@npm:^2.2.0, env-paths@npm:^2.2.1": version: 2.2.1 resolution: "env-paths@npm:2.2.1" @@ -4993,6 +5067,7 @@ __metadata: "@typescript-eslint/parser": "npm:^8.54.0" archiver: "npm:^7.0.1" babel-loader: "npm:^10.0.0" + babel-preset-solid: "npm:^1.9.0" chalk: "npm:^5.6.2" clean-webpack-plugin: "npm:^4.0.0" copy-webpack-plugin: "npm:^13.0.1" @@ -5014,6 +5089,7 @@ __metadata: sass: "npm:^1.97.3" sass-loader: "npm:^16.0.6" serialize-anything: "npm:^1.2.3" + solid-js: "npm:^1.9.0" stylelint: "npm:^17.1.0" stylelint-config-sass-guidelines: "npm:^12.1.0" stylelint-scss: "npm:^7.0.0" @@ -5028,6 +5104,13 @@ __metadata: languageName: unknown linkType: soft +"html-entities@npm:2.3.3": + version: 2.3.3 + resolution: "html-entities@npm:2.3.3" + checksum: 10c0/a76cbdbb276d9499dc7ef800d23f3964254e659f04db51c8d1ff6abfe21992c69b7217ecfd6e3c16ff0aa027ba4261d77f0dba71f55639c16a325bbdf69c535d + languageName: node + linkType: hard + "html-tags@npm:^5.1.0": version: 5.1.0 resolution: "html-tags@npm:5.1.0" @@ -6502,6 +6585,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.1.2": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 10c0/7fd2e4e247e85241d6f2a464d0085eed599a26d7b0a5233790c49f53473232eb85350e8133344d9b3fd58b89339e7ad7270fe1f89d28abe50674ec97b87f80b5 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -7387,6 +7479,22 @@ __metadata: languageName: node linkType: hard +"seroval-plugins@npm:~1.5.0": + version: 1.5.0 + resolution: "seroval-plugins@npm:1.5.0" + peerDependencies: + seroval: ^1.0 + checksum: 10c0/a70636d35e0644e37efad37963e6d41ae9e4a02fbf1b57c89dbe4d62122908039e8a0fda1720b8a56aea93741735b2028ada6d3d50c1d40bbb67661f0de92042 + languageName: node + linkType: hard + +"seroval@npm:~1.5.0": + version: 1.5.0 + resolution: "seroval@npm:1.5.0" + checksum: 10c0/aff16b14a7145388555cefd4ebd41759024ee1c2c064080fd8d4fabea4b7c89d103155cd98f5109523b8878e577da73cc6cd8abf98965f2d1f0ba19dc38317ab + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -7550,6 +7658,17 @@ __metadata: languageName: node linkType: hard +"solid-js@npm:^1.9.0": + version: 1.9.11 + resolution: "solid-js@npm:1.9.11" + dependencies: + csstype: "npm:^3.1.0" + seroval: "npm:~1.5.0" + seroval-plugins: "npm:~1.5.0" + checksum: 10c0/78b55d47b11a9f65410f78d3bd604b96540557b396681c08df02ad5cad800b2ea9ddbfceab832055b5fbddd3072d925cefda25616d8f380e70292a264ceb8854 + languageName: node + linkType: hard + "source-map-js@npm:>=0.6.2 <2.0.0, source-map-js@npm:^1.0.1, source-map-js@npm:^1.2.1": version: 1.2.1 resolution: "source-map-js@npm:1.2.1"