@studyportals/fawkes 8.2.2 → 8.2.4-0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +176 -176
  2. package/dist/src/common/IPresenter.d.ts +1 -1
  3. package/dist/src/enums/AttendanceFilterOptionValue.d.ts +5 -0
  4. package/dist/src/enums/AttendanceFilterOptionValue.js +6 -0
  5. package/dist/src/enums/DegreeTypeFilterOptionValue.d.ts +29 -0
  6. package/dist/src/enums/DegreeTypeFilterOptionValue.js +30 -0
  7. package/dist/src/enums/DurationFilterOptionValue.d.ts +16 -0
  8. package/dist/src/enums/DurationFilterOptionValue.js +17 -0
  9. package/dist/src/enums/EducationalFormFilterOptionValue.d.ts +8 -0
  10. package/dist/src/enums/EducationalFormFilterOptionValue.js +9 -0
  11. package/dist/src/enums/FilterCombinations.d.ts +1 -0
  12. package/dist/src/enums/FilterCombinations.js +1 -0
  13. package/dist/src/enums/FilterKey.d.ts +21 -0
  14. package/dist/src/enums/FilterKey.js +22 -0
  15. package/dist/src/enums/FormatFilterOptionValue.d.ts +4 -0
  16. package/dist/src/enums/FormatFilterOptionValue.js +5 -0
  17. package/dist/src/enums/SpecialProgrammesFilterOptionValue.d.ts +5 -0
  18. package/dist/src/enums/SpecialProgrammesFilterOptionValue.js +6 -0
  19. package/dist/src/enums/TuitionFeeFilterOptionValue.d.ts +3 -0
  20. package/dist/src/enums/TuitionFeeFilterOptionValue.js +4 -0
  21. package/dist/src/organisations/SearchIndexabilityManager.js +2 -1
  22. package/dist/src/organisations/policies/index.d.ts +1 -0
  23. package/dist/src/organisations/policies/index.js +1 -0
  24. package/dist/src/organisations/policies/our-picks/City.d.ts +12 -0
  25. package/dist/src/organisations/policies/our-picks/City.js +35 -0
  26. package/dist/src/organisations/rules/OnlyFullLocationFiltersSelectedRule.d.ts +10 -0
  27. package/dist/src/organisations/rules/OnlyFullLocationFiltersSelectedRule.js +32 -0
  28. package/dist/src/presenters/AreaPresenter.d.ts +1 -0
  29. package/dist/src/presenters/AreaPresenter.js +3 -0
  30. package/dist/src/presenters/CityPresenter.d.ts +14 -0
  31. package/dist/src/presenters/CityPresenter.js +60 -0
  32. package/dist/src/presenters/fragments/ICityFragment.d.ts +5 -0
  33. package/dist/src/programmes/policies/CountryAttendanceDegree.js +1 -1
  34. package/dist/src/programmes/policies/CountryDurationDegree.js +1 -1
  35. package/dist/src/programmes/policies/CountryEducationalForm.js +1 -1
  36. package/dist/src/programmes/policies/DisciplineArea.d.ts +1 -1
  37. package/dist/src/programmes/policies/DisciplineArea.js +1 -1
  38. package/dist/src/programmes/policies/DisciplineCountryEducationalForm.js +1 -1
  39. package/dist/src/programmes/policies/DisciplineEducationalForm.js +1 -1
  40. package/dist/src/programmes/rules/ErasmusOrJointSpecialProgrammesRule.js +2 -2
  41. package/dist/src/sitemap-generator/ISearchApiClient.d.ts +9 -0
  42. package/dist/src/sitemap-generator/OrganisationsSitemapUrlGeneratorManager.js +2 -1
  43. package/dist/src/structured-data/ScholarshipStructuredDataFactory.d.ts +17 -0
  44. package/dist/src/structured-data/ScholarshipStructuredDataFactory.js +63 -0
  45. package/dist/src/structured-data/SearchStructuredDataFactory.d.ts +16 -3
  46. package/dist/src/structured-data/SearchStructuredDataFactory.js +71 -7
  47. package/dist/src/structured-data/dto/EntityDTO.d.ts +2 -0
  48. package/dist/src/structured-data/dto/PaywallDTO.d.ts +14 -0
  49. package/dist/src/structured-data/dto/index.d.ts +1 -0
  50. package/dist/src/structured-data/dto/index.js +1 -0
  51. package/dist/src/structured-data/index.d.ts +1 -0
  52. package/dist/src/structured-data/index.js +1 -0
  53. package/package.json +105 -105
  54. package/dist/src/common/IPolicyMetaData.d.ts +0 -6
  55. package/dist/src/organisations/OrganisationsRuleBasedIndexabilityPolicy.d.ts +0 -11
  56. package/dist/src/organisations/OrganisationsRuleBasedIndexabilityPolicy.js +0 -19
  57. package/dist/src/organisations/rules/OnlineAttendanceRule.d.ts +0 -6
  58. package/dist/src/organisations/rules/OnlineAttendanceRule.js +0 -14
  59. package/dist/src/programmes/IProgrammeSearchApplicationState.d.ts +0 -4
  60. package/dist/src/programmes/IProgrammeSearchDependencies.d.ts +0 -7
  61. package/dist/src/programmes/IProgrammeSearchDependencies.js +0 -1
  62. package/dist/src/programmes/IProgrammesSeoDependencies.d.ts +0 -2
  63. package/dist/src/programmes/IProgrammesSeoDependencies.js +0 -1
  64. package/dist/src/programmes/policies/DisciplineCountryDegree.d.ts +0 -16
  65. package/dist/src/programmes/policies/DisciplineCountryDegree.js +0 -53
  66. package/dist/src/programmes/rules/DegreeAttendanceSpecificRule.d.ts +0 -10
  67. package/dist/src/programmes/rules/DegreeAttendanceSpecificRule.js +0 -42
  68. package/dist/src/programmes/rules/DegreeCountrySpecificRule.d.ts +0 -10
  69. package/dist/src/programmes/rules/DegreeCountrySpecificRule.js +0 -43
  70. package/dist/src/programmes/rules/DegreeCountryTuitionFeeRule.d.ts +0 -11
  71. package/dist/src/programmes/rules/DegreeCountryTuitionFeeRule.js +0 -40
  72. package/dist/src/programmes/rules/MBACountryAttendanceRule.d.ts +0 -11
  73. package/dist/src/programmes/rules/MBACountryAttendanceRule.js +0 -40
  74. package/dist/src/programmes/rules/MasterOfArtsDisciplineRule.d.ts +0 -10
  75. package/dist/src/programmes/rules/MasterOfArtsDisciplineRule.js +0 -41
  76. package/dist/src/programmes/rules/MasterOfEducationCountryDisciplineRule.d.ts +0 -11
  77. package/dist/src/programmes/rules/MasterOfEducationCountryDisciplineRule.js +0 -69
  78. package/dist/src/programmes/rules/MasterOfLawsCountryAttendanceRule.d.ts +0 -12
  79. package/dist/src/programmes/rules/MasterOfLawsCountryAttendanceRule.js +0 -43
  80. package/dist/src/programmes/rules/MasterOfLawsCountryTuitionFeeRule.d.ts +0 -12
  81. package/dist/src/programmes/rules/MasterOfLawsCountryTuitionFeeRule.js +0 -46
  82. package/dist/src/programmes/rules/MasterOfPhilosophyCountryAttendanceRule.d.ts +0 -12
  83. package/dist/src/programmes/rules/MasterOfPhilosophyCountryAttendanceRule.js +0 -43
  84. package/dist/src/programmes/rules/MasterOfScienceDisciplineRule.d.ts +0 -10
  85. package/dist/src/programmes/rules/MasterOfScienceDisciplineRule.js +0 -41
  86. package/dist/src/sitemap-generator/IPageNumberProvider.d.ts +0 -3
  87. package/dist/src/sitemap-generator/IPageNumberProvider.js +0 -1
  88. /package/dist/src/{common/IPolicyMetaData.js → presenters/fragments/ICityFragment.js} +0 -0
  89. /package/dist/src/{programmes/IProgrammeSearchApplicationState.js → structured-data/dto/PaywallDTO.js} +0 -0
