Skip to content

Commit 34f3b6a

Browse files
feat: add ThemeFontControl component for managing homepage fonts (#1169) (#1176)
Co-authored-by: Manas Kumar <141910018+manaskumar3003@users.noreply.github.com>
2 parents 5282c64 + 106b397 commit 34f3b6a

51 files changed

Lines changed: 2483 additions & 1689 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
namespace HiEvents\DomainObjects\Enums;
4+
5+
/**
6+
* Curated set of fonts available for public event and organizer homepages.
7+
* Kept in sync with frontend/src/constants/homepageFonts.ts — update both when adding/removing fonts.
8+
*/
9+
enum HomepageFontFamily: string
10+
{
11+
use BaseEnum;
12+
13+
case Outfit = 'Outfit';
14+
case Inter = 'Inter';
15+
case Roboto = 'Roboto';
16+
case OpenSans = 'Open Sans';
17+
case Poppins = 'Poppins';
18+
case Montserrat = 'Montserrat';
19+
case Lato = 'Lato';
20+
case Nunito = 'Nunito';
21+
case Raleway = 'Raleway';
22+
case DMSans = 'DM Sans';
23+
case PlusJakartaSans = 'Plus Jakarta Sans';
24+
case WorkSans = 'Work Sans';
25+
case SpaceGrotesk = 'Space Grotesk';
26+
case Manrope = 'Manrope';
27+
case Oswald = 'Oswald';
28+
case BebasNeue = 'Bebas Neue';
29+
case PlayfairDisplay = 'Playfair Display';
30+
case Merriweather = 'Merriweather';
31+
case Lora = 'Lora';
32+
}

backend/app/Http/Request/EventSettings/UpdateEventSettingsRequest.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod;
66
use HiEvents\DomainObjects\Enums\HomepageBackgroundType;
7+
use HiEvents\DomainObjects\Enums\HomepageFontFamily;
78
use HiEvents\DomainObjects\Enums\PaymentProviders;
89
use HiEvents\DomainObjects\Enums\PriceDisplayMode;
910
use HiEvents\Http\Request\BaseRequest;
@@ -98,6 +99,7 @@ public function rules(): array
9899
'homepage_theme_settings.background' => ['nullable', 'string', ...RulesHelper::HEX_COLOR],
99100
'homepage_theme_settings.mode' => ['nullable', 'string', Rule::in(['light', 'dark'])],
100101
'homepage_theme_settings.background_type' => ['nullable', 'string', Rule::in(HomepageBackgroundType::valuesArray())],
102+
'homepage_theme_settings.font_family' => ['nullable', 'string', Rule::in(HomepageFontFamily::valuesArray())],
101103

102104
// Self-service settings
103105
'allow_attendee_self_edit' => ['boolean'],
@@ -147,6 +149,7 @@ public function messages(): array
147149
'homepage_theme_settings.background' => $colorMessage,
148150
'homepage_theme_settings.mode.in' => __('The mode must be light or dark.'),
149151
'homepage_theme_settings.background_type.in' => __('The background type must be COLOR or MIRROR_COVER_IMAGE.'),
152+
'homepage_theme_settings.font_family.in' => __('The selected font is not supported.'),
150153
];
151154
}
152155
}

backend/app/Http/Request/Organizer/Settings/PartialUpdateOrganizerSettingsRequest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use HiEvents\DomainObjects\Enums\AttendeeDetailsCollectionMethod;
66
use HiEvents\DomainObjects\Enums\HomepageBackgroundType;
7+
use HiEvents\DomainObjects\Enums\HomepageFontFamily;
78
use HiEvents\DomainObjects\Enums\OrganizerHomepageVisibility;
89
use HiEvents\DomainObjects\Enums\TrackingPixelProvider;
910
use HiEvents\Http\Request\BaseRequest;
@@ -110,6 +111,7 @@ public static function rules(): array
110111
'homepage_theme_settings.background' => ['nullable', 'string', ...RulesHelper::HEX_COLOR],
111112
'homepage_theme_settings.mode' => ['nullable', 'string', Rule::in(['light', 'dark'])],
112113
'homepage_theme_settings.background_type' => ['nullable', 'string', Rule::in(HomepageBackgroundType::valuesArray())],
114+
'homepage_theme_settings.font_family' => ['nullable', 'string', Rule::in(HomepageFontFamily::valuesArray())],
113115

