@bash-app/bash-common 30.245.0 → 30.247.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.
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Section heading copy for the homepage rows and `/category/:bashType` pages.
3
+ *
4
+ * Two input shapes need to coexist:
5
+ * 1. `BashEventType` enum values (e.g. "ComedyShow", "Rave") — these have
6
+ * a singular label in `BashEventTypeToString` and get pluralized via
7
+ * explicit rules in `pluralizeBashEventSingularLabel`.
8
+ * 2. Free-text section keys persisted by scrapers as `bashEvent.eventType`
9
+ * strings that don't exist in the enum — for example
10
+ * `scrapeInsomniacToBash.mjs` defaults to `"Raves"` and
11
+ * `wiseguysComedyScraper.mjs` writes `"Comedy"`. These are already
12
+ * meaningful as headings and must be returned verbatim — never with
13
+ * a `" bashes"` suffix tacked on.
14
+ */
15
+ import { BashEventType } from "@prisma/client";
16
+
17
+ import {
18
+ bashSectionTitleForBashType,
19
+ canonicalBashEventRowKey,
20
+ } from "../definitions.js";
21
+
22
+ describe("bashSectionTitleForBashType", () => {
23
+ describe("free-text scraper section keys (non-enum)", () => {
24
+ it("returns 'Raves' verbatim, not 'Raves bashes'", () => {
25
+ expect(bashSectionTitleForBashType("Raves")).toBe("Raves");
26
+ });
27
+
28
+ it("returns 'Comedy' verbatim, not 'Comedy bashes'", () => {
29
+ expect(bashSectionTitleForBashType("Comedy")).toBe("Comedy");
30
+ });
31
+
32
+ it("returns mass-noun headings as-is", () => {
33
+ expect(bashSectionTitleForBashType("EDM")).toBe("EDM");
34
+ expect(bashSectionTitleForBashType("Hip Hop")).toBe("Hip Hop");
35
+ expect(bashSectionTitleForBashType("Karaoke")).toBe("Karaoke");
36
+ });
37
+ });
38
+
39
+ describe("synthetic homepage rows", () => {
40
+ it("'Featured' → 'Featured'", () => {
41
+ expect(bashSectionTitleForBashType("Featured")).toBe("Featured");
42
+ });
43
+
44
+ it("'Trending' → 'Trending'", () => {
45
+ expect(bashSectionTitleForBashType("Trending")).toBe("Trending");
46
+ });
47
+ });
48
+
49
+ describe("BashEventType enum values get pluralized", () => {
50
+ it("'Rave' enum → 'Raves'", () => {
51
+ expect(bashSectionTitleForBashType(BashEventType.Rave)).toBe("Raves");
52
+ });
53
+
54
+ it("'ComedyShow' enum → 'Comedy Shows'", () => {
55
+ expect(bashSectionTitleForBashType(BashEventType.ComedyShow)).toBe(
56
+ "Comedy Shows"
57
+ );
58
+ });
59
+
60
+ it("'Birthday' enum → 'Birthday Parties' (manual override)", () => {
61
+ expect(bashSectionTitleForBashType(BashEventType.Birthday)).toBe(
62
+ "Birthday Parties"
63
+ );
64
+ });
65
+
66
+ it("'WeddingReception' enum → 'Wedding Receptions' (suffix rule)", () => {
67
+ expect(bashSectionTitleForBashType(BashEventType.WeddingReception)).toBe(
68
+ "Wedding Receptions"
69
+ );
70
+ });
71
+
72
+ it("'HouseParty' enum → 'House Parties' (Party suffix rule)", () => {
73
+ expect(bashSectionTitleForBashType(BashEventType.HouseParty)).toBe(
74
+ "House Parties"
75
+ );
76
+ });
77
+ });
78
+
79
+ describe("homepage tier-section row keys (`LazyTierSection`, bashType=`title`)", () => {
80
+ it("titles ending with ' Events' are returned verbatim (no duplicate ' bashes')", () => {
81
+ expect(bashSectionTitleForBashType("Pro Events")).toBe("Pro Events");
82
+ expect(bashSectionTitleForBashType("Elite Events")).toBe("Elite Events");
83
+ expect(bashSectionTitleForBashType("Legend Events")).toBe("Legend Events");
84
+ });
85
+ });
86
+
87
+ describe("Insomniac-style scraper fallback keys matching enum literals", () => {
88
+ it("'Festival' → 'Festivals'", () => {
89
+ expect(bashSectionTitleForBashType("Festival")).toBe("Festivals");
90
+ });
91
+ it("'Concert' → 'Concerts'", () => {
92
+ expect(bashSectionTitleForBashType("Concert")).toBe("Concerts");
93
+ });
94
+ it("'Party' enum → 'Parties'", () => {
95
+ expect(bashSectionTitleForBashType("Party")).toBe("Parties");
96
+ });
97
+ });
98
+
99
+ describe("keys that diverge from Prisma enum literals (case or display labels)", () => {
100
+ it("lower-case prisma value 'party' → 'Parties'", () => {
101
+ expect(bashSectionTitleForBashType("party")).toBe("Parties");
102
+ });
103
+
104
+ it("lower-case 'fundraiser' → 'Fundraisers'", () => {
105
+ expect(bashSectionTitleForBashType("fundraiser")).toBe("Fundraisers");
106
+ });
107
+
108
+ it("singular display label row key 'House Party' → 'House Parties'", () => {
109
+ expect(bashSectionTitleForBashType("House Party")).toBe("House Parties");
110
+ });
111
+
112
+ it("trimmed stray whitespace still pluralizes", () => {
113
+ expect(bashSectionTitleForBashType(" Fundraiser ")).toBe("Fundraisers");
114
+ });
115
+ });
116
+
117
+ describe("already-plural enum headings", () => {
118
+ it("normalizes casing when the row key matches a pluralized enum title", () => {
119
+ expect(bashSectionTitleForBashType("House Parties")).toBe(
120
+ "House Parties"
121
+ );
122
+ expect(bashSectionTitleForBashType("house parties")).toBe("House Parties");
123
+ expect(bashSectionTitleForBashType("Comedy Shows")).toBe("Comedy Shows");
124
+ });
125
+ });
126
+ });
127
+
128
+ describe("canonicalBashEventRowKey", () => {
129
+ it("maps singular labels and enum literals to the Prisma enum string", () => {
130
+ expect(canonicalBashEventRowKey("House Party")).toBe(
131
+ BashEventType.HouseParty
132
+ );
133
+ expect(canonicalBashEventRowKey(BashEventType.HouseParty)).toBe(
134
+ BashEventType.HouseParty
135
+ );
136
+ expect(canonicalBashEventRowKey("houseparty")).toBe(BashEventType.HouseParty);
137
+ });
138
+
139
+ it("returns trimmed free-text when no enum matches", () => {
140
+ expect(canonicalBashEventRowKey("Graduation")).toBe("Graduation");
141
+ });
142
+ });
@@ -1,5 +1,4 @@
1
1
  import {
2
- AgePolicy,
3
2
  BashEventType,
4
3
  BracketType,
5
4
  CompetitionType,
@@ -18,6 +17,7 @@ import {
18
17
  User,
19
18
  } from "@prisma/client";
20
19
  import type {
20
+ AgePolicy as AgePolicyEnum,
21
21
  BashPassTier,
22
22
  BashEventDressTags,
23
23
  BashEventVibeTags,
@@ -28,6 +28,7 @@ import type {
28
28
  } from "@prisma/client";
29
29
  import {
30
30
  CheckoutExt,
31
+ PublicTeaserUser,
31
32
  PublicUser,
32
33
  ServiceBookingExt,
33
34
  type UserExt,
@@ -79,6 +80,15 @@ export const GovIdRetentionPolicy = {
79
80
 
80
81
  export type GovIdRetentionPolicy = GovIdRetentionPolicyEnum;
81
82
 
83
+ /** Mirrors Prisma `AgePolicy` — `@prisma/client` browser bundle omits runtime enum exports. */
84
+ export const AgePolicy = {
85
+ AllAges: "AllAges",
86
+ EighteenPlus: "EighteenPlus",
87
+ TwentyOnePlus: "TwentyOnePlus",
88
+ } as const satisfies Record<AgePolicyEnum, AgePolicyEnum>;
89
+
90
+ export type AgePolicy = AgePolicyEnum;
91
+
82
92
  export const PASSWORD_MIN_LENGTH = 8 as const;
83
93
  export const PASSWORD_REQUIREMENTS_REGEX = new RegExp(
84
94
  String.raw`^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&^#])[A-Za-z\d@$!%*?&^#]{${PASSWORD_MIN_LENGTH},}$`
@@ -708,7 +718,7 @@ export interface LoginSession {
708
718
  }
709
719
 
710
720
  export interface AttendeeOfBashEvent {
711
- user: PublicUser;
721
+ user: PublicUser | PublicTeaserUser;
712
722
  tickets: Ticket[];
713
723
  }
714
724
 
@@ -1114,7 +1124,6 @@ export const YearsOfExperienceToString: { [key in YearsOfExperienceEnum]: string
1114
1124
 
1115
1125
  // Export Prisma enums for use in frontend
1116
1126
  export {
1117
- AgePolicy,
1118
1127
  BracketType,
1119
1128
  CompetitionType,
1120
1129
  EntertainmentServiceType,
@@ -1439,8 +1448,8 @@ export const INCOME_RANGE_VALUES: ReadonlyArray<IncomeRange> = [
1439
1448
 
1440
1449
  /** Homepage / discovery section labels for synthetic rows (not `BashEventType` enum values). */
1441
1450
  const SYNTHETIC_BASH_SECTION_TITLE: Record<string, string> = {
1442
- Featured: "Featured bashes",
1443
- Trending: "Trending bashes",
1451
+ Featured: "Featured",
1452
+ Trending: "Trending",
1444
1453
  };
1445
1454
 
1446
1455
  /**
@@ -1481,24 +1490,25 @@ const WHOLE_WORD_BASH_LABEL_PLURAL: ReadonlyMap<string, string> = new Map([
1481
1490
  ]);
1482
1491
 
1483
1492
  function pluralizeBashEventSingularLabel(label: string): string {
1484
- const manual = BASH_EVENT_SINGULAR_LABEL_TO_PLURAL_SECTION.get(label);
1493
+ const t = label.trim();
1494
+ const manual = BASH_EVENT_SINGULAR_LABEL_TO_PLURAL_SECTION.get(t);
1485
1495
  if (manual) return manual;
1486
1496
 
1487
- const wholeWord = WHOLE_WORD_BASH_LABEL_PLURAL.get(label);
1497
+ const wholeWord = WHOLE_WORD_BASH_LABEL_PLURAL.get(t);
1488
1498
  if (wholeWord) return wholeWord;
1489
1499
 
1490
- if (label === "Party") return "Parties";
1491
- if (label.endsWith(" Party")) {
1492
- return `${label.slice(0, -" Party".length)} Parties`;
1500
+ if (t === "Party") return "Parties";
1501
+ if (t.endsWith(" Party")) {
1502
+ return `${t.slice(0, -" Party".length)} Parties`;
1493
1503
  }
1494
- if (label.endsWith("-Party")) {
1495
- return `${label.slice(0, -"-Party".length)}-Parties`;
1504
+ if (t.endsWith("-Party")) {
1505
+ return `${t.slice(0, -"-Party".length)}-Parties`;
1496
1506
  }
1497
- if (label.endsWith(" Party)")) {
1498
- return `${label.replace(/ Party\)$/, " Parties)")}`;
1507
+ if (t.endsWith(" Party)")) {
1508
+ return `${t.replace(/ Party\)$/, " Parties)")}`;
1499
1509
  }
1500
- if (label.endsWith(" Rave")) {
1501
- return `${label.slice(0, -" Rave".length)} Raves`;
1510
+ if (t.endsWith(" Rave")) {
1511
+ return `${t.slice(0, -" Rave".length)} Raves`;
1502
1512
  }
1503
1513
 
1504
1514
  const replacements: ReadonlyArray<[suffix: string, plural: string]> = [
@@ -1535,37 +1545,97 @@ function pluralizeBashEventSingularLabel(label: string): string {
1535
1545
  ];
1536
1546
 
1537
1547
  for (const [suffix, plural] of replacements) {
1538
- if (label.endsWith(suffix)) {
1539
- return `${label.slice(0, -suffix.length)}${plural}`;
1548
+ if (t.endsWith(suffix)) {
1549
+ return `${t.slice(0, -suffix.length)}${plural}`;
1540
1550
  }
1541
1551
  }
1542
1552
 
1543
- return `${label} bashes`;
1553
+ // No explicit rule matched. The label is almost certainly:
1554
+ // - already plural (e.g. "Raves" from `scrapeInsomniacToBash.mjs`)
1555
+ // - a mass noun used as a category heading (e.g. "Comedy" from
1556
+ // `wiseguysComedyScraper.mjs`, "EDM", "Hip Hop")
1557
+ // - a future enum value not yet covered above
1558
+ // For all of these, returning the label verbatim is correct. Appending
1559
+ // " bashes" produces "Raves bashes" / "Comedy bashes", which reads wrong.
1560
+ return t;
1544
1561
  }
1545
1562
 
1546
1563
  const BASH_EVENT_TYPE_ENUM_VALUES: ReadonlySet<string> = new Set(
1547
1564
  Object.values(BashEventType) as string[]
1548
1565
  );
1549
1566
 
1567
+ function resolveCanonicalBashEventTypeKey(raw: string): string | undefined {
1568
+ const t = raw.trim();
1569
+ if (!t) return undefined;
1570
+ if (BASH_EVENT_TYPE_ENUM_VALUES.has(t)) return t;
1571
+ const lower = t.toLowerCase();
1572
+ for (const v of BASH_EVENT_TYPE_ENUM_VALUES) {
1573
+ if (v.toLowerCase() === lower) return v;
1574
+ }
1575
+ return undefined;
1576
+ }
1577
+
1578
+ /**
1579
+ * When the row key matches a `BashEventTypeToString` value (e.g. "House Party")
1580
+ * but not the Prisma enum literal (`HouseParty`), still pluralize like the enum.
1581
+ */
1582
+ function resolveBashEventEnumKeyFromSingularLabel(raw: string): string | undefined {
1583
+ const t = raw.trim();
1584
+ if (!t) return undefined;
1585
+ const lower = t.toLowerCase();
1586
+ for (const enumKey of BASH_EVENT_TYPE_ENUM_VALUES) {
1587
+ const k = enumKey as BashEventType;
1588
+ const label = BashEventTypeToString[k];
1589
+ if (label === t || label.toLowerCase() === lower) return enumKey;
1590
+ }
1591
+ return undefined;
1592
+ }
1593
+
1594
+ /**
1595
+ * Stable row key for homepage/category grouping when `eventType` may be stored
1596
+ * as a Prisma enum literal (e.g. `HouseParty`) or as the singular UI label
1597
+ * (`House Party`).
1598
+ */
1599
+ export function canonicalBashEventRowKey(raw: string): string {
1600
+ const trimmed = raw.trim();
1601
+ if (!trimmed) return trimmed;
1602
+ const enumKey =
1603
+ resolveCanonicalBashEventTypeKey(trimmed) ??
1604
+ resolveBashEventEnumKeyFromSingularLabel(trimmed);
1605
+ if (enumKey != null) return enumKey;
1606
+ return trimmed;
1607
+ }
1608
+
1550
1609
  /**
1551
1610
  * Heading shown on the home page row and the `/category/...` page for a bash
1552
1611
  * `eventType` / section key. Uses plural-friendly copy for Prisma
1553
1612
  * `BashEventType` values; keeps synthetic rows and membership tier rows clear.
1554
1613
  */
1555
1614
  export function bashSectionTitleForBashType(bashType: string): string {
1556
- const synthetic = SYNTHETIC_BASH_SECTION_TITLE[bashType];
1615
+ const raw = bashType.trim();
1616
+ const lower = raw.toLowerCase();
1617
+
1618
+ const synthetic = SYNTHETIC_BASH_SECTION_TITLE[raw];
1557
1619
  if (synthetic != null) return synthetic;
1620
+ if (/\bEvents$/i.test(raw)) return raw;
1558
1621
 
1559
- if (/\bEvents$/i.test(bashType)) {
1560
- return bashType;
1622
+ for (const enumKey of BASH_EVENT_TYPE_ENUM_VALUES) {
1623
+ const singular = BashEventTypeToString[enumKey as BashEventType];
1624
+ const pluralHeading = pluralizeBashEventSingularLabel(singular);
1625
+ if (pluralHeading.toLowerCase() === lower) {
1626
+ return pluralHeading;
1627
+ }
1561
1628
  }
1562
1629
 
1563
- if (BASH_EVENT_TYPE_ENUM_VALUES.has(bashType)) {
1564
- const singular = BashEventTypeToString[bashType as BashEventType];
1630
+ const enumKey =
1631
+ resolveCanonicalBashEventTypeKey(raw) ??
1632
+ resolveBashEventEnumKeyFromSingularLabel(raw);
1633
+ if (enumKey != null) {
1634
+ const singular = BashEventTypeToString[enumKey as BashEventType];
1565
1635
  return pluralizeBashEventSingularLabel(singular);
1566
1636
  }
1567
1637
 
1568
- const spaced = insertSpacesInCamelCase(bashType);
1638
+ const spaced = insertSpacesInCamelCase(raw);
1569
1639
  return pluralizeBashEventSingularLabel(spaced);
1570
1640
  }
1571
1641
 
@@ -199,6 +199,25 @@ export const FRONT_END_USER_DATA_TO_SELECT = {
199
199
  },
200
200
  } satisfies Prisma.UserSelect;
201
201
 
202
+ /**
203
+ * Minimal `User` fields for public teaser surfaces (event detail avatars, checkout rows,
204
+ * non-privileged attendee rosters). Omits `email` and other account-level PII.
205
+ */
206
+ export const PUBLIC_TEASER_USER_DATA_TO_SELECT = {
207
+ id: true,
208
+ username: true,
209
+ givenName: true,
210
+ familyName: true,
211
+ image: true,
212
+ imageUpdatedAt: true,
213
+ uploadedImage: true,
214
+ uploadedImageUpdatedAt: true,
215
+ } satisfies Prisma.UserSelect;
216
+
217
+ export type PublicTeaserUser = Prisma.UserGetPayload<{
218
+ select: typeof PUBLIC_TEASER_USER_DATA_TO_SELECT;
219
+ }>;
220
+
202
221
  export const PRIVATE_USER_ACCOUNT_TO_SELECT = {
203
222
  ...FRONT_END_USER_DATA_TO_SELECT,
204
223
  /** Self session only — preferred payment rail (Stripe vs Square). */
@@ -1287,7 +1306,8 @@ export type TicketFlexible = Ticket & {
1287
1306
  };
1288
1307
 
1289
1308
  export interface CheckoutExt extends Checkout {
1290
- owner: PublicUser;
1309
+ /** Full owner row for hosts/organizers; teaser row (no email) for other authenticated viewers. */
1310
+ owner: PublicUser | PublicTeaserUser;
1291
1311
  tickets: Ticket[];
1292
1312
  }
1293
1313
 
@@ -1302,6 +1322,18 @@ export const CHECKOUT_DATA_TO_INCLUDE = {
1302
1322
  },
1303
1323
  } satisfies Prisma.CheckoutInclude;
1304
1324
 
1325
+ /** Checkout list for non-privileged viewers — buyer email omitted on `owner`. */
1326
+ export const CHECKOUT_DATA_TO_INCLUDE_TEASER_OWNER = {
1327
+ owner: {
1328
+ select: PUBLIC_TEASER_USER_DATA_TO_SELECT,
1329
+ },
1330
+ tickets: {
1331
+ select: {
1332
+ ownerId: true,
1333
+ },
1334
+ },
1335
+ } satisfies Prisma.CheckoutInclude;
1336
+
1305
1337
  export interface ReviewExt extends Review {
1306
1338
  comments?: BashComment[];
1307
1339
  }
package/src/index.ts CHANGED
@@ -87,6 +87,7 @@ export {
87
87
  EventServiceType,
88
88
  EventTemplateCategory,
89
89
  ExhibitorBookingStatus,
90
+ FeaturedReason,
90
91
  FinancingSubType,
91
92
  FlyerCampaignStatus,
92
93
  FlyerConversionType,
@@ -305,6 +306,7 @@ export * from "./utils/service/attendeeOptionUtils.js";
305
306
  export * from "./utils/service/billingCapUtils.js";
306
307
  export * from "./utils/service/eventPromoterUtils.js";
307
308
  export * from "./utils/service/regexUtils.js";
309
+ export * from "./utils/service/serviceFeaturedSortUtils.js";
308
310
  export * from "./utils/service/serviceBookingExposureUtils.js";
309
311
  export * from "./utils/service/serviceUtils.js";
310
312
  export * from "./utils/service/venueSettlementUtils.js";
@@ -24,6 +24,7 @@ export * from './service/frontendServiceBookingUtils.js';
24
24
  export * from './service/regexUtils.js';
25
25
  export * from './service/serviceBookingStatusUtils.js';
26
26
  export * from './service/serviceBookingTypes.js';
27
+ export * from './service/serviceFeaturedSortUtils.js';
27
28
  export * from './service/serviceRateTypes.js';
28
29
  export * from './service/serviceRateUtils.js';
29
30
  export * from './service/serviceUtils.js';
@@ -0,0 +1,76 @@
1
+ import { FeaturedReason } from "@prisma/client";
2
+
3
+ /**
4
+ * Subset of `Service` fields needed to decide featured-tier ordering.
5
+ * Both the API public list endpoint and the front-end browse filter sort
6
+ * with this comparator so server and client agree on order.
7
+ */
8
+ export interface FeaturedServiceSortable {
9
+ isFeaturedService?: boolean | null;
10
+ featuredServiceUntil?: Date | string | null;
11
+ featuredAt?: Date | string | null;
12
+ featuredReason?: FeaturedReason | null;
13
+ createdAt?: Date | string | null;
14
+ }
15
+
16
+ /** Reasons that rank above auto-features in the public sort. */
17
+ const PAID_TIER_REASONS: ReadonlySet<FeaturedReason> = new Set([
18
+ "Paid",
19
+ "Admin",
20
+ "Membership",
21
+ ]);
22
+
23
+ function toMs(value: Date | string | null | undefined): number {
24
+ if (!value) return 0;
25
+ const d = value instanceof Date ? value : new Date(value);
26
+ const t = d.getTime();
27
+ return Number.isFinite(t) ? t : 0;
28
+ }
29
+
30
+ function isActiveFeatured(s: FeaturedServiceSortable, now: Date): boolean {
31
+ if (!s.isFeaturedService) return false;
32
+ if (!s.featuredServiceUntil) return true;
33
+ return new Date(s.featuredServiceUntil) >= now;
34
+ }
35
+
36
+ /**
37
+ * Tier 0 = paid / admin / membership feature, 1 = AutoNewService, 2 = unfeatured
38
+ * (or featured but with no reason recorded — these existed before the migration
39
+ * and are treated as "legacy featured" sitting just below auto-features).
40
+ */
41
+ function featuredTier(s: FeaturedServiceSortable, now: Date): number {
42
+ if (!isActiveFeatured(s, now)) return 3;
43
+ if (s.featuredReason && PAID_TIER_REASONS.has(s.featuredReason)) return 0;
44
+ if (s.featuredReason === "AutoNewService") return 1;
45
+ return 2;
46
+ }
47
+
48
+ /**
49
+ * Public-services ORDER BY equivalent in JS:
50
+ * 1. paid/admin/membership features (FIFO by featuredAt asc)
51
+ * 2. auto-featured new services (FIFO by featuredAt asc)
52
+ * 3. legacy featured rows with no recorded reason (FIFO by featuredAt asc)
53
+ * 4. everything else (newest first by createdAt desc)
54
+ *
55
+ * Pass a single `now` so all comparisons in one sort use the same instant.
56
+ */
57
+ export function compareFeaturedServices(
58
+ now: Date = new Date()
59
+ ): (a: FeaturedServiceSortable, b: FeaturedServiceSortable) => number {
60
+ return (a, b) => {
61
+ const ta = featuredTier(a, now);
62
+ const tb = featuredTier(b, now);
63
+ if (ta !== tb) return ta - tb;
64
+
65
+ if (ta < 3) {
66
+ const fa = toMs(a.featuredAt);
67
+ const fb = toMs(b.featuredAt);
68
+ if (fa !== fb) return fa - fb; // ASC — earliest paid wins
69
+ }
70
+
71
+ return toMs(b.createdAt) - toMs(a.createdAt); // DESC for non-featured / tie-break
72
+ };
73
+ }
74
+
75
+ /** Minimum number of active featured services required for the homepage / browse "Featured Services" carousel to render. Below this floor the section feels sparse. */
76
+ export const MIN_FEATURED_SERVICES_FOR_SECTION = 3;