@@ -0,0 +1,35 @@
1
+ import { FilterCombinations } from '../../../enums/FilterCombinations';
2
+ import { FilterKey } from '@studyportals/search-filters/server-side';
3
+ import { SortingOptions } from '../../../enums/SortingOptions';
4
+ import { OrganisationsSeoIndexabilityPolicy } from '../../../organisations/policies/OrganisationsSeoIndexabilityPolicy';
5
+ import { CityPresenter } from '../../../presenters/CityPresenter';
6
+ import { OnlyFullLocationFiltersSelectedRule } from '../../../organisations/rules/OnlyFullLocationFiltersSelectedRule';
7
+ export class City extends OrganisationsSeoIndexabilityPolicy {
8
+ name = 'City Policy';
9
+ description = 'Controls indexing of pages filtered by geographic cities, ensuring only SEO-valuable regional content with sufficient search volume is indexed.';
10
+ sortingOption = SortingOptions.OUR_PICKS;
11
+ baseRules = [
12
+ new OnlyFullLocationFiltersSelectedRule()
13
+ ];
14
+ async generateUrls() {
15
+ const cityFragments = await CityPresenter
16
+ .getInstance(this.dependencies.searchApiClient)
17
+ .getFragments();
18
+ const paths = [];
19
+ for (const city of cityFragments) {
20
+ const filterKeyValues = new Map([
21
+ [FilterKey.CITY, [city.id]],
22
+ [FilterKey.AREA, [city.areaId]],
23
+ [FilterKey.COUNTRY, [city.countryId]]
24
+ ]);
25
+ const result = await this.checkRulesForSitemap(filterKeyValues);
26
+ if (result) {
27
+ paths.push(this.getPathWithSortingOption(city.path));
28
+ }
29
+ }
30
+ return paths;
31
+ }
32
+ get filterCombination() {
33
+ return FilterCombinations.CITY;
34
+ }
35
+ }
@@ -0,0 +1,10 @@
1
+ import { IRule } from '../../common/IRule';
2
+ import { ISearchDependencies } from '../../common/ISearchDependencies';
3
+ export declare class OnlyFullLocationFiltersSelectedRule implements IRule {
4
+ private readonly COUNTRIES_WITH_AREAS;
5
+ getName(): string;
6
+ forSearch(dependencies: ISearchDependencies): Promise<boolean>;
7
+ forSitemapGenerator(): Promise<boolean>;
8
+ getDescription(): string;
9
+ private isEligible;
10
+ }
@@ -0,0 +1,32 @@
1
+ import { FilterKey } from '@studyportals/search-filters/server-side';
2
+ export class OnlyFullLocationFiltersSelectedRule {
3
+ COUNTRIES_WITH_AREAS = ["30", "82", "202"]; //UK, US, AU
4
+ getName() {
5
+ return `OnlyFullLocationFiltersSelectedRule`;
6
+ }
7
+ async forSearch(dependencies) {
8
+ return await this.isEligible(dependencies);
9
+ }
10
+ forSitemapGenerator() {
11
+ //TODO
12
+ return Promise.resolve(false);
13
+ }
14
+ getDescription() {
15
+ return `Only city, country and area (if US, UK or AU) filters are selected`;
16
+ }
17
+ async isEligible(dependencies) {
18
+ const seoInfoBase = dependencies.seoInfoBase;
19
+ const filterState = dependencies.filterState;
20
+ const singleCitySelected = await seoInfoBase.singleSelectionFor(FilterKey.CITY, filterState);
21
+ const singleAreaSelected = await seoInfoBase.singleSelectionFor(FilterKey.AREA, filterState);
22
+ const singleCountrySelected = await seoInfoBase.singleSelectionFor(FilterKey.COUNTRY, filterState);
23
+ const needArea = filterState.getSelectedValuesFor(FilterKey.COUNTRY)
24
+ .some((countryId) => this.COUNTRIES_WITH_AREAS.includes(countryId));
25
+ if (needArea) {
26
+ const cityAreaAndCountryOnly = await seoInfoBase.selectionOnlyFor([FilterKey.CITY, FilterKey.COUNTRY, FilterKey.AREA], filterState);
27
+ return singleCitySelected && singleCountrySelected && singleAreaSelected && cityAreaAndCountryOnly;
28
+ }
29
+ const cityAndCountryOnly = await seoInfoBase.selectionOnlyFor([FilterKey.CITY, FilterKey.COUNTRY], filterState);
30
+ return singleCitySelected && singleCountrySelected && cityAndCountryOnly;
31
+ }
32
+ }
@@ -9,4 +9,5 @@ export declare class AreaPresenter implements IPresenter {
9
9
  static getInstance(): AreaPresenter;
10
10
  private generatePaths;
11
11
  getFragments(): IAreaFragment[];
12
+ isPartOfGivenCountries(areaId: string, countries: string[]): boolean;
12
13
  }
@@ -32,4 +32,7 @@ export class AreaPresenter {
32
32
  getFragments() {
33
33
  return this.fragments;
34
34
  }
35
+ isPartOfGivenCountries(areaId, countries) {
36
+ return areasAll.some(a => a.id === areaId && countries.includes(a.countryId));
37
+ }
35
38
  }
@@ -0,0 +1,14 @@
1
+ import { FilterKey } from '@studyportals/search-filters/server-side';
2
+ import { ICityFragment } from './fragments/ICityFragment';
3
+ import { IPresenter, ISearchApiClient } from '../../sitemap-generator-seo';
4
+ export declare class CityPresenter implements IPresenter {
5
+ readonly filterKey: FilterKey;
6
+ private searchApiClient;
7
+ private static instance;
8
+ private resultSize;
9
+ private constructor();
10
+ private fragmentsPromise;
11
+ static getInstance(searchApiClient: ISearchApiClient): CityPresenter;
12
+ getFragments(): Promise<ICityFragment[]>;
13
+ private fetchCities;
14
+ }
@@ -0,0 +1,60 @@
1
+ import { FilterKey } from '@studyportals/search-filters/server-side';
2
+ import { countriesExtendedAll } from '@studyportals/static-domain-data/countries/countries-extended';
3
+ import { countryGetIsoCodePath } from '@studyportals/static-domain-data/countries/country-get-iso-path';
4
+ import { areaGetPath } from '@studyportals/static-domain-data/areas/area-get-path';
5
+ export class CityPresenter {
6
+ filterKey = FilterKey.CITY;
7
+ searchApiClient;
8
+ static instance;
9
+ resultSize = 600;
10
+ constructor(searchApiClient) {
11
+ this.searchApiClient = searchApiClient;
12
+ }
13
+ fragmentsPromise = null;
14
+ static getInstance(searchApiClient) {
15
+ if (!CityPresenter.instance) {
16
+ CityPresenter.instance = new CityPresenter(searchApiClient);
17
+ }
18
+ return CityPresenter.instance;
19
+ }
20
+ async getFragments() {
21
+ if (!this.fragmentsPromise) {
22
+ this.fragmentsPromise = this.fetchCities();
23
+ }
24
+ return this.fragmentsPromise;
25
+ }
26
+ async fetchCities() {
27
+ try {
28
+ const data = await this.searchApiClient.getCities();
29
+ const cityFragments = data.map(city => {
30
+ const country = countriesExtendedAll.find(item => item.iso?.toLowerCase() === city.countryIsoCode?.toLowerCase());
31
+ let cityPath = ``;
32
+ const countryPath = countryGetIsoCodePath(country?.iso || '');
33
+ if (city.areaId === null || city.areaId === undefined) {
34
+ cityPath += countryPath;
35
+ }
36
+ if (!city.areaId || city.areaId !== null) {
37
+ const area = areaGetPath(city.areaId?.toString() || '');
38
+ if (area && area !== '') {
39
+ cityPath += `/${area}`;
40
+ }
41
+ else {
42
+ cityPath += countryPath;
43
+ }
44
+ }
45
+ cityPath += `/${city.virtualPath}`;
46
+ return {
47
+ id: city.id.toString(),
48
+ path: cityPath,
49
+ areaId: city.areaId?.toString(),
50
+ countryId: country ? country.id.toString() : '',
51
+ };
52
+ });
53
+ return cityFragments;
54
+ }
55
+ catch (error) {
56
+ console.error('Error fetching city data:', error);
57
+ throw error;
58
+ }
59
+ }
60
+ }
@@ -0,0 +1,5 @@
1
+ import { IFragment } from './IFragment';
2
+ export interface ICityFragment extends IFragment {
3
+ areaId: string;
4
+ countryId: string;
5
+ }
@@ -10,7 +10,7 @@ import { AttendancePresenter } from '../../presenters/AttendancePresenter';
10
10
  import { DegreePresenter } from '../../presenters/DegreePresenter';
11
11
  export class CountryAttendanceDegree extends ProgrammesBaseIndexabilityPolicy {
12
12
  name = 'Country Attendance Degree Policy';
13
- description = `Determines indexing rules for pages filtered by country,
13
+ description = `Determines indexing rules for pages filtered by country,
14
14
  study format (online, on-campus, etc.), and degree type.`;
15
15
  filterKeys = [FilterKey.COUNTRY, FilterKey.DELIVERY_METHOD, FilterKey.DEGREE_TYPE];
16
16
  rules = [
@@ -10,7 +10,7 @@ import { DegreePresenter } from '../../presenters/DegreePresenter';
10
10
  import { DurationPresenter } from '../../presenters/DurationPresenter';
11
11
  export class CountryDurationDegree extends ProgrammesBaseIndexabilityPolicy {
12
12
  name = 'Country Duration Degree Policy';
13
- description = `Determines indexing rules for pages filtered by country,
13
+ description = `Determines indexing rules for pages filtered by country,
14
14
  duration (1 year, 2 years, etc.), and degree type.`;
15
15
  filterKeys = [FilterKey.COUNTRY, FilterKey.DURATION, FilterKey.DEGREE_TYPE];
16
16
  rules = [
@@ -8,7 +8,7 @@ import { CountryPresenter } from '../../presenters/CountryPresenter';
8
8
  import { EducationalFormPresenter } from '../../presenters/EducationalFormPresenter';
9
9
  export class CountryEducationalForm extends ProgrammesBaseIndexabilityPolicy {
10
10
  name = 'Country Educational Form Policy';
11
- description = `Determines indexing rules for pages filtered by both country,
11
+ description = `Determines indexing rules for pages filtered by both country,
12
12
  and educational form (academic, semester, summer school, etc.).`;
13
13
  filterKeys = [FilterKey.COUNTRY, FilterKey.EDUCATIONAL_FORM];
14
14
  rules = [
@@ -1,4 +1,4 @@
1
- import { FilterKey } from '@studyportals/search-filters';
1
+ import { FilterKey } from "../../enums/FilterKey";
2
2
  import { OnlyFiltersSelectedRule } from '../../common/rules/OnlyFiltersSelectedRule';
3
3
  import { SingleValueSelectedForFilterRule } from '../../common/rules/SingleValueSelectedForFilterRule';
4
4
  import { FilterCombinations } from '../../enums/FilterCombinations';
@@ -1,4 +1,4 @@
1
- import { FilterKey } from '@studyportals/search-filters';
1
+ import { FilterKey } from "../../enums/FilterKey";
2
2
  import { OnlyFiltersSelectedRule } from '../../common/rules/OnlyFiltersSelectedRule';
3
3
  import { SingleValueSelectedForFilterRule } from '../../common/rules/SingleValueSelectedForFilterRule';
4
4
  import { FilterCombinations } from '../../enums/FilterCombinations';
@@ -9,7 +9,7 @@ import { CountryPresenter } from '../../presenters/CountryPresenter';
9
9
  import { EducationalFormPresenter } from '../../presenters/EducationalFormPresenter';
10
10
  export class DisciplineCountryEducationalForm extends ProgrammesBaseIndexabilityPolicy {
11
11
  name = 'Discipline Country Educational Form Policy';
12
- description = `Determines indexing rules for pages filtered by discipline,
12
+ description = `Determines indexing rules for pages filtered by discipline,
13
13
  country, and educational form (academic, semester, summer school, etc.).`;
14
14
  filterKeys = [FilterKey.DISCIPLINES, FilterKey.COUNTRY, FilterKey.EDUCATIONAL_FORM];
15
15
  rules = [
@@ -8,7 +8,7 @@ import { DisciplinePresenter } from '../../presenters/DisciplinePresenter';
8
8
  import { EducationalFormPresenter } from '../../presenters/EducationalFormPresenter';
9
9
  export class DisciplineEducationalForm extends ProgrammesBaseIndexabilityPolicy {
10
10
  name = 'Discipline Educational Form Policy';
11
- description = `Determines indexing rules for pages filtered by both discipline,
11
+ description = `Determines indexing rules for pages filtered by both discipline,
12
12
  and educational form (academic, semester, summer school, etc.).`;
13
13
  filterKeys = [FilterKey.DISCIPLINES, FilterKey.EDUCATIONAL_FORM];
14
14
  rules = [
@@ -1,6 +1,6 @@
1
- import { FilterKey } from '@studyportals/search-filters';
2
- import { SpecialProgrammesFilterOptionValue } from '@studyportals/search-filters';
1
+ import { FilterKey } from "../../enums/FilterKey";
3
2
  import { BaseProgrammeRule } from '../BaseProgrammeRule';
3
+ import { SpecialProgrammesFilterOptionValue } from "../../enums/SpecialProgrammesFilterOptionValue";
4
4
  export class ErasmusOrJointSpecialProgrammesRule extends BaseProgrammeRule {
5
5
  forSearch(dependencies) {
6
6
  const { seoInfoBase, filterState } = dependencies;
@@ -1,5 +1,14 @@
1
1
  import { FilterKeyValuesMap } from '../common/FilterKeyValuesMap';
2
2
  export interface ISearchApiClient {
3
3
  getOrganisationIds(): Promise<number[]>;
4
+ getCityIds?(): Promise<number[]>;
5
+ getCities(): Promise<{
6
+ id: number;
7
+ name: string;
8
+ virtualPath: string;
9
+ areaId: number | null;
10
+ countryIsoCode: string;
11
+ }[]>;
12
+ getProgrammeCount(filterKeyValues: FilterKeyValuesMap): Promise<number>;
4
13
  getCount(filterKeyValues: FilterKeyValuesMap): Promise<number>;
5
14
  }
@@ -1,6 +1,6 @@
1
1
  import { DependencyTypes } from '../enums/DependencyTypes';
2
2
  import { BaseSitemapUrlGeneratorManager } from './BaseSitemapUrlGeneratorManager';
3
- import { Area, AreaAttendance, Attendance, Continent, Country, CountryAttendance, RankedArea, RankedAreaDiscipline, RankedAttendance, RankedAttendanceDiscipline, RankedContinent, RankedContinentAttendance, RankedCountry, RankedCountryAttendance, RankedCountryDiscipline, RankedDiscipline, RankedUnfiltered, Unfiltered } from '../organisations/policies';
3
+ import { City, Area, AreaAttendance, Attendance, Continent, Country, CountryAttendance, RankedArea, RankedAreaDiscipline, RankedAttendance, RankedAttendanceDiscipline, RankedContinent, RankedContinentAttendance, RankedCountry, RankedCountryAttendance, RankedCountryDiscipline, RankedDiscipline, RankedUnfiltered, Unfiltered } from '../organisations/policies';
4
4
  import { AreaPresenter } from '../presenters/AreaPresenter';
5
5
  import { AttendancePresenter } from '../presenters/AttendancePresenter';
6
6
  import { ContinentPresenter } from '../presenters/ContinentPresenter';
@@ -22,6 +22,7 @@ export class OrganisationsSitemapUrlGeneratorManager extends BaseSitemapUrlGener
22
22
  rankingApiClient
23
23
  };
24
24
  this.policies = [
25
+ new City(dependencies),
25
26
  new Area(dependencies),
26
27
  new AreaAttendance(dependencies),
27
28
  new Attendance(dependencies),
@@ -0,0 +1,17 @@
1
+ import { MonetaryGrant } from "schema-dts";
2
+ import { StructuredDataCurrencyConversionService } from "./interface/StructuredDataCurrencyConversionService";
3
+ import { SearchStructuredDataFactory } from "./SearchStructuredDataFactory";
4
+ import { IScholarshipCard } from "@studyportals/domain-client";
5
+ import { EntityDTO, OfferDTO, PaywallDTO } from "./dto";
6
+ type MonetaryGrantWithPaywall = MonetaryGrant & {
7
+ isAccessibleForFree?: boolean;
8
+ };
9
+ export declare class ScholarshipStructuredDataFactory extends SearchStructuredDataFactory<IScholarshipCard> {
10
+ private currencyConversionService;
11
+ constructor(currencyConversionService: StructuredDataCurrencyConversionService);
12
+ protected buildStructuredDataForCard(entity: EntityDTO<IScholarshipCard>): Promise<MonetaryGrantWithPaywall | undefined>;
13
+ protected getOfferData(card: IScholarshipCard): Promise<OfferDTO | undefined>;
14
+ protected getPaywallData(): PaywallDTO | undefined;
15
+ private buildAmount;
16
+ }
17
+ export {};
@@ -0,0 +1,63 @@
1
+ import { SearchStructuredDataFactory } from "./SearchStructuredDataFactory";
2
+ export class ScholarshipStructuredDataFactory extends SearchStructuredDataFactory {
3
+ currencyConversionService;
4
+ constructor(currencyConversionService) {
5
+ super();
6
+ this.currencyConversionService = currencyConversionService;
7
+ }
8
+ async buildStructuredDataForCard(entity) {
9
+ const url = `https://${window.location.host}${entity.card.getScholarshipVirtualName()}`;
10
+ const amount = await this.buildAmount(entity.card);
11
+ const structuredData = {
12
+ "@type": "MonetaryGrant",
13
+ name: entity.card.getScholarshipTitle() || undefined,
14
+ description: entity.card.getDescriptionOfApplicationBasis() || undefined,
15
+ funder: entity.card.getProviderName() || undefined,
16
+ url,
17
+ amount
18
+ };
19
+ this.addPaywallToStructuredData(structuredData, entity.paywall);
20
+ return structuredData;
21
+ }
22
+ async getOfferData(card) {
23
+ const benefits = card.getDescriptionOfBenefits();
24
+ const hasMonetaryValue = benefits && !isNaN(Number(benefits.split(' ')[0]));
25
+ if (!hasMonetaryValue)
26
+ return;
27
+ const [amount, currency] = [Number(benefits.split(' ')[0]), benefits.split(' ')[1]];
28
+ return {
29
+ price: Math.round(await this.currencyConversionService.convert(amount, currency, 'USD')),
30
+ category: 'tuition',
31
+ validFrom: undefined
32
+ };
33
+ }
34
+ getPaywallData() {
35
+ return {
36
+ isAccessibleForFree: false,
37
+ cssSelector: '.Paywalled',
38
+ accessMode: 'registration'
39
+ };
40
+ }
41
+ async buildAmount(card) {
42
+ const benefits = card.getDescriptionOfBenefits();
43
+ const hasMonetaryValue = benefits && !isNaN(Number(benefits.split(' ')[0]));
44
+ let amount;
45
+ let currency;
46
+ if (hasMonetaryValue) {
47
+ [amount, currency] = [Number(benefits.split(' ')[0]), benefits.split(' ')[1]];
48
+ }
49
+ let value;
50
+ if (hasMonetaryValue) {
51
+ value = Math.round(await this.currencyConversionService.convert(amount, currency, 'USD'));
52
+ currency = 'USD';
53
+ }
54
+ else {
55
+ value = benefits || undefined;
56
+ }
57
+ return {
58
+ "@type": "MonetaryAmount",
59
+ value,
60
+ currency
61
+ };
62
+ }
63
+ }
@@ -1,12 +1,14 @@
1
- import { SearchResultsPage, WithContext, Offer, AggregateRating, Thing, FAQPage } from 'schema-dts';
1
+ import { SearchResultsPage, WithContext, Offer, AggregateRating, Thing, FAQPage, WebPage } from 'schema-dts';
2
2
  import { ReviewRatingDTO } from './dto/ReviewRatingDTO';
3
3
  import { OfferDTO } from './dto/OfferDTO';
4
4
  import { EntityDTO } from './dto/EntityDTO';
5
5
  import { FAQItemDto } from './dto/FAQItemDto';
6
6
  import { BreadcrumbDTO } from './dto/BreadcrumbDTO';
7
+ import { PaywallDTO } from './dto/PaywallDTO';
7
8
  export declare abstract class SearchStructuredDataFactory<TCard> {
8
- buildStructuredData(title: string, description: string, cards: TCard[], faqItems?: FAQItemDto[], breadcrumbs?: BreadcrumbDTO[]): Promise<WithContext<SearchResultsPage>>;
9
- protected abstract buildStructuredDataForCard(entity: EntityDTO<TCard>): Thing | undefined;
9
+ buildStructuredData(title: string, description: string, cards: TCard[], faqItems?: FAQItemDto[], breadcrumbs?: BreadcrumbDTO[], listItemStructure?: boolean): Promise<WithContext<SearchResultsPage | WebPage>>;
10
+ private generateMainEntities;
11
+ protected abstract buildStructuredDataForCard(entity: EntityDTO<TCard>): Promise<Thing | undefined> | Thing | undefined;
10
12
  /**
11
13
  * Get the rating for a card.
12
14
  * This method should be overridden by subclasses to provide the specific rating logic.
@@ -25,10 +27,21 @@ export declare abstract class SearchStructuredDataFactory<TCard> {
25
27
  * @protected
26
28
  */
27
29
  protected getOfferData(card: TCard): Promise<OfferDTO | undefined>;
30
+ protected getPaywallData(): PaywallDTO | undefined;
28
31
  private constructOptionalPageOffers;
29
32
  protected constructOptionalFaqPage(faqItems: FAQItemDto[]): FAQPage | undefined;
33
+ private constructOptionalPageParts;
30
34
  private constructOptionalBreadcrumbs;
35
+ private constructPaywallElement;
31
36
  protected constructAggregateRating(reviewRating?: ReviewRatingDTO): AggregateRating | undefined;
32
37
  protected constructOffer(offerData?: OfferDTO): Offer | undefined;
38
+ /**
39
+ * Add paywall information to any structured data object
40
+ *
41
+ * @param structuredData The structured data object to enhance
42
+ * @param paywallData The paywall data to add
43
+ * @protected
44
+ */
45
+ protected addPaywallToStructuredData(structuredData: Record<string, any>, paywallData?: PaywallDTO): void;
33
46
  private stripHtmlWithListRetain;
34
47
  }
@@ -1,5 +1,5 @@
1
1
  export class SearchStructuredDataFactory {
2
- async buildStructuredData(title, description, cards, faqItems = [], breadcrumbs = []) {
2
+ async buildStructuredData(title, description, cards, faqItems = [], breadcrumbs = [], listItemStructure) {
3
3
  const entities = [];
4
4
  const ratings = [];
5
5
  const offersData = [];
@@ -12,21 +12,45 @@ export class SearchStructuredDataFactory {
12
12
  offersData.push(offer);
13
13
  entities.push({ card, offer, reviewRating });
14
14
  }
15
- const mainEntities = entities
16
- .map(entity => this.buildStructuredDataForCard(entity))
17
- .filter(entity => entity !== undefined);
15
+ const mainEntities = await this.generateMainEntities(entities, listItemStructure);
18
16
  const data = {
19
17
  '@context': 'https://schema.org',
20
- '@type': 'SearchResultsPage',
18
+ '@type': !listItemStructure ? 'SearchResultsPage' : 'WebPage',
21
19
  'name': title,
22
20
  description,
23
21
  'mainEntity': mainEntities
24
22
  };
23
+ const paywallData = this.getPaywallData();
25
24
  data.offers = this.constructOptionalPageOffers(offersData);
26
- data.hasPart = this.constructOptionalFaqPage(faqItems);
25
+ data.hasPart = this.constructOptionalPageParts(faqItems, paywallData);
27
26
  data.breadcrumb = this.constructOptionalBreadcrumbs(breadcrumbs);
27
+ if (paywallData) {
28
+ data.isAccessibleForFree = paywallData.isAccessibleForFree;
29
+ }
28
30
  return data;
29
31
  }
32
+ async generateMainEntities(entities, listItemStructure) {
33
+ const mainEntities = [];
34
+ for (const entity of entities) {
35
+ const structuredData = await this.buildStructuredDataForCard(entity);
36
+ if (!structuredData)
37
+ continue;
38
+ mainEntities.push(structuredData);
39
+ }
40
+ if (listItemStructure) {
41
+ const listItems = mainEntities.map((entity, index) => ({
42
+ '@type': 'ListItem',
43
+ 'position': index + 1,
44
+ 'item': entity
45
+ }));
46
+ return [{
47
+ '@type': 'ItemList',
48
+ 'numberOfItems': mainEntities.length,
49
+ 'itemListElement': listItems
50
+ }];
51
+ }
52
+ return mainEntities;
53
+ }
30
54
  /**
31
55
  * Get the rating for a card.
32
56
  * This method should be overridden by subclasses to provide the specific rating logic.
@@ -48,7 +72,10 @@ export class SearchStructuredDataFactory {
48
72
  * @protected
49
73
  */
50
74
  // eslint-disable-next-line @typescript-eslint/no-unused-vars
51
- async getOfferData(card) {
75
+ getOfferData(card) {
76
+ return Promise.resolve(undefined);
77
+ }
78
+ getPaywallData() {
52
79
  return undefined;
53
80
  }
54
81
  constructOptionalPageOffers(offersData) {
@@ -94,6 +121,21 @@ export class SearchStructuredDataFactory {
94
121
  }))
95
122
  };
96
123
  }
124
+ constructOptionalPageParts(faqItems, paywallData) {
125
+ const parts = [];
126
+ const faqPage = this.constructOptionalFaqPage(faqItems);
127
+ if (faqPage) {
128
+ parts.push(faqPage);
129
+ }
130
+ if (paywallData && paywallData.cssSelector) {
131
+ parts.push(this.constructPaywallElement(paywallData));
132
+ }
133
+ if (parts.length === 0)
134
+ return undefined;
135
+ if (parts.length === 1 && parts[0]['@type'] === 'FAQPage')
136
+ return parts[0];
137
+ return parts;
138
+ }
97
139
  constructOptionalBreadcrumbs(breadcrumbs) {
98
140
  if (breadcrumbs.length <= 0)
99
141
  return;
@@ -108,6 +150,16 @@ export class SearchStructuredDataFactory {
108
150
  }))
109
151
  };
110
152
  }
153
+ constructPaywallElement(paywallData) {
154
+ const element = {
155
+ '@type': 'WebPageElement',
156
+ 'isAccessibleForFree': paywallData.isAccessibleForFree
157
+ };
158
+ if (paywallData.cssSelector) {
159
+ element.cssSelector = paywallData.cssSelector;
160
+ }
161
+ return element;
162
+ }
111
163
  constructAggregateRating(reviewRating) {
112
164
  if (!reviewRating)
113
165
  return;
@@ -131,6 +183,18 @@ export class SearchStructuredDataFactory {
131
183
  'category': 'tuition'
132
184
  };
133
185
  }
186
+ /**
187
+ * Add paywall information to any structured data object
188
+ *
189
+ * @param structuredData The structured data object to enhance
190
+ * @param paywallData The paywall data to add
191
+ * @protected
192
+ */
193
+ addPaywallToStructuredData(structuredData, paywallData) {
194
+ if (!paywallData)
195
+ return;
196
+ structuredData.isAccessibleForFree = paywallData.isAccessibleForFree;
197
+ }
134
198
  stripHtmlWithListRetain(html) {
135
199
  const tempDiv = document.createElement('div');
136
200
  tempDiv.innerHTML = html;
@@ -1,7 +1,9 @@
1
1
  import { OfferDTO } from "./OfferDTO";
2
2
  import { ReviewRatingDTO } from "./ReviewRatingDTO";
3
+ import { PaywallDTO } from "./PaywallDTO";
3
4
  export interface EntityDTO<TCard> {
4
5
  card: TCard;
5
6
  offer?: OfferDTO;
6
7
  reviewRating?: ReviewRatingDTO;
8
+ paywall?: PaywallDTO;
7
9
  }
@@ -0,0 +1,14 @@
1
+ export interface PaywallDTO {
2
+ /**
3
+ * Whether the content is behind a paywall
4
+ */
5
+ isAccessibleForFree: boolean;
6
+ /**
7
+ * CSS selector for the paywall element (optional)
8
+ */
9
+ cssSelector?: string;
10
+ /**
11
+ * Type of access restriction
12
+ */
13
+ accessMode?: 'subscription' | 'payment' | 'registration' | 'free';
14
+ }
@@ -2,5 +2,6 @@ export * from './BreadcrumbDTO';
2
2
  export * from './EntityDTO';
3
3
  export * from './FAQItemDto';
4
4
  export * from './OfferDTO';
5
+ export * from './PaywallDTO';
5
6
  export * from './ReviewRatingDTO';
6
7
  export * from './OrganisationDTO';
@@ -2,5 +2,6 @@ export * from './BreadcrumbDTO';
2
2
  export * from './EntityDTO';
3
3
  export * from './FAQItemDto';
4
4
  export * from './OfferDTO';
5
+ export * from './PaywallDTO';
5
6
  export * from './ReviewRatingDTO';
6
7
  export * from './OrganisationDTO';
@@ -1,3 +1,4 @@
1
1
  export * from './ProgrammeStructuredDataFactory';
2
2
  export * from './OrganisationStructuredDataFactory';
3
+ export * from './ScholarshipStructuredDataFactory';
3
4
  export * from './dto';
@@ -1,3 +1,4 @@
1
1
  export * from './ProgrammeStructuredDataFactory';
2
2
  export * from './OrganisationStructuredDataFactory';
3
+ export * from './ScholarshipStructuredDataFactory';
3
4
  export * from './dto';