114116
// SEO
115117
'seo_keywords' => ['sometimes', 'nullable', 'string', 'max:255'],

backend/app/Services/Domain/Event/CreateEventService.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,10 @@ private function createEventSettings(
190190
: ($organizerThemeSettings['background_type'] ?? HomepageBackgroundType::COLOR->name),
191191
];
192192

193+
if (!empty($organizerThemeSettings['font_family'])) {
194+
$homepageThemeSettings['font_family'] = $organizerThemeSettings['font_family'];
195+
}
196+
193197
$this->eventSettingsRepository->create([
194198
'event_id' => $event->getId(),
195199

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Tests\Unit\DomainObjects\Enums;
4+
5+
use HiEvents\DomainObjects\Enums\HomepageFontFamily;
6+
use Tests\TestCase;
7+
8+
class HomepageFontFamilyTest extends TestCase
9+
{
10+
public function test_values_array_contains_curated_fonts(): void
11+
{
12+
$values = HomepageFontFamily::valuesArray();
13+
14+
$this->assertContains('Outfit', $values);
15+
$this->assertContains('Inter', $values);
16+
$this->assertContains('Plus Jakarta Sans', $values);
17+
$this->assertContains('Playfair Display', $values);
18+
$this->assertContains('Bebas Neue', $values);
19+
}
20+
21+
public function test_values_are_unique_non_empty_strings(): void
22+
{
23+
$values = HomepageFontFamily::valuesArray();
24+
25+
$this->assertNotEmpty($values);
26+
$this->assertSame($values, array_values(array_unique($values)));
27+
28+
foreach ($values as $value) {
29+
$this->assertIsString($value);
30+
$this->assertNotSame('', trim($value));
31+
}
32+
}
33+
}

backend/tests/Unit/Services/Domain/Event/CreateEventServiceTest.php

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,132 @@ public function testCreateEventWithoutEventCoverDoesNotCreateImageRecord(): void
351351
$this->assertTrue(true);
352352
}
353353

354+
public function testCreateEventInheritsOrganizerFontFamily(): void
355+
{
356+
$eventData = $this->createMockEventDomainObjectWithCategory('MUSIC');
357+
358+
$organizerSettings = Mockery::mock(OrganizerSettingDomainObject::class);
359+
$organizerSettings->shouldReceive('getHomepageThemeSettings')
360+
->andReturn([
361+
'accent' => '#ff0000',
362+
'background' => '#ffffff',
363+
'mode' => 'light',
364+
'background_type' => HomepageBackgroundType::COLOR->name,
365+
'font_family' => 'Inter',
366+
]);
367+
$organizerSettings->shouldReceive('getDefaultAttendeeDetailsCollectionMethod')
368+
->andReturn('per_order');
369+
$organizerSettings->shouldReceive('getDefaultShowMarketingOptIn')
370+
->andReturn(false);
371+
$organizerSettings->shouldReceive('getDefaultPassPlatformFeeToBuyer')
372+
->andReturn(false);
373+
$organizerSettings->shouldReceive('getDefaultAllowAttendeeSelfEdit')
374+
->andReturn(false);
375+
376+
$organizer = $this->createMockOrganizerDomainObject()
377+
->shouldReceive('getOrganizerSettings')
378+
->andReturn($organizerSettings)
379+
->getMock();
380+
381+
$this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
382+
return $callback();
383+
});
384+
385+
$this->organizerRepository
386+
->shouldReceive('loadRelation')
387+
->with(OrganizerSettingDomainObject::class)
388+
->once()
389+
->andReturnSelf()
390+
->getMock()
391+
->shouldReceive('findFirstWhere')
392+
->andReturn($organizer);
393+
394+
$this->eventRepository->shouldReceive('create')->andReturn($eventData);
395+
396+
$this->config->shouldReceive('get')
397+
->with('filesystems.public')
398+
->andReturn('public');
399+
$this->config->shouldReceive('get')
400+
->with('app.event_categories_cover_images_path')
401+
->andReturn('event-covers');
402+
403+
$mockDisk = Mockery::mock();
404+
$mockDisk->shouldReceive('exists')
405+
->with('event-covers/MUSIC.jpg')
406+
->andReturn(false);
407+
408+
$this->filesystemManager->shouldReceive('disk')
409+
->with('public')
410+
->andReturn($mockDisk);
411+
412+
$this->eventSettingsRepository->shouldReceive('create')
413+
->with(Mockery::on(function ($arg) {
414+
return isset($arg['homepage_theme_settings']['font_family'])
415+
&& $arg['homepage_theme_settings']['font_family'] === 'Inter';
416+
}));
417+
418+
$this->eventStatisticsRepository->shouldReceive('create');
419+
420+
$this->purifier->shouldReceive('purify')->andReturn('Test Description');
421+
422+
$this->createEventService->createEvent($eventData);
423+
424+
$this->assertTrue(true);
425+
}
426+
427+
public function testCreateEventOmitsFontFamilyWhenOrganizerHasNone(): void
428+
{
429+
$eventData = $this->createMockEventDomainObjectWithCategory('MUSIC');
430+
$organizer = $this->createMockOrganizerDomainObject()
431+
->shouldReceive('getOrganizerSettings')
432+
->andReturn(new OrganizerSettingDomainObject())
433+
->getMock();
434+
435+
$this->databaseManager->shouldReceive('transaction')->once()->andReturnUsing(function ($callback) {
436+
return $callback();
437+
});
438+
439+
$this->organizerRepository
440+
->shouldReceive('loadRelation')
441+
->with(OrganizerSettingDomainObject::class)
442+
->once()
443+
->andReturnSelf()
444+
->getMock()
445+
->shouldReceive('findFirstWhere')
446+
->andReturn($organizer);
447+
448+
$this->eventRepository->shouldReceive('create')->andReturn($eventData);
449+
450+
$this->config->shouldReceive('get')
451+
->with('filesystems.public')
452+
->andReturn('public');
453+
$this->config->shouldReceive('get')
454+
->with('app.event_categories_cover_images_path')
455+
->andReturn('event-covers');
456+
457+
$mockDisk = Mockery::mock();
458+
$mockDisk->shouldReceive('exists')
459+
->with('event-covers/MUSIC.jpg')
460+
->andReturn(false);
461+
462+
$this->filesystemManager->shouldReceive('disk')
463+
->with('public')
464+
->andReturn($mockDisk);
465+
466+
$this->eventSettingsRepository->shouldReceive('create')
467+
->with(Mockery::on(function ($arg) {
468+
return !array_key_exists('font_family', $arg['homepage_theme_settings'] ?? []);
469+
}));
470+
471+
$this->eventStatisticsRepository->shouldReceive('create');
472+
473+
$this->purifier->shouldReceive('purify')->andReturn('Test Description');
474+
475+
$this->createEventService->createEvent($eventData);
476+
477+
$this->assertTrue(true);
478+
}
479+
354480
private function createMockEventDomainObject(): EventDomainObject
355481
{
356482
return Mockery::mock(EventDomainObject::class, static function ($mock) {
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
.option {
2+
display: flex;
3+
align-items: baseline;
4+
justify-content: space-between;
5+
width: 100%;
6+
gap: 12px;
7+
}
8+
9+
.optionLabel {
10+
font-size: 14px;
11+
font-weight: 500;
12+
}
13+
14+
.optionSample {
15+
font-size: 13px;
16+
color: var(--mantine-color-dimmed);
17+
letter-spacing: 0.5px;
18+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import {Select, Text} from "@mantine/core";
2+
import {t} from "@lingui/macro";
3+
import {useEffect, useMemo} from "react";
4+
import {
5+
buildHomepageFontStack,
6+
buildHomepageFontUrl,
7+
DEFAULT_HOMEPAGE_FONT,
8+
HOMEPAGE_FONTS,
9+
} from "../../../constants/homepageFonts.ts";
10+
import {ensureHomepageFontLoaded} from "../../../utilites/fontLoader.ts";
11+
import classes from "./ThemeFontControl.module.scss";
12+
13+
interface ThemeFontControlProps {
14+
value: string | null | undefined;
15+
onChange: (fontFamily: string) => void;
16+
disabled?: boolean;
17+
}
18+
19+
export const ThemeFontControl = ({value, onChange, disabled = false}: ThemeFontControlProps) => {
20+
const selected = value || DEFAULT_HOMEPAGE_FONT;
21+
22+
const data = useMemo(
23+
() => HOMEPAGE_FONTS.map(font => ({value: font.value, label: font.label})),
24+
[],
25+
);
26+
27+
useEffect(() => {
28+
HOMEPAGE_FONTS.forEach(font => {
29+
if (font.value === DEFAULT_HOMEPAGE_FONT || typeof document === 'undefined') {
30+
return;
31+
}
32+
const id = `hi-font-preview-${font.bunnyFamily}`;
33+
if (document.getElementById(id)) {
34+
return;
35+
}
36+
const link = document.createElement('link');
37+
link.id = id;
38+
link.rel = 'stylesheet';
39+
link.href = buildHomepageFontUrl(font.value);
40+
document.head.appendChild(link);
41+
});
42+
}, []);
43+
44+
useEffect(() => {
45+
ensureHomepageFontLoaded(selected);
46+
}, [selected]);
47+
48+
const handleChange = (next: string | null) => {
49+
if (!next) {
50+
return;
51+
}
52+
onChange(next);
53+
};
54+
55+
const renderOption = ({option}: {option: {value: string; label: string}}) => (
56+
<div className={classes.option} style={{fontFamily: buildHomepageFontStack(option.value)}}>
57+
<span className={classes.optionLabel}>{option.label}</span>
58+
<span className={classes.optionSample}>Aa 123</span>
59+
</div>
60+
);
61+
62+
return (
63+
<div>
64+
<Select
65+
label={t`Font Family`}
66+
description={t`Choose a typeface that matches your brand. Fonts are self-hosted via Bunny Fonts.`}
67+
size="sm"
68+
value={selected}
69+
onChange={handleChange}
70+
data={data}
71+
disabled={disabled}
72+
searchable
73+
allowDeselect={false}
74+
nothingFoundMessage={t`No matching fonts`}
75+
renderOption={renderOption}
76+
styles={{
77+
input: {fontFamily: buildHomepageFontStack(selected)},
78+
}}
79+
/>
80+
<Text size="xs" c="dimmed" mt={6} style={{fontFamily: buildHomepageFontStack(selected)}}>
81+
{t`The quick brown fox jumps over the lazy dog.`}
82+
</Text>
83+
</div>
84+
);
85+
};
86+
87+
export default ThemeFontControl;

frontend/src/components/layouts/EventHomepage/EventHomepage.module.scss

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
@use "../../../styles/mixins.scss";
22

33
// Design tokens
4-
$font-display: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
5-
$font-body: 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
4+
$font-display: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
5+
$font-body: var(--theme-font-family, 'Outfit', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
66

77
$radius-xl: 28px;
88
$radius-lg: 20px;

frontend/src/components/layouts/EventHomepage/index.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import {useOrganizerTrackingPixels} from "../../../hooks/useOrganizerTrackingPix
3737
import {trackPixelEvent, hasActivePixels} from "../../../utilites/trackingPixels";
3838
import {CookieConsentBanner} from "../../common/CookieConsentBanner";
3939
import {removeTransparency} from "../../../utilites/colorHelper.ts";
40+
import {ensureHomepageFontLoaded} from "../../../utilites/fontLoader.ts";
4041
import {ShareComponent} from "../../common/ShareIcon";
4142
import {EventDateRange} from "../../common/EventDateRange";
4243
import {CalendarOptionsPopover} from "../../common/CalendarOptionsPopover";
@@ -116,6 +117,10 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
116117
const cssVars = computeThemeVariables(themeSettings);
117118
const backgroundType = themeSettings.background_type;
118119

120+
useEffect(() => {
121+
ensureHomepageFontLoaded(themeSettings.font_family);
122+
}, [themeSettings.font_family]);
123+
119124
const themeStyles = {
120125
'--event-bg-color': themeSettings.background,
121126
'--event-content-bg-color': cssVars['--theme-surface'],
@@ -127,6 +132,8 @@ const EventHomepage = ({...loaderData}: EventHomepageProps) => {
127132
'--event-accent-soft': cssVars['--theme-accent-soft'],
128133
'--event-accent-muted': cssVars['--theme-accent-muted'],
129134
'--event-border-color': cssVars['--theme-border'],
135+
'--theme-font-family': cssVars['--theme-font-family'],
136+
fontFamily: cssVars['--theme-font-family'],
130137
} as React.CSSProperties;
131138

132139
const coverImageData = eventCoverImage(event);

0 commit comments

Comments
 (0)