diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Events.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Events.razor
new file mode 100644
index 00000000..6e3da7e9
--- /dev/null
+++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/Pages/Events.razor
@@ -0,0 +1,12 @@
+@page "/events"
+
+Events
+
+
Your Events
+
+This component demonstrates showing up to 4 events that user joined.
+
+
+
+@code {
+}
diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor
new file mode 100644
index 00000000..618e6c68
--- /dev/null
+++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor
@@ -0,0 +1,47 @@
+@if (HasNoEvent)
+{
+
+
+
+ You don’t have any events you have participated in now.
+
+
+
+}
+else
+{
+
+
+
+}
+
+
+
+
+
+@code {
+ [Parameter]
+ public string? Id { get; set; }
+
+ [Parameter]
+ public string? Title { get; set; }
+
+ [Parameter]
+ public string? Summary { get; set; }
+
+ [Parameter]
+ public bool HasNoEvent { get; set; }
+}
diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor.css
new file mode 100644
index 00000000..b4b911fa
--- /dev/null
+++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventItemComponent.razor.css
@@ -0,0 +1,37 @@
+::deep .event {
+ display: flex;
+ padding: 0em;
+ flex-direction: column;
+ align-items: normal;
+ align-content: center;
+ justify-content: center;
+}
+
+::deep .no-event {
+ display: flex;
+ padding: 0em;
+ flex-direction: row;
+ align-items: center;
+ align-content: center;
+ justify-content: center;
+ background-color: transparent;
+ box-shadow: none !important;
+ border: hidden;
+}
+
+::deep .event-details-link {
+ flex-grow: 0;
+ align-self: center;
+ margin-block: 0.3em;
+}
+
+div.event-summary.card.border {
+ background-color: var(--neutral-layer-2);
+ padding: 1em;
+ padding-inline: 1.5em;
+ flex-grow: 1;
+}
+
+div.event-item-flex-wrapper {
+ flex-grow: 1;
+}
\ No newline at end of file
diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor
new file mode 100644
index 00000000..4b2b3913
--- /dev/null
+++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor
@@ -0,0 +1,78 @@
+@using AzureOpenAIProxy.PlaygroundApp.Models;
+
+
+
+
+ @if (events == null || events.Any() == false)
+ {
+
+ }
+ else
+ {
+ // Shows up to 4 events that the user currently joined.
+ @foreach (var e in events.Take(4))
+ {
+
+
+ }
+ }
+
+
+
+@code {
+ private List? events;
+
+ [Parameter]
+ public string? Id { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ // TODO: Fetch events from the API server.
+ events = await CreateEventDetailsAsync();
+ }
+
+ private async Task> CreateEventDetailsAsync()
+ {
+ return await Task.FromResult(new List
+ {
+ new EventDetails
+ {
+ EventId = Guid.NewGuid(),
+ Title = "Event 1",
+ Summary = "Summary 1",
+ MaxTokenCap = 1000,
+ DailyRequestCap = 100
+ },
+ new EventDetails
+ {
+ EventId = Guid.NewGuid(),
+ Title = "Event 2",
+ Summary = "Et iusto clita ipsum et. Amet lorem est lorem takimata et aliquyam. Aliquyam invidunt dolor erat eu sed ut sadipscing justo sed justo amet magna ea lorem ipsum exerci. Erat diam tempor imperdiet lorem duis. Amet est sanctus tempor kasd erat odio diam accumsan stet. Voluptua aliquyam magna at no vulputate justo labore labore eos stet. Dolore ut ad sadipscing sit elitr ipsum commodo nam invidunt wisi labore vero feugait sanctus sea ad et sadipscing. Possim tempor nonummy erat et no erat lorem in dolore consequat eos feugiat justo vero. Ut eirmod et duis accusam dolore est sea duis dolor et duis illum. Esse ut aliquyam placerat enim amet et labore sadipscing sed stet duo eos at consequat autem accusam lorem invidunt. Sea clita rebum eum et no dolore et sit. Liber aliquyam duo eu. Feugiat sadipscing sed eos sanctus gubergren dolore amet. Erat liber nam ea aliquam ut autem dolores magna aliquyam illum vero vulputate ut accusam est rebum. Et takimata est dolore ut elitr gubergren sanctus ipsum magna magna at sed amet dolores amet. Rebum dolore sit ea et gubergren. Dolore aliquam ipsum in at est justo justo ipsum. Ipsum nisl sea lorem.",
+ MaxTokenCap = 2000,
+ DailyRequestCap = 200,
+ },
+ new EventDetails
+ {
+ EventId = Guid.NewGuid(),
+ Title = "Event 3",
+ Summary = "Lorem ipsum dolor sit amet stet ipsum invidunt amet invidunt magna vero delenit tempor invidunt no rebum eirmod. Duo labore eu no nonumy consequat lobortis consequat consetetur ipsum et ipsum ea eirmod esse. Eirmod rebum voluptua duo et autem eirmod vero amet dolores tincidunt lorem ipsum stet dolore sed aliquyam nonumy consetetur. Rebum no invidunt justo consetetur gubergren sea luptatum ut et amet ut aliquyam lorem ipsum. Nonummy et dolor placerat sit hendrerit invidunt. Et est dolore magna et suscipit duo aliquyam sed dolore ipsum erat nonummy eirmod. Nonummy consequat et et et accusam hendrerit et dolor et. Sanctus gubergren elitr sit takimata accusam lobortis quod sit nonumy nonumy diam clita clita. Ea takimata dolor molestie duo tempor invidunt amet nobis lorem accumsan duo rebum diam ipsum dolores erat ea. Amet nulla eirmod takimata no vel in et sea lobortis ut ullamcorper sadipscing delenit duo takimata ipsum eos consectetuer. Et et ea no duis eu labore quod ipsum feugiat esse lorem clita et nibh iriure diam magna. Sit duis tempor dolore sed et no magna et dolor labore clita erat sed dolores accusam molestie clita. Quis amet eum sit magna kasd eu invidunt nihil. Labore diam erat dignissim labore ipsum qui clita vel eos. Nisl praesent amet consequat ipsum justo quod tempor sed est aliquyam labore lorem accusam diam.",
+ MaxTokenCap = 3000,
+ DailyRequestCap = 300,
+ },
+ new EventDetails
+ {
+ EventId = Guid.NewGuid(),
+ Title = "Event 4",
+ Summary = "Summary 4",
+ MaxTokenCap = 3000,
+ DailyRequestCap = 300,
+ }
+ });
+ }
+}
diff --git a/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor.css b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor.css
new file mode 100644
index 00000000..86c03a42
--- /dev/null
+++ b/src/AzureOpenAIProxy.PlaygroundApp/Components/UI/EventListComponent.razor.css
@@ -0,0 +1,7 @@
+::deep .event-list {
+ background-color: var(--neutral-layer-4);
+ padding-block: 2.0em;
+ padding-inline: 1.5em;
+ margin-top: 1rem;
+ border-radius: 8px;
+}
diff --git a/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs
new file mode 100644
index 00000000..27532ad5
--- /dev/null
+++ b/test/AzureOpenAIProxy.AppHost.Tests/PlaygroundApp/Pages/EventsPageTests.cs
@@ -0,0 +1,69 @@
+using System.Net;
+
+using AzureOpenAIProxy.AppHost.Tests.Fixtures;
+
+using FluentAssertions;
+
+namespace AzureOpenAIProxy.AppHost.Tests.PlaygroundApp.Pages;
+
+public class EventsPageTests(AspireAppHostFixture host) : IClassFixture
+{
+ [Fact]
+ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_OK()
+ {
+ // Arrange
+ using var httpClient = host.App!.CreateHttpClient("playgroundapp");
+ await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
+
+ // Act
+ var response = await httpClient.GetAsync("/events");
+
+ // Assert
+ response.StatusCode.Should().Be(HttpStatusCode.OK);
+ }
+
+ [Theory]
+ [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/css/reboot.css")]
+ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_CSS_Elements(string expected)
+ {
+ // Arrange
+ using var httpClient = host.App!.CreateHttpClient("playgroundapp");
+ await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
+
+ // Act
+ var html = await httpClient.GetStringAsync("/events");
+
+ // Assert
+ html.Should().Contain(expected);
+ }
+
+ [Theory]
+ [InlineData("_content/Microsoft.FluentUI.AspNetCore.Components/Microsoft.FluentUI.AspNetCore.Components.lib.module.js")]
+ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_JavaScript_Elements(string expected)
+ {
+ // Arrange
+ using var httpClient = host.App!.CreateHttpClient("playgroundapp");
+ await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
+
+ // Act
+ var html = await httpClient.GetStringAsync("/events");
+
+ // Assert
+ html.Should().Contain(expected);
+ }
+
+ [Theory]
+ [InlineData("")]
+ public async Task Given_Resource_When_Invoked_Endpoint_Then_It_Should_Return_HTML_Elements(string expected)
+ {
+ // Arrange
+ using var httpClient = host.App!.CreateHttpClient("playgroundapp");
+ await host.ResourceNotificationService.WaitForResourceAsync("playgroundapp", KnownResourceStates.Running).WaitAsync(TimeSpan.FromSeconds(30));
+
+ // Act
+ var html = await httpClient.GetStringAsync("/events");
+
+ // Assert
+ html.Should().Contain(expected);
+ }
+}
\ No newline at end of file
diff --git a/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/EventsPageTests.cs b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/EventsPageTests.cs
new file mode 100644
index 00000000..ad139ff7
--- /dev/null
+++ b/test/AzureOpenAIProxy.PlaygroundApp.Tests/Pages/EventsPageTests.cs
@@ -0,0 +1,93 @@
+using FluentAssertions;
+
+using Microsoft.Playwright;
+using Microsoft.Playwright.NUnit;
+
+namespace AzureOpenAIProxy.PlaygroundApp.Tests.Pages;
+
+[Parallelizable(ParallelScope.Self)]
+[TestFixture]
+[Property("Category", "Integration")]
+public class EventsPageTests : PageTest
+{
+ public override BrowserNewContextOptions ContextOptions() => new()
+ {
+ IgnoreHTTPSErrors = true,
+ };
+
+ [SetUp]
+ public async Task Init()
+ {
+ await Page.GotoAsync("https://localhost:5001/events");
+ await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
+ }
+
+ // Grid check
+ [Test]
+ public async Task Given_Events_Page_When_Navigated_Then_It_Should_Have_EventListComponent()
+ {
+ // Act
+ var eventListComponent = Page.Locator("div.event-list").First;
+
+ // Assert
+ await Expect(eventListComponent).ToBeVisibleAsync();
+ }
+
+ [Test]
+ public async Task Given_Events_When_Loaded_Then_It_Should_Have_Less_Than_Or_Equal_To_Four_EventItemComponents()
+ {
+ // Arrange
+ var eventList = Page.Locator("#user-event-list");
+ var listEvents = await eventList.Locator("div.event-list-item").AllAsync();
+
+ // Act
+ var childrenCount = listEvents.Count;
+
+ // Assert
+ Assert.That(childrenCount, Is.GreaterThan(0));
+ Assert.That(childrenCount, Is.LessThanOrEqualTo(4));
+ }
+
+ [Test]
+ public async Task Given_Events_When_Loaded_Then_It_Should_Have_Header_And_Summary_In_The_Card()
+ {
+ // Act
+ var eventCards = await Page.Locator("div.fluent-card-minimal-style.event").AllAsync();
+
+ // Assert
+ foreach (var card in eventCards)
+ {
+ card.Should().NotBeNull();
+ // Check headers
+ var header = card.Locator("div.fluent-nav-item.event-details-link").First;
+ await Expect(header).ToBeVisibleAsync();
+
+ // Check summaries
+ var summary = card.Locator("div.event-summary.card.border").First;
+ await Expect(summary).ToBeVisibleAsync();
+ }
+ }
+
+ [Test]
+ public async Task Given_Events_When_Loaded_Then_Their_Links_Are_Enabled_To_Click()
+ {
+ // Act
+ var eventCards = await Page.Locator("div.fluent-card-minimal-style.event").AllAsync();
+
+ // Assert
+ foreach (var card in eventCards)
+ {
+ // Getting a link element.
+ var link = card.Locator("div.fluent-nav-item.event-details-link").First
+ .Locator("a.fluent-nav-link").First;
+
+ await Expect(link).ToBeEnabledAsync();
+ }
+ }
+
+ [TearDown]
+ public async Task CleanUp()
+ {
+ await Page.CloseAsync();
+ }
+}
\ No newline at end of file