@studyportals/fawkes 7.3.2-0 → 7.3.3-1

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.
@@ -0,0 +1,18 @@
1
+ import { SearchStructuredDataFactory } from "./SearchStructuredDataFactory";
2
+ import { IProgrammeCard } from "@studyportals/domain-client";
3
+ import { ReviewRatingDTO } from "./dto/ReviewRatingDTO";
4
+ import { OfferDTO } from "./dto/OfferDTO";
5
+ import { Course } from "schema-dts";
6
+ import { EntityDTO } from "./dto/EntityDTO";
7
+ import { StructuredDataCurrencyConversionService } from "../structured-data/interface/StructuredDataCurrencyConversionService";
8
+ export declare class ProgrammeStructuredDataFactory extends SearchStructuredDataFactory<IProgrammeCard> {
9
+ private currencyConversionService;
10
+ constructor(currencyConversionService: StructuredDataCurrencyConversionService);
11
+ private regionalDataPriority;
12
+ protected getRating(card: IProgrammeCard): ReviewRatingDTO | undefined;
13
+ protected getOfferData(card: IProgrammeCard): Promise<OfferDTO | undefined>;
14
+ protected buildStructuredDataForCard(entity: EntityDTO<IProgrammeCard>): Course;
15
+ private getCourseMode;
16
+ private getCourseWorkload;
17
+ private getCourseStartDate;
18
+ }
@@ -0,0 +1,86 @@
1
+ import { SearchStructuredDataFactory } from "./SearchStructuredDataFactory";
2
+ export class ProgrammeStructuredDataFactory extends SearchStructuredDataFactory {
3
+ currencyConversionService;
4
+ constructor(currencyConversionService) {
5
+ super();
6
+ this.currencyConversionService = currencyConversionService;
7
+ }
8
+ regionalDataPriority = ['international', 'general', 'eea', 'national'];
9
+ getRating(card) {
10
+ if (typeof card.getReviewRating === 'undefined')
11
+ return;
12
+ const rating = card.getReviewRating();
13
+ if (!rating)
14
+ return;
15
+ return {
16
+ rating: rating?.getAverageStarRating(),
17
+ quantity: rating?.getQuantity(),
18
+ };
19
+ }
20
+ async getOfferData(card) {
21
+ const tuitionFees = card.getTuitionFees();
22
+ let tuition;
23
+ let timing;
24
+ for (const target of this.regionalDataPriority) {
25
+ if (!tuition) {
26
+ tuition = tuitionFees.find(t => t.getTarget() === target);
27
+ }
28
+ if (!timing) {
29
+ timing = card.canShowTimings() ? card.getTimings().find(t => t.getType() === target && t.canShowStartDate()) : undefined;
30
+ }
31
+ }
32
+ if (!tuition) {
33
+ return;
34
+ }
35
+ return {
36
+ price: await this.currencyConversionService.convert(tuition.getAmount(), tuition.getCurrency(), 'USD'),
37
+ category: 'tuition',
38
+ validFrom: timing ? timing.getStartDate().getDate() : undefined
39
+ };
40
+ }
41
+ buildStructuredDataForCard(entity) {
42
+ const card = entity.card;
43
+ return {
44
+ "@type": "Course",
45
+ "name": card.getTitle(),
46
+ "description": card.getSummary(),
47
+ "url": card.getProgrammeLink().getUrl(),
48
+ "provider": {
49
+ "@type": "EducationalOrganization",
50
+ "name": card.getUniversityLink().getDescription(),
51
+ "url": `https://${window.location.host}${card.getUniversityLink().getUrl()}`
52
+ },
53
+ "offers": this.constructOffer(entity.offer),
54
+ "aggregateRating": this.constructAggregateRating(entity.reviewRating),
55
+ "hasCourseInstance": {
56
+ "@type": "CourseInstance",
57
+ "startDate": this.getCourseStartDate(card),
58
+ "courseWorkload": this.getCourseWorkload(card),
59
+ "courseMode": this.getCourseMode(card),
60
+ }
61
+ };
62
+ }
63
+ getCourseMode(card) {
64
+ if (card.isBlended())
65
+ return 'blended';
66
+ if (card.isOnline())
67
+ return 'online';
68
+ if (card.isOnCampus())
69
+ return 'onsite';
70
+ }
71
+ getCourseWorkload(card) {
72
+ if (card.isFullTime())
73
+ return 'PT40H';
74
+ if (card.isPartTime())
75
+ return 'PT20H';
76
+ return undefined;
77
+ }
78
+ getCourseStartDate(card) {
79
+ for (const target of this.regionalDataPriority) {
80
+ const timing = card.canShowTimings() ? card.getTimings().find(t => t.getType() === target && t.canShowStartDate()) : undefined;
81
+ if (timing) {
82
+ return timing.getStartDate().getDate().toISOString().split('T')[0];
83
+ }
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,35 @@
1
+ import { SearchResultsPage, WithContext, Offer, AggregateRating, Thing, FAQPage } from "schema-dts";
2
+ import { ReviewRatingDTO } from "./dto/ReviewRatingDTO";
3
+ import { OfferDTO } from "./dto/OfferDTO";
4
+ import { EntityDTO } from "./dto/EntityDTO";
5
+ import { FAQItemDto } from "./dto/FAQItemDto";
6
+ import { BreadcrumbDTO } from "./dto/BreadcrumbDTO";
7
+ 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;
10
+ /**
11
+ * Get the rating for a card.
12
+ * This method should be overridden by subclasses to provide the specific rating logic.
13
+ * If no rating is available, return undefined and no aggregate rating will be constructed for the card & page.
14
+ *
15
+ * @param card
16
+ * @protected
17
+ */
18
+ protected getRating(card: TCard): ReviewRatingDTO | undefined;
19
+ /**
20
+ * Get the offer data for a card.
21
+ * This method should be overridden by subclasses to provide the specific offer logic.
22
+ * If no offer is available, return undefined and no aggregate offer will be constructed for the card & page.
23
+ *
24
+ * @param card
25
+ * @protected
26
+ */
27
+ protected getOfferData(card: TCard): Promise<OfferDTO | undefined>;
28
+ private constructOptionalPageAggregateRating;
29
+ private constructOptionalPageOffers;
30
+ protected constructOptionalFaqPage(faqItems: FAQItemDto[]): FAQPage | undefined;
31
+ private constructOptionalBreadcrumbs;
32
+ protected constructAggregateRating(reviewRating?: ReviewRatingDTO): AggregateRating | undefined;
33
+ protected constructOffer(offerData?: OfferDTO): Offer | undefined;
34
+ private stripHtmlWithListRetain;
35
+ }
@@ -0,0 +1,154 @@
1
+ export class SearchStructuredDataFactory {
2
+ async buildStructuredData(title, description, cards, faqItems = [], breadcrumbs = []) {
3
+ const entities = [];
4
+ const ratings = [];
5
+ const offersData = [];
6
+ for (const card of cards) {
7
+ const reviewRating = this.getRating(card);
8
+ const offer = await this.getOfferData(card);
9
+ if (reviewRating)
10
+ ratings.push(reviewRating);
11
+ if (offer)
12
+ offersData.push(offer);
13
+ entities.push({ card, offer, reviewRating });
14
+ }
15
+ const data = {
16
+ "@context": "https://schema.org",
17
+ "@type": "SearchResultsPage",
18
+ "name": title,
19
+ description,
20
+ "mainEntity": entities.map(entity => this.buildStructuredDataForCard(entity))
21
+ };
22
+ data.aggregateRating = this.constructOptionalPageAggregateRating(ratings);
23
+ data.offers = this.constructOptionalPageOffers(offersData);
24
+ data.hasPart = this.constructOptionalFaqPage(faqItems);
25
+ data.breadcrumb = this.constructOptionalBreadcrumbs(breadcrumbs);
26
+ return data;
27
+ }
28
+ /**
29
+ * Get the rating for a card.
30
+ * This method should be overridden by subclasses to provide the specific rating logic.
31
+ * If no rating is available, return undefined and no aggregate rating will be constructed for the card & page.
32
+ *
33
+ * @param card
34
+ * @protected
35
+ */
36
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
37
+ getRating(card) {
38
+ return undefined;
39
+ }
40
+ /**
41
+ * Get the offer data for a card.
42
+ * This method should be overridden by subclasses to provide the specific offer logic.
43
+ * If no offer is available, return undefined and no aggregate offer will be constructed for the card & page.
44
+ *
45
+ * @param card
46
+ * @protected
47
+ */
48
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
49
+ async getOfferData(card) {
50
+ return undefined;
51
+ }
52
+ constructOptionalPageAggregateRating(ratings) {
53
+ if (ratings.length <= 0)
54
+ return;
55
+ let totalQuantity = 0;
56
+ let totalRatingValue = 0;
57
+ for (const rating of ratings) {
58
+ totalQuantity += rating.quantity;
59
+ totalRatingValue += rating.rating;
60
+ }
61
+ const totalAverageRating = Math.round((totalRatingValue / ratings.length) * 10) / 10;
62
+ return this.constructAggregateRating({ rating: totalAverageRating, quantity: totalQuantity });
63
+ }
64
+ constructOptionalPageOffers(offersData) {
65
+ if (offersData.length <= 0)
66
+ return;
67
+ let minPrice = 0;
68
+ let maxPrice = 0;
69
+ let totalPrice = 0;
70
+ for (const offer of offersData) {
71
+ if (offer.price < minPrice || minPrice === 0) {
72
+ minPrice = offer.price;
73
+ }
74
+ if (offer.price > maxPrice) {
75
+ maxPrice = offer.price;
76
+ }
77
+ totalPrice += offer.price;
78
+ }
79
+ return {
80
+ "@type": "AggregateOffer",
81
+ "lowPrice": minPrice,
82
+ "highPrice": maxPrice,
83
+ "priceCurrency": "USD",
84
+ "offerCount": offersData.length,
85
+ "priceSpecification": {
86
+ "@type": "PriceSpecification",
87
+ "price": Math.round(totalPrice / offersData.length),
88
+ "priceCurrency": "USD"
89
+ }
90
+ };
91
+ }
92
+ constructOptionalFaqPage(faqItems) {
93
+ if (faqItems.length <= 0)
94
+ return;
95
+ return {
96
+ "@type": "FAQPage",
97
+ "mainEntity": faqItems.map(item => ({
98
+ "@type": "Question",
99
+ "name": item.question,
100
+ "acceptedAnswer": {
101
+ "@type": "Answer",
102
+ "text": this.stripHtmlWithListRetain(item.answer)
103
+ }
104
+ }))
105
+ };
106
+ }
107
+ constructOptionalBreadcrumbs(breadcrumbs) {
108
+ if (breadcrumbs.length <= 0)
109
+ return;
110
+ return {
111
+ "@type": "BreadcrumbList",
112
+ "itemListElement": breadcrumbs.map((breadcrumb) => ({
113
+ "@type": "ListItem",
114
+ "@id": `${breadcrumb.url}#listitem#thing`,
115
+ "name": breadcrumb.name,
116
+ "position": breadcrumb.position,
117
+ "item": breadcrumb.url
118
+ }))
119
+ };
120
+ }
121
+ constructAggregateRating(reviewRating) {
122
+ if (!reviewRating)
123
+ return;
124
+ return {
125
+ "@type": "AggregateRating",
126
+ "ratingValue": reviewRating.rating,
127
+ "reviewCount": reviewRating.quantity,
128
+ "bestRating": 5,
129
+ "worstRating": 1
130
+ };
131
+ }
132
+ constructOffer(offerData) {
133
+ if (!offerData)
134
+ return;
135
+ return {
136
+ "@type": "Offer",
137
+ "price": offerData.price,
138
+ "priceCurrency": "USD",
139
+ "availability": "https://schema.org/InStock",
140
+ "validFrom": offerData.validFrom ? offerData.validFrom.toISOString().split('T')[0] : undefined,
141
+ "category": "tuition"
142
+ };
143
+ }
144
+ stripHtmlWithListRetain(html) {
145
+ const tempDiv = document.createElement('div');
146
+ tempDiv.innerHTML = html;
147
+ // Replace all <li> elements with bullet points manually
148
+ tempDiv.querySelectorAll('li').forEach(li => {
149
+ li.textContent = '• ' + li.textContent;
150
+ });
151
+ // Get the text content, which includes newlines for block elements
152
+ return tempDiv.textContent?.trim() ?? '';
153
+ }
154
+ }
@@ -0,0 +1,5 @@
1
+ export interface BreadcrumbDTO {
2
+ name: string;
3
+ position: number;
4
+ url: string;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,7 @@
1
+ import { OfferDTO } from "./OfferDTO";
2
+ import { ReviewRatingDTO } from "./ReviewRatingDTO";
3
+ export interface EntityDTO<TCard> {
4
+ card: TCard;
5
+ offer?: OfferDTO;
6
+ reviewRating?: ReviewRatingDTO;
7
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export interface FAQItemDto {
2
+ question: string;
3
+ answer: string;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ export interface OfferDTO {
2
+ price: number;
3
+ validFrom: Date | undefined;
4
+ category: 'tuition';
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ export interface ReviewRatingDTO {
2
+ rating: number;
3
+ quantity: number;
4
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1 @@
1
+ export * from './ProgrammeStructuredDataFactory';
@@ -0,0 +1 @@
1
+ export * from './ProgrammeStructuredDataFactory';
@@ -0,0 +1,3 @@
1
+ export interface StructuredDataCurrencyConversionService {
2
+ convert(originalAmount: number, originalCurrency: string, targetCurrency?: string): Promise<number>;
3
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@studyportals/fawkes",
3
- "version": "7.3.2-0",
3
+ "version": "7.3.3-1",
4
4
  "description": "A package to centralize SEO related logic for SBLP and Sitemap Generator.",
5
5
  "files": [
6
6
  "./dist"
@@ -67,6 +67,7 @@
67
67
  "author": "The Jedi Council",
68
68
  "license": "ISC",
69
69
  "devDependencies": {
70
+ "@adobe/structured-data-validator": "^1.4.1",
70
71
  "@studyportals/code-style": "^2.2.1",
71
72
  "@studyportals/webpack-helper": "^6.0.6",
72
73
  "@vitest/coverage-istanbul": "^2.1.8",
@@ -79,10 +80,11 @@
79
80
  "vitest": "^2.1.8"
80
81
  },
81
82
  "dependencies": {
82
- "@studyportals/domain-client": "^6.0.0",
83
+ "@studyportals/domain-client": "^6.2.0",
83
84
  "@studyportals/ranking-api-interface": "^1.3.12",
84
85
  "@studyportals/search-filters": "^4.9.1",
85
86
  "@studyportals/static-domain-data": "^6.1.0",
87
+ "schema-dts": "^1.1.5",
86
88
  "ts-loader": "^9.5.2"
87
89
  },
88
90
  "optionalDependencies": {