summaryrefslogtreecommitdiff
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/AmbientBackgroundArtwork.svelte202
-rw-r--r--src/components/AppEventDate.svelte72
-rw-r--r--src/components/AppIcon.svelte131
-rw-r--r--src/components/AppIconRiver.svelte92
-rw-r--r--src/components/Artwork.svelte118
-rw-r--r--src/components/CollapsableContent.svelte36
-rw-r--r--src/components/EditorsChoiceBadge.svelte56
-rw-r--r--src/components/Error.svelte10
-rw-r--r--src/components/GradientOverlay.svelte23
-rw-r--r--src/components/Grid.svelte37
-rw-r--r--src/components/HoverWrapper.svelte54
-rw-r--r--src/components/LaunchNativeButton.svelte69
-rw-r--r--src/components/LinkWrapper.svelte60
-rw-r--r--src/components/Menu.svelte218
-rw-r--r--src/components/MotionArtwork.svelte152
-rw-r--r--src/components/Page.svelte68
-rw-r--r--src/components/PageModal.svelte82
-rw-r--r--src/components/PageResolver.svelte25
-rw-r--r--src/components/ProductPageArcadeBanner.svelte188
-rw-r--r--src/components/ProductPageArcadeFooter.svelte159
-rw-r--r--src/components/SFSymbol.svelte51
-rw-r--r--src/components/ShareArrowButton.svelte90
-rw-r--r--src/components/Shelf/Title.svelte112
-rw-r--r--src/components/Shelf/Wrapper.svelte81
-rw-r--r--src/components/ShelfItemLayout.svelte103
-rw-r--r--src/components/StarRating.svelte80
-rw-r--r--src/components/SystemImage.svelte52
-rw-r--r--src/components/VideoPlayer.svelte412
-rw-r--r--src/components/decorators/HlsJSDecorator.svelte67
-rw-r--r--src/components/hero/AppLockupDetail.svelte109
-rw-r--r--src/components/hero/Carousel.svelte132
-rw-r--r--src/components/hero/CarouselBackgroundPortal.svelte17
-rw-r--r--src/components/hero/Hero.svelte536
-rw-r--r--src/components/icons/AppStoreLogo.svg1
-rw-r--r--src/components/icons/AppleArcadeLogo.svg1
-rw-r--r--src/components/jet/Video.svelte66
-rw-r--r--src/components/jet/action/ExternalUrlAction.svelte52
-rw-r--r--src/components/jet/action/FlowAction.svelte41
-rw-r--r--src/components/jet/action/ShelfBasedPageScrollAction.svelte51
-rw-r--r--src/components/jet/badge/ContentRatingBadge.svelte61
-rw-r--r--src/components/jet/item/AccessibilityFeaturesItem.svelte159
-rw-r--r--src/components/jet/item/AccessibilityParagraphItem.svelte22
-rw-r--r--src/components/jet/item/Annotation/AnnotationItem.svelte17
-rw-r--r--src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte146
-rw-r--r--src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte114
-rw-r--r--src/components/jet/item/AppEventItem.svelte176
-rw-r--r--src/components/jet/item/ArcadeFooterItem.svelte83
-rw-r--r--src/components/jet/item/BannerItem.svelte37
-rw-r--r--src/components/jet/item/BrickItem.svelte300
-rw-r--r--src/components/jet/item/ContentModal.svelte39
-rw-r--r--src/components/jet/item/EditorialCardItem.svelte41
-rw-r--r--src/components/jet/item/FooterLockupItem.svelte93
-rw-r--r--src/components/jet/item/HeroCarouselItem.svelte60
-rw-r--r--src/components/jet/item/InAppPurchaseLockup.svelte74
-rw-r--r--src/components/jet/item/LargeBrickItem.svelte106
-rw-r--r--src/components/jet/item/LargeHeroBreakoutItem.svelte268
-rw-r--r--src/components/jet/item/LargeImageLockupItem.svelte130
-rw-r--r--src/components/jet/item/LargeLockupItem.svelte121
-rw-r--r--src/components/jet/item/LargeStoryCardItem.svelte38
-rw-r--r--src/components/jet/item/LinkableTextItem.svelte88
-rw-r--r--src/components/jet/item/MediumImageLockupItem.svelte118
-rw-r--r--src/components/jet/item/MediumLockupItem.svelte96
-rw-r--r--src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte304
-rw-r--r--src/components/jet/item/MediumStoryCardItem.svelte27
-rw-r--r--src/components/jet/item/MixedMediaLockupItem.svelte39
-rw-r--r--src/components/jet/item/ParagraphShelfItem.svelte21
-rw-r--r--src/components/jet/item/PosterLockupItem.svelte121
-rw-r--r--src/components/jet/item/PrivacyHeaderItem.svelte41
-rw-r--r--src/components/jet/item/PrivacyTypeItem.svelte193
-rw-r--r--src/components/jet/item/ProductBadgeItem.svelte188
-rw-r--r--src/components/jet/item/ProductCapabilityItem.svelte84
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte31
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte89
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte142
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte34
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte38
-rw-r--r--src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte50
-rw-r--r--src/components/jet/item/ProductPageLinkItem.svelte68
-rw-r--r--src/components/jet/item/ProductRatingsItem.svelte37
-rw-r--r--src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte99
-rw-r--r--src/components/jet/item/ProductReview/UserReviewItem.svelte25
-rw-r--r--src/components/jet/item/ReviewItem.svelte237
-rw-r--r--src/components/jet/item/SearchLinkItem.svelte47
-rw-r--r--src/components/jet/item/SearchResult/AppSearchResultItem.svelte392
-rw-r--r--src/components/jet/item/SmallBreakoutItem.svelte187
-rw-r--r--src/components/jet/item/SmallLockupItem.svelte110
-rw-r--r--src/components/jet/item/SmallLockupWithOrdinalItem.svelte176
-rw-r--r--src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte69
-rw-r--r--src/components/jet/item/SmallStoryCardWithArtworkItem.svelte87
-rw-r--r--src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte156
-rw-r--r--src/components/jet/item/SmallStoryCardWithMediaItem.svelte104
-rw-r--r--src/components/jet/item/SmallStoryCardWithMediaRiver.svelte118
-rw-r--r--src/components/jet/item/TitledParagraphItem.svelte175
-rw-r--r--src/components/jet/item/TrailersLockupItem.svelte51
-rw-r--r--src/components/jet/marker-shelf/ProductTopLockup.svelte463
-rw-r--r--src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte36
-rw-r--r--src/components/jet/shelf/AccessibilityFeaturesShelf.svelte35
-rw-r--r--src/components/jet/shelf/AccessibilityHeaderShelf.svelte182
-rw-r--r--src/components/jet/shelf/ActionShelf.svelte80
-rw-r--r--src/components/jet/shelf/AnnotationShelf.svelte49
-rw-r--r--src/components/jet/shelf/AppEventDetailShelf.svelte290
-rw-r--r--src/components/jet/shelf/AppPromotionShelf.svelte47
-rw-r--r--src/components/jet/shelf/AppShowcaseShelf.svelte29
-rw-r--r--src/components/jet/shelf/AppTrailerLockupShelf.svelte48
-rw-r--r--src/components/jet/shelf/ArcadeFooterShelf.svelte32
-rw-r--r--src/components/jet/shelf/BannerShelf.svelte35
-rw-r--r--src/components/jet/shelf/BrickShelf.svelte31
-rw-r--r--src/components/jet/shelf/CategoryBrickShelf.svelte28
-rw-r--r--src/components/jet/shelf/EditorialCardShelf.svelte32
-rw-r--r--src/components/jet/shelf/EditorialLinkShelf.svelte122
-rw-r--r--src/components/jet/shelf/FallbackShelf.svelte39
-rw-r--r--src/components/jet/shelf/FramedArtworkShelf.svelte98
-rw-r--r--src/components/jet/shelf/FramedVideoShelf.svelte78
-rw-r--r--src/components/jet/shelf/HeroCarouselShelf.svelte38
-rw-r--r--src/components/jet/shelf/HorizontalRuleShelf.svelte54
-rw-r--r--src/components/jet/shelf/HorizontalShelf.svelte53
-rw-r--r--src/components/jet/shelf/InAppPurchaseLockupShelf.svelte31
-rw-r--r--src/components/jet/shelf/LargeBrickShelf.svelte26
-rw-r--r--src/components/jet/shelf/LargeHeroBreakoutShelf.svelte31
-rw-r--r--src/components/jet/shelf/LargeImageLockupShelf.svelte30
-rw-r--r--src/components/jet/shelf/LargeLockupShelf.svelte28
-rw-r--r--src/components/jet/shelf/LargeStoryCardShelf.svelte32
-rw-r--r--src/components/jet/shelf/LinkableTextShelf.svelte43
-rw-r--r--src/components/jet/shelf/MarkerShelf.svelte36
-rw-r--r--src/components/jet/shelf/MediumImageLockupShelf.svelte28
-rw-r--r--src/components/jet/shelf/MediumLockupShelf.svelte31
-rw-r--r--src/components/jet/shelf/MediumStoryCardShelf.svelte31
-rw-r--r--src/components/jet/shelf/PageHeaderShelf.svelte34
-rw-r--r--src/components/jet/shelf/ParagraphShelf.svelte52
-rw-r--r--src/components/jet/shelf/PosterLockupShelf.svelte31
-rw-r--r--src/components/jet/shelf/PrivacyFooterShelf.svelte40
-rw-r--r--src/components/jet/shelf/PrivacyHeaderShelf.svelte145
-rw-r--r--src/components/jet/shelf/PrivacyTypeShelf.svelte29
-rw-r--r--src/components/jet/shelf/ProductBadgeShelf.svelte59
-rw-r--r--src/components/jet/shelf/ProductCapabilityShelf.svelte31
-rw-r--r--src/components/jet/shelf/ProductDescriptionShelf.svelte95
-rw-r--r--src/components/jet/shelf/ProductMediaShelf.svelte269
-rw-r--r--src/components/jet/shelf/ProductPageLinkShelf.svelte59
-rw-r--r--src/components/jet/shelf/ProductRatingsShelf.svelte29
-rw-r--r--src/components/jet/shelf/ProductReviewShelf.svelte38
-rw-r--r--src/components/jet/shelf/QuoteShelf.svelte80
-rw-r--r--src/components/jet/shelf/ReviewsContainerShelf.svelte84
-rw-r--r--src/components/jet/shelf/ReviewsShelf.svelte28
-rw-r--r--src/components/jet/shelf/RibbonBarShelf.svelte135
-rw-r--r--src/components/jet/shelf/SearchLinkShelf.svelte26
-rw-r--r--src/components/jet/shelf/SearchResultShelf.svelte49
-rw-r--r--src/components/jet/shelf/Shelf.svelte320
-rw-r--r--src/components/jet/shelf/SmallBreakoutShelf.svelte32
-rw-r--r--src/components/jet/shelf/SmallBrickShelf.svelte26
-rw-r--r--src/components/jet/shelf/SmallLockupShelf.svelte54
-rw-r--r--src/components/jet/shelf/SmallStoryCardShelf.svelte66
-rw-r--r--src/components/jet/shelf/TitledParagraphShelf.svelte118
-rw-r--r--src/components/jet/shelf/TodayCardShelf.svelte187
-rw-r--r--src/components/jet/shelf/UberShelf.svelte40
-rw-r--r--src/components/jet/today-card/TodayCard.svelte401
-rw-r--r--src/components/jet/today-card/TodayCardMedia.svelte49
-rw-r--r--src/components/jet/today-card/TodayCardOverlay.svelte48
-rw-r--r--src/components/jet/today-card/background-color-utils.ts54
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte78
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte62
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte41
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaList.svelte86
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaRiver.svelte78
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaVideo.svelte72
-rw-r--r--src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte100
-rw-r--r--src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte42
-rw-r--r--src/components/jet/web-navigation/CategoryTabItem.svelte67
-rw-r--r--src/components/jet/web-navigation/PlatformSelectorDropdown.svelte88
-rw-r--r--src/components/jet/web-navigation/PlatformSelectorItem.svelte97
-rw-r--r--src/components/navigation/Navigation.svelte423
-rw-r--r--src/components/navigation/SearchInput.svelte82
-rw-r--r--src/components/navigation/Skeleton.svelte85
-rw-r--r--src/components/navigation/navigation-items.ts79
-rw-r--r--src/components/pages/AppEventDetailPage.svelte44
-rw-r--r--src/components/pages/ArticlePage.svelte141
-rw-r--r--src/components/pages/ChartsHubPage.svelte11
-rw-r--r--src/components/pages/DefaultPage.svelte173
-rw-r--r--src/components/pages/ErrorPage.svelte23
-rw-r--r--src/components/pages/ProductPage.svelte77
-rw-r--r--src/components/pages/SearchLandingPage.svelte33
-rw-r--r--src/components/pages/SearchResultsPage.svelte113
-rw-r--r--src/components/pages/SeeAllPage.svelte56
-rw-r--r--src/components/pages/StaticMessagePage.svelte113
-rw-r--r--src/components/pages/TodayPage.svelte22
-rw-r--r--src/components/pages/TopChartsPage.svelte218
-rw-r--r--src/components/pages/VisionProPage.svelte12
-rw-r--r--src/components/structure/Fonts.svelte19
-rw-r--r--src/components/structure/Footer.svelte47
-rw-r--r--src/components/structure/MetaTags.svelte68
-rw-r--r--src/components/structure/VisionProFooter.svelte142
190 files changed, 17579 insertions, 0 deletions
diff --git a/src/components/AmbientBackgroundArtwork.svelte b/src/components/AmbientBackgroundArtwork.svelte
new file mode 100644
index 0000000..bc9563c
--- /dev/null
+++ b/src/components/AmbientBackgroundArtwork.svelte
@@ -0,0 +1,202 @@
+<script lang="ts">
+ import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import ResizeDetector from '@amp/web-app-components/src/components/helpers/ResizeDetector.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let artwork: JetArtworkType;
+ export let active: boolean = false;
+
+ $: isBackgroundImageLoaded = false;
+ $: backgroundImage = artwork
+ ? buildSrc(
+ artwork.template,
+ {
+ crop: 'sr',
+ width: 400,
+ height: Math.floor(400 / 1.6667),
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+
+ $: if (backgroundImage) {
+ const img = new Image();
+ img.onload = () => (isBackgroundImageLoaded = true);
+ img.src = backgroundImage;
+ }
+
+ let resizing = false;
+ const handleResizeUpdate = (e: CustomEvent<{ isResizing: boolean }>) =>
+ (resizing = e.detail.isResizing);
+
+ let isOutOfView = true;
+ const handleIntersectionOberserverUpdate = (
+ isIntersectingViewport: boolean,
+ ) => (isOutOfView = !isIntersectingViewport);
+</script>
+
+{#if backgroundImage}
+ <ResizeDetector on:resizeUpdate={handleResizeUpdate} />
+
+ <div
+ class="container"
+ class:active
+ class:resizing
+ class:loaded={isBackgroundImageLoaded}
+ class:out-of-view={isOutOfView}
+ style:--background-image={`url(${backgroundImage})`}
+ style:--background-color={artwork.backgroundColor &&
+ colorAsString(artwork.backgroundColor)}
+ use:intersectionObserver={{
+ callback: handleIntersectionOberserverUpdate,
+ threshold: 0,
+ }}
+ >
+ <div class="overlay" />
+ </div>
+{/if}
+
+<style>
+ .container {
+ --veil: rgb(240, 240, 240, 0.65);
+ --speed: 0.66s;
+ --aspect-ratio: 16/9;
+ --scale: 1.2;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ aspect-ratio: var(--aspect-ratio);
+ max-height: 900px;
+ opacity: 0;
+
+ /*
+ This stack of background images represents the following three layers, listed front-to-back:
+
+ 1) A gradient from transparent to white that acts as a mask for the entire container.
+ `mask-image` caused too much thrashing and CPU usage when animating and resizing,
+ so we are mimicking its functionality with this top-layer background image.
+ 2) A semi-transparent veil to evenly fade out the bg. Note that this is not technically
+ a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
+ used in `background-image`.
+ 3) The joe color of the background image that will eventualy be loaded.
+ */
+ background-image: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0) 50%,
+ var(--pageBg) 80%
+ ),
+ linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
+ linear-gradient(
+ 0deg,
+ var(--background-color) 0%,
+ var(--background-color) 80%
+ );
+ background-position: center;
+ background-size: 120%;
+
+ /*
+ Blurring via the CSS filter does not extend edge-to-edge of the contents width, but we
+ can mitigate that by ever-so-slightly bumping up the `scale` of content so it bleeds off
+ the page cleanly.
+ */
+ filter: blur(20px) saturate(1.3);
+ transform: scale(var(--scale));
+ transition: opacity calc(var(--speed) * 2) ease-out,
+ background-size var(--speed) ease-in;
+
+ @media (prefers-color-scheme: dark) {
+ --veil: rgba(0, 0, 0, 0.5);
+ }
+ }
+
+ .container.loaded {
+ /*
+ This stack of background images represents the following three layers, listed front-to-back:
+
+ 1) A gradient from transparent to white that acts as a mask for the entire container.
+ `mask-image` caused too much thrashing and CPU usage when animating and resizing,
+ so we are mimicking its functionality with this top-layer background image.
+ 2) A semi-transparent veil to evenly fade out the image. Note that this is not technically
+ a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
+ used in `background-image`.
+ 3) The actual background image.
+ */
+ background-image: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0) 50%,
+ var(--pageBg) 80%
+ ),
+ linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
+ var(--background-image);
+ }
+
+ .container.active {
+ opacity: 1;
+ transition: opacity calc(var(--speed) / 2) ease-in;
+ background-size: 100%;
+ }
+
+ .overlay {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ left: 0;
+ width: 100%;
+ aspect-ratio: var(--aspect-ratio);
+ max-height: 900px;
+ opacity: 0;
+ background-image: var(--background-image);
+ background-position: 100% 100%;
+ background-size: 250%;
+ filter: brightness(1.3) saturate(0);
+ mix-blend-mode: overlay;
+ will-change: opacity, background-position;
+ animation: shift-background 60s infinite linear alternate;
+ animation-play-state: paused;
+ transition: opacity var(--speed) ease-in;
+ }
+
+ .active .overlay {
+ opacity: 0.3;
+ animation-play-state: running;
+ transition: opacity calc(var(--speed) * 2) ease-in
+ calc(var(--speed) * 2);
+ }
+
+ .active.out-of-view .overlay,
+ .active.resizing .overlay {
+ animation-play-state: paused;
+ opacity: 0;
+ }
+
+ @keyframes shift-background {
+ 0% {
+ background-position: 0% 50%;
+ background-size: 250%;
+ }
+
+ 25% {
+ background-position: 60% 20%;
+ background-size: 300%;
+ }
+
+ 50% {
+ background-position: 100% 50%;
+ background-size: 320%;
+ }
+
+ 75% {
+ background-position: 40% 100%;
+ background-size: 220%;
+ }
+
+ 100% {
+ background-position: 20% 50%;
+ background-size: 300%;
+ }
+ }
+</style>
diff --git a/src/components/AppEventDate.svelte b/src/components/AppEventDate.svelte
new file mode 100644
index 0000000..41ee248
--- /dev/null
+++ b/src/components/AppEventDate.svelte
@@ -0,0 +1,72 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { fade } from 'svelte/transition';
+ import type { Optional } from '@jet/environment/types/optional';
+ import type { AppEvent } from '@jet-app/app-store/api/models';
+ import { getJet } from '~/jet';
+ import {
+ chooseAppEventDate,
+ renderDate,
+ computeAppEventFormattedDates,
+ type RequiredAppEventFormattedDate,
+ } from '~/jet/utils/app-event-formatted-date';
+
+ const jet = getJet();
+
+ /**
+ * New pattern (*prefered*): accept appEvent object and compute formattedDates on client-side.
+ * This avoids timezone differences in SSR server (UTC) which cause incorrect event date and time.
+ * By computing dates in the browser, we ensure the user sees dates in their local timezone.
+ */
+ export let appEvent:
+ | Pick<AppEvent, 'appEventBadgeKind' | 'startDate' | 'endDate'>
+ | undefined = undefined;
+
+ // Legacy pattern: accept pre-computed formattedDates from Jet
+ export let formattedDates: RequiredAppEventFormattedDate[] | undefined =
+ undefined;
+
+ let appEventDate: Optional<RequiredAppEventFormattedDate>;
+
+ onMount(() => {
+ const dates = appEvent
+ ? computeAppEventFormattedDates(
+ jet.objectGraph,
+ appEvent.appEventBadgeKind,
+ appEvent.startDate,
+ appEvent.endDate,
+ )
+ : formattedDates;
+
+ if (dates) {
+ appEventDate = chooseAppEventDate(dates);
+ }
+ });
+
+ /**
+ * `Date` instances in the view-model will have been serialized to `string`
+ * instances by ServerKit when delivered to the client; we need to normalize
+ * this so that we have a `string` both client- and server-side.
+ */
+ function normalizeDate(date: Date | string): string {
+ return typeof date === 'string' ? date : date.toISOString();
+ }
+</script>
+
+{#if appEventDate}
+ <time
+ transition:fade={{ duration: 210 }}
+ datetime={appEventDate.displayFromDate &&
+ normalizeDate(appEventDate.displayFromDate)}
+ >
+ {renderDate(jet.objectGraph.loc, appEventDate)}
+ </time>
+{:else}
+ <span aria-hidden="true">&hellip;</span>
+{/if}
+
+<style>
+ span {
+ color: transparent;
+ }
+</style>
diff --git a/src/components/AppIcon.svelte b/src/components/AppIcon.svelte
new file mode 100644
index 0000000..4cb0262
--- /dev/null
+++ b/src/components/AppIcon.svelte
@@ -0,0 +1,131 @@
+<script lang="ts" context="module">
+ import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
+ import type { NamedProfile } from '~/config/components/artwork';
+
+ export type AppIconProfile = Extract<
+ NamedProfile,
+ | 'app-icon'
+ | 'app-icon-large'
+ | 'app-icon-medium'
+ | 'app-icon-small'
+ | 'app-icon-xlarge'
+ | 'app-icon-river'
+ | 'brick-app-icon'
+ >;
+
+ export function doesAppIconNeedBorder(icon: JetArtworkType): boolean {
+ const doesIconHaveTransparentBackground =
+ icon.backgroundColor &&
+ isNamedColor(icon.backgroundColor) &&
+ icon.backgroundColor.name === 'clear';
+ const isIconPrerendered =
+ icon.style === 'roundedRectPrerendered' ||
+ icon.style === 'roundPrerendered';
+ const isIconUnadorned = icon.style === 'unadorned';
+
+ return (
+ !doesIconHaveTransparentBackground &&
+ !isIconPrerendered &&
+ !isIconUnadorned
+ );
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
+ import { isNamedColor } from '~/utils/color';
+
+ export let icon: JetArtworkType;
+ export let profile: AppIconProfile = 'app-icon';
+ export let fixedWidth: boolean = true;
+ export let disableAutoCenter: boolean = false;
+ export let withBorder: boolean = false;
+
+ const profiles = ArtworkConfig.get().PROFILES;
+
+ $: computedProfile = (
+ icon.style === 'pill'
+ ? `${profile}-pill`
+ : icon.style === 'tvRect'
+ ? `${profile}-tv-rect`
+ : profile
+ ) as NamedProfile;
+ $: widthFromProfile = profiles?.get(computedProfile)?.[0] ?? 0;
+ $: hasTransparentBackground =
+ !!icon.backgroundColor &&
+ isNamedColor(icon.backgroundColor) &&
+ icon.backgroundColor.name === 'clear';
+ $: needsBorder = withBorder || doesAppIconNeedBorder(icon);
+
+ // These prerendered "Solarium" icons need to use higher than normal quality due to how their
+ // rendering pipeline downscales/transforms sources.
+ $: quality =
+ icon.style &&
+ ['roundedRectPrerendered', 'roundPrerendered'].includes(icon.style)
+ ? 75
+ : undefined;
+</script>
+
+<div
+ class="app-icon"
+ class:pill={icon.style === 'pill'}
+ class:round={icon.style === 'round'}
+ class:rounded-rect={icon.style === 'roundedRect'}
+ class:tv-rect={icon.style === 'tvRect'}
+ class:rounded-rect-prerendered={icon.style === 'roundedRectPrerendered'}
+ class:round-prerendered={icon.style === 'roundPrerendered'}
+ class:with-border={needsBorder}
+ style={fixedWidth ? `--profileWidth: ${widthFromProfile}px` : ''}
+>
+ <Artwork
+ {disableAutoCenter}
+ {hasTransparentBackground}
+ {quality}
+ artwork={icon}
+ profile={computedProfile}
+ noShelfChevronAnchor={true}
+ />
+</div>
+
+<style>
+ .app-icon {
+ aspect-ratio: 1 / 1;
+ min-width: var(--profileWidth, auto);
+ }
+
+ .app-icon.pill {
+ aspect-ratio: 4 / 3;
+
+ /*
+ Creates elliptical corners with horizontal radii at 50% of the width and vertical radii
+ at 65% of the height, for a rounded, squished, pill-like effect
+ */
+ border-radius: 50% 50% 50% 50% / 65% 65% 65% 65%;
+ }
+
+ .app-icon.round {
+ border-radius: 50%;
+ }
+
+ .app-icon.rounded-rect {
+ border-radius: 23%;
+ }
+
+ .app-icon.tv-rect {
+ aspect-ratio: 16/9;
+ border-radius: 9% / 16%;
+ }
+
+ .app-icon.rounded-rect-prerendered {
+ border-radius: 25%;
+ }
+
+ .app-icon.round-prerendered {
+ border-radius: 50%;
+ }
+
+ .app-icon.with-border {
+ box-shadow: 0 0 0 1px var(--systemQuaternary);
+ }
+</style>
diff --git a/src/components/AppIconRiver.svelte b/src/components/AppIconRiver.svelte
new file mode 100644
index 0000000..b673dd0
--- /dev/null
+++ b/src/components/AppIconRiver.svelte
@@ -0,0 +1,92 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import type { Artwork } from '@jet-app/app-store/api/models';
+ import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte';
+
+ export let icons: Artwork[];
+ export let profile: AppIconProfile = 'app-icon-river';
+
+ $: aspectRatio = icons[0].width / icons[0].height;
+
+ let mounted = false;
+ const numberOfIcons = icons.length;
+
+ // We shift the order of the bottom row of icons to ensure that the same icons aren't shown
+ // next to each other. Note that this is different from purely shuffling the icons, as that
+ // could still lead to the same icons being next to one another, due to how small the set is.
+ // The input and output here is as such:
+ // in = [1, 2, 3, 4, 5, 6, 7]
+ // out = [4, 5, 6, 7, 1, 2, 3]
+ const iconsInShiftedOrder = [
+ ...icons.slice(numberOfIcons / 2),
+ ...icons.slice(0, numberOfIcons / 2),
+ ];
+
+ // We are quadrupling the icons we render so the flow is seamless and stretches across the
+ // full width of the container.
+ const topRow = Array(4).fill(icons).flat();
+ const bottomRow = Array(4).fill(iconsInShiftedOrder).flat();
+
+ // We use this `mounted` flag to defer the rendering of the `AppIconRiver`, since it's markup heavy
+ // and has no semantic meaning for SEO. This deferring saves about 190kb of initial HTML per instance.
+ onMount(() => (mounted = true));
+</script>
+
+{#if mounted}
+ {#each [topRow, bottomRow] as iconRow}
+ <ul class="app-icons">
+ {#each iconRow as icon}
+ <li
+ class="app-icon-container"
+ style:--aspect-ratio={aspectRatio}
+ >
+ <AppIcon {icon} {profile} fixedWidth={false} />
+ </li>
+ {/each}
+ </ul>
+ {/each}
+{/if}
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .app-icons {
+ --icon-width: var(--app-icon-river-icon-width, 128px);
+ --speed: var(--app-icon-river-speed, 240s);
+ --direction: -50%;
+
+ @include rtl {
+ --direction: 50%;
+ }
+ display: flex;
+ width: fit-content;
+ z-index: 2;
+ animation: scroll var(--speed) linear infinite;
+ }
+
+ .app-icons:last-of-type {
+ margin-bottom: 20px;
+ }
+
+ .app-icon-container {
+ width: var(--icon-width);
+ aspect-ratio: var(--aspect-ratio);
+ margin: 8px;
+ }
+
+ .app-icons:last-of-type .app-icon-container {
+ position: relative;
+ right: calc((var(--icon-width) / 2) + 8px);
+ }
+
+ @keyframes scroll {
+ 0% {
+ transform: translateX(0);
+ }
+
+ 100% {
+ transform: translateX(var(--direction));
+ }
+ }
+</style>
diff --git a/src/components/Artwork.svelte b/src/components/Artwork.svelte
new file mode 100644
index 0000000..04de1d4
--- /dev/null
+++ b/src/components/Artwork.svelte
@@ -0,0 +1,118 @@
+<script lang="ts" context="module">
+ import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
+ import type {
+ Artwork as ComponentArtworkType,
+ Profile as ArtworkProfile,
+ CropCode,
+ ImageSizes,
+ } from '@amp/web-app-components/src/components/Artwork/types';
+
+ import type { NamedProfile } from '~/config/components/artwork';
+
+ /**
+ * Creates a {@linkcode Profile} on-the-fly based on the properties of
+ * the {@linkcode artwork}
+ */
+ export function getNaturalProfile(
+ artwork: JetArtworkType,
+ imageSizes: ImageSizes = [artwork.width],
+ ): ArtworkProfile {
+ const aspectRatio = artwork.width / artwork.height;
+
+ return [imageSizes, aspectRatio, artwork.crop as CropCode];
+ }
+
+ export type Profile = NamedProfile | ArtworkProfile;
+</script>
+
+<script lang="ts">
+ import type { ImageSettings } from '@amp/web-app-components/src/components/Artwork/types';
+ import Artwork from '@amp/web-app-components/src/components/Artwork/Artwork.svelte';
+ import { colorAsString, isNamedColor } from '~/utils/color';
+
+ import {
+ ArtworkConfig,
+ type ArtworkProfileMap,
+ } from '@amp/web-app-components/config/components/artwork';
+
+ export let artwork: JetArtworkType;
+ export let profile: Profile;
+ export let alt: string = '';
+ export let topRoundedSecondary: boolean = false;
+ export let useContainerStyle: boolean = false;
+ export let forceFullWidth: boolean = true;
+ export let isDecorative: boolean = true;
+ export let lazyLoad: boolean = true;
+ export let disableAutoCenter: boolean = false;
+ export let noShelfChevronAnchor: boolean = false;
+ export let forceCropCode: boolean = false;
+ export let quality: number | undefined = undefined;
+ export let hasTransparentBackground: boolean =
+ !!artwork.backgroundColor &&
+ isNamedColor(artwork.backgroundColor) &&
+ artwork.backgroundColor.name === 'clear';
+ export let useCropCodeFromArtwork: boolean = true;
+ export let withoutBorder: boolean = false;
+
+ let imageSettings: ImageSettings;
+ $: imageSettings = {
+ forceCropCode,
+ hasTransparentBackground,
+ quality,
+ };
+
+ let PROFILES: ArtworkProfileMap<string> | undefined;
+ let computedProfileAttributes: Profile | undefined;
+
+ $: {
+ const config = ArtworkConfig?.get();
+ PROFILES = config?.PROFILES;
+
+ const defaultProfileAttributes: Profile | undefined =
+ typeof profile === 'string' ? PROFILES?.get(profile) : profile;
+
+ const cropCodeIndex = 2;
+
+ if (
+ useCropCodeFromArtwork &&
+ artwork?.crop &&
+ defaultProfileAttributes
+ ) {
+ computedProfileAttributes = [...defaultProfileAttributes];
+ computedProfileAttributes[cropCodeIndex] =
+ artwork?.crop as CropCode;
+ }
+ }
+
+ $: artworkForComponent = {
+ ...artwork,
+ backgroundColor: artwork.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : undefined,
+ } satisfies ComponentArtworkType;
+</script>
+
+<Artwork
+ artwork={artworkForComponent}
+ profile={computedProfileAttributes || profile}
+ {topRoundedSecondary}
+ {useContainerStyle}
+ {forceFullWidth}
+ {imageSettings}
+ {alt}
+ {isDecorative}
+ {lazyLoad}
+ {disableAutoCenter}
+ {noShelfChevronAnchor}
+ {withoutBorder}
+/>
+
+<style>
+ /* When a user enables the "Smart Invert" accessibility setting, images should not be inverted,
+ so we are re-inverting back to their normal state in this media query, which only currently works for Safari. */
+ @media (inverted-colors: inverted) {
+ :global(.artwork-component img) {
+ filter: invert(1);
+ }
+ }
+</style>
diff --git a/src/components/CollapsableContent.svelte b/src/components/CollapsableContent.svelte
new file mode 100644
index 0000000..e75fbf1
--- /dev/null
+++ b/src/components/CollapsableContent.svelte
@@ -0,0 +1,36 @@
+<script lang="ts">
+ import ChevronDown from '~/sf-symbols/chevron.down.svg';
+</script>
+
+<details>
+ <summary>
+ <slot name="summary" />
+ <ChevronDown />
+ </summary>
+
+ <slot />
+</details>
+
+<style>
+ details[open] summary {
+ display: none;
+ }
+
+ summary {
+ list-style: none;
+ cursor: pointer;
+ }
+
+ summary::-webkit-details-marker {
+ display: none;
+ }
+
+ summary :global(svg) {
+ overflow: visible;
+ width: 14px;
+ fill: var(--systemTertiary);
+ position: relative;
+ top: 3px;
+ left: 2px;
+ }
+</style>
diff --git a/src/components/EditorsChoiceBadge.svelte b/src/components/EditorsChoiceBadge.svelte
new file mode 100644
index 0000000..2c4efe1
--- /dev/null
+++ b/src/components/EditorsChoiceBadge.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ import LaurelIcon from '~/sf-symbols/laurel.left.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+</script>
+
+<h4>
+ <span class="icon-container left" aria-hidden="true">
+ <LaurelIcon />
+ </span>
+ {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
+ <span class="icon-container right" aria-hidden="true">
+ <LaurelIcon />
+ </span>
+</h4>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ h4 {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ margin-bottom: 10px;
+ gap: 10px;
+ font: var(--font, var(--title-1-emphasized));
+ color: var(--systemSecondary);
+ }
+
+ .icon-container.right {
+ transform: rotateY(180deg);
+
+ @include rtl {
+ transform: rotateY(0);
+ }
+ }
+
+ .icon-container.left {
+ @include rtl {
+ transform: rotateY(180deg);
+ }
+ }
+
+ .icon-container :global(svg) {
+ overflow: visible;
+ height: 42px;
+ transform: translateY(3px);
+ }
+
+ .icon-container :global(svg path) {
+ fill: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/Error.svelte b/src/components/Error.svelte
new file mode 100644
index 0000000..a0aeba1
--- /dev/null
+++ b/src/components/Error.svelte
@@ -0,0 +1,10 @@
+<script lang="ts">
+ import ErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let error: Error;
+
+ const i18n = getI18n();
+</script>
+
+<ErrorPage translateFn={$i18n.t} {error} />
diff --git a/src/components/GradientOverlay.svelte b/src/components/GradientOverlay.svelte
new file mode 100644
index 0000000..5827a2c
--- /dev/null
+++ b/src/components/GradientOverlay.svelte
@@ -0,0 +1,23 @@
+<script lang="ts">
+ export let shouldDarken: boolean = true;
+</script>
+
+<div class="gradient-overlay" style:--brightness={shouldDarken ? 0.85 : 1} />
+
+<style>
+ .gradient-overlay {
+ position: absolute;
+ z-index: 1;
+ bottom: 0;
+ width: 100%;
+ height: var(--height, 60%);
+ border-radius: var(--border-radius, var(--global-border-radius-large));
+ background: linear-gradient(
+ transparent,
+ var(--color, var(--systemSecondary-onLight)) var(--height, 100%)
+ );
+ backdrop-filter: blur(10px);
+ filter: saturate(1.5) brightness(var(--brightness));
+ mask-image: linear-gradient(180deg, transparent 6%, rgb(0, 0, 0.5) 85%);
+ }
+</style>
diff --git a/src/components/Grid.svelte b/src/components/Grid.svelte
new file mode 100644
index 0000000..df2ca74
--- /dev/null
+++ b/src/components/Grid.svelte
@@ -0,0 +1,37 @@
+<script lang="ts" generics="T">
+ import { getGridVars } from '@amp/web-app-components/src/components/Shelf/utils/getGridVars';
+ import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
+
+ export let items: T[] = [];
+ export let gridType: GridType;
+
+ $: style = getGridVars(gridType);
+</script>
+
+<ul {style} class="grid" data-test-id="grid">
+ {#each items as item}
+ <li>
+ <slot {item} />
+ </li>
+ {/each}
+</ul>
+
+<style lang="scss">
+ @mixin grid-styles-for-viewport($viewport: null) {
+ grid-template-columns: repeat(var(--grid-#{$viewport}), 1fr);
+ column-gap: var(--grid-column-gap-#{$viewport});
+ row-gap: var(--grid-row-gap-#{$viewport});
+ }
+
+ .grid {
+ display: grid;
+ width: 100%;
+ padding: 0 var(--bodyGutter);
+
+ @each $viewport in ('xsmall', 'small', 'medium', 'large', 'xlarge') {
+ @media (--range-#{$viewport}-only) {
+ @include grid-styles-for-viewport($viewport);
+ }
+ }
+ }
+</style>
diff --git a/src/components/HoverWrapper.svelte b/src/components/HoverWrapper.svelte
new file mode 100644
index 0000000..2d2742f
--- /dev/null
+++ b/src/components/HoverWrapper.svelte
@@ -0,0 +1,54 @@
+<script lang="ts">
+ export let element: keyof HTMLElementTagNameMap = 'article';
+ export let hasChin: boolean = false;
+</script>
+
+<svelte:element this={element} class="hover-wrapper" class:has-chin={hasChin}>
+ <slot />
+</svelte:element>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/mixins/scrim-opacity-controller' as *;
+ @use 'amp/stylekit/core/mixins/hover-style' as *;
+
+ .hover-wrapper {
+ position: relative;
+ display: var(--display, flex);
+ overflow: hidden;
+ align-items: center;
+ cursor: pointer;
+ border-radius: var(--global-border-radius-large);
+ box-shadow: var(--shadow-small);
+
+ @include scrim-opacity-controller;
+ }
+
+ .hover-wrapper.has-chin,
+ .hover-wrapper.has-chin::after {
+ // For chins, we cannot use `border-raidus` due a Chrome bug with unequal radii
+ // (e.g. there is no rounding at the bottom) and mask-image. To get around that,
+ // we use clip-path to the same effect.
+ // https://issues.chromium.org/issues/40778541.
+ border-radius: unset;
+ clip-path: inset(
+ 0 0 0 0 round var(--global-border-radius-large)
+ var(--global-border-radius-large) 0 0
+ );
+ }
+
+ /* stylelint-disable order/order */
+ .hover-wrapper::after {
+ mix-blend-mode: soft-light;
+
+ @include content-container-hover-style;
+
+ // These properties are overriding those provided by `content-container-hover-style`
+ border-radius: var(--global-border-radius-large);
+ transition: opacity 210ms ease-out;
+ }
+ /* stylelint-enable order/order */
+
+ .hover-wrapper:hover::after {
+ @include scrim-opacity;
+ }
+</style>
diff --git a/src/components/LaunchNativeButton.svelte b/src/components/LaunchNativeButton.svelte
new file mode 100644
index 0000000..eb7942b
--- /dev/null
+++ b/src/components/LaunchNativeButton.svelte
@@ -0,0 +1,69 @@
+<script lang="ts">
+ import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { getJet } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+ import { launchAppOnMac } from '~/utils/launch-client';
+
+ export let url: string;
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ function handleButtonClick(event: MouseEvent) {
+ // Need to call both event.preventDefault() and event.stopPropagation()
+ // to prevent navigation to the production page on web
+ event.preventDefault();
+ event.stopPropagation();
+
+ if (url) {
+ launchAppOnMac(url);
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ targetId: 'OpenInMacAppStore',
+ targetType: 'button',
+ actionType: 'open',
+ });
+ }
+ }
+</script>
+
+<button
+ class="get-button blue"
+ aria-label={$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.AX')}
+ on:click={handleButtonClick}
+>
+ <LineClamp clamp={1}>
+ {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.Action')}
+ <span>
+ {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.App')}
+ </span>
+ </LineClamp>
+ <ArrowIcon class="external-link-arrow" aria-hidden="true" />
+</button>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ button {
+ display: inline-flex;
+ }
+
+ button span {
+ font-weight: 500;
+ }
+
+ button :global(.external-link-arrow) {
+ align-self: center;
+ width: var(--launch-native-button-arrow-size, 9px);
+ height: var(--launch-native-button-arrow-size, 9px);
+ padding-top: 1px;
+ margin-inline-start: 4px;
+ fill: var(--systemPrimary-onDark);
+
+ @include rtl {
+ transform: rotate(-90deg);
+ }
+ }
+</style>
diff --git a/src/components/LinkWrapper.svelte b/src/components/LinkWrapper.svelte
new file mode 100644
index 0000000..0e5025d
--- /dev/null
+++ b/src/components/LinkWrapper.svelte
@@ -0,0 +1,60 @@
+<!--
+@component
+Wraps a link around the provided slot contents if a valid `FlowAction` or `ExternalUrlAction` is given.
+If no valid action is provided, the contents are rendered as-is with no decoration.
+
+💡 For accessibility, this component should ideally wrap the entire visual block (e.g., `div`, `article`) so that
+screen readers and keyboard users interpret the entire element as a single link.
+
+@example
+```
+ <LinkWrapper action={item.clickAction}>
+ <article>
+ <Artwork artwork={item.artwork} />
+ {item.title}
+ </article>
+ </LinkWrapper>
+```
+-->
+<script lang="ts">
+ import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
+ import { type Opt, isSome } from '@jet/environment/types/optional';
+
+ import FlowActionComponent from '~/components/jet/action/FlowAction.svelte';
+ import { isExternalUrlAction } from '~/jet/models';
+ import ExternalUrlAction from './jet/action/ExternalUrlAction.svelte';
+ import ShelfBasedPageScrollAction, {
+ isShelfBasedPageScrollAction,
+ } from './jet/action/ShelfBasedPageScrollAction.svelte';
+
+ export let action: Opt<Action> = null;
+ export let label: Opt<string> = null;
+ export let withoutLabel: Opt<boolean> = false;
+ export let includeExternalLinkArrowIcon: boolean = true;
+</script>
+
+{#if isSome(action) && isFlowAction(action) && isSome(action.pageUrl)}
+ <FlowActionComponent
+ destination={action}
+ aria-label={withoutLabel ? null : label || action.title}
+ >
+ <slot />
+ </FlowActionComponent>
+{:else if isSome(action) && isExternalUrlAction(action)}
+ <ExternalUrlAction
+ destination={action}
+ aria-label={withoutLabel ? null : label || action.title}
+ includeArrowIcon={includeExternalLinkArrowIcon}
+ >
+ <slot />
+ </ExternalUrlAction>
+{:else if isSome(action) && isShelfBasedPageScrollAction(action)}
+ <ShelfBasedPageScrollAction
+ destination={action}
+ aria-label={withoutLabel ? null : label || action.title}
+ >
+ <slot />
+ </ShelfBasedPageScrollAction>
+{:else}
+ <slot />
+{/if}
diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte
new file mode 100644
index 0000000..8221c79
--- /dev/null
+++ b/src/components/Menu.svelte
@@ -0,0 +1,218 @@
+<script lang="ts" generics="T">
+ import { tick } from 'svelte';
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { MouseEventHandler } from 'svelte/elements';
+ import { onDestroy, onMount } from 'svelte';
+ import { generateUuid } from '@amp/web-apps-utils/src';
+ import {
+ computePosition,
+ autoUpdate,
+ offset,
+ flip,
+ shift,
+ } from '@floating-ui/dom';
+
+ export let options: T[];
+ // Allows the developer the override the floating-ui calculated offset to a fixed number
+ export let forcedXPosition: number | null = null;
+
+ export let handleShowMenu: () => void = () => {};
+
+ let isMenuOpen = false;
+
+ /**
+ * Display the menu
+ *
+ * @example
+ * <script>
+ * let menu;
+ *
+ * function showMenu() {
+ * menu.show();
+ * }
+ * <\/script>
+ *
+ * <Menu bind:this={menu} />
+ */
+ export async function show() {
+ if (!menuEl) return;
+
+ isMenuOpen = true;
+
+ // Menu position should be updated *only* after the dialog has been shown
+ updateMenuPosition();
+
+ // Focuses the first link in the dropdown after the DOM updates
+ await tick();
+ menuEl.querySelector('a')?.focus();
+
+ // When the modal is open, track viewport changes and update the menu position
+ floatingUIAutoUpdatePositionCleanupCallback = autoUpdate(
+ trigger!,
+ menuEl!,
+ updateMenuPosition,
+ );
+ }
+
+ /**
+ * Close the menu
+ *
+ * @example
+ * <script>
+ * let menu;
+ *
+ * function closeMenu() {
+ * menu.close();
+ * }
+ * <\/script>
+ *
+ * <Menu bind:this={menu} />
+ */
+ export function close() {
+ if (!menuEl) return;
+
+ isMenuOpen = false;
+ cleanUpFloatingUIAutoPosition();
+ }
+
+ function toggle() {
+ if (isMenuOpen) {
+ close();
+ } else {
+ show();
+ handleShowMenu?.();
+ }
+ }
+
+ const menuId = generateUuid();
+
+ let menuEl: HTMLUListElement | undefined;
+ let trigger: HTMLButtonElement | undefined;
+
+ function handleKeyUp(event: KeyboardEvent) {
+ if (event.key === 'Escape') {
+ close();
+ }
+ }
+
+ /**
+ * Dismiss the dialog when clicking anywhere with the dialog open
+ */
+ const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => {
+ const clickedElement = event.target as HTMLElement;
+
+ // Only close the dialog if the click is "outside" of the trigger
+ // Otherwise, it will be closed immediately
+ if (!trigger?.contains(clickedElement)) {
+ close();
+ }
+ };
+
+ /// MARK: Menu Positioning through `FloatingUI`
+
+ /**
+ * Update the position of the menu to align it with the trigger
+ */
+ async function updateMenuPosition() {
+ const { x, y } = await computePosition(trigger!, menuEl!, {
+ middleware: [
+ offset({
+ mainAxis: 10,
+ }),
+
+ flip(),
+ shift(),
+ ],
+ placement: 'bottom-end',
+ });
+
+ Object.assign(menuEl!.style, {
+ left: `${forcedXPosition || x}px`,
+ top: `${y}px`,
+ });
+ }
+
+ let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>;
+
+ /**
+ * Cleans up the `FloatingUI` auto-update listener, which should only be "active"
+ * while the menu is open
+ */
+ function cleanUpFloatingUIAutoPosition() {
+ floatingUIAutoUpdatePositionCleanupCallback?.();
+ floatingUIAutoUpdatePositionCleanupCallback = undefined;
+ }
+
+ onMount(() => {
+ // Ensures menu is hidden initially
+ if (menuEl) isMenuOpen = false;
+ });
+
+ onDestroy(function () {
+ cleanUpFloatingUIAutoPosition();
+ });
+</script>
+
+<svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} />
+
+<button
+ class="menu-trigger"
+ aria-controls={menuId}
+ aria-haspopup="menu"
+ aria-expanded={isMenuOpen}
+ bind:this={trigger}
+ on:click={toggle}
+>
+ <slot name="trigger" />
+</button>
+
+<ul
+ id={menuId}
+ hidden={!isMenuOpen}
+ tabindex="-1"
+ class="menu-popover focus-visible"
+ bind:this={menuEl}
+>
+ {#each options as option}
+ <li class="menu-item" role="presentation">
+ <slot name="option" {option} />
+ </li>
+ {/each}
+</ul>
+
+<style>
+ :root {
+ --menu-common-padding: 4px 8px;
+ }
+
+ .menu-trigger {
+ background-color: var(--menu-trigger-background-color);
+ border-radius: var(--menu-trigger-border-radius);
+ font: var(--menu-trigger-font);
+ padding: var(--menu-trigger-padding, var(--menu-common-padding));
+ }
+
+ .menu-popover {
+ background-color: var(--menu-popover-background-color, var(--pageBg));
+ padding: var(--menu-popover-padding, 0);
+ border: var(--menu-popover-border, none);
+ border-radius: var(
+ --menu-popover-border-radius,
+ var(--global-border-radius-large)
+ );
+ box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium));
+ position: absolute;
+ inset: auto;
+ z-index: var(--menu-popover-z-index, 2);
+ }
+
+ .menu-popover::backdrop {
+ background: var(--menu-popover-backdrop-background, none);
+ }
+
+ .menu-item {
+ padding: var(--menu-item-padding, var(--menu-common-padding));
+ margin: var(--menu-item-margin, 0);
+ white-space: nowrap;
+ }
+</style>
diff --git a/src/components/MotionArtwork.svelte b/src/components/MotionArtwork.svelte
new file mode 100644
index 0000000..646df26
--- /dev/null
+++ b/src/components/MotionArtwork.svelte
@@ -0,0 +1,152 @@
+<script lang="ts">
+ import { createEventDispatcher, onMount, onDestroy } from 'svelte';
+ import { loggerFor } from '@amp/web-apps-logger';
+
+ const logger = loggerFor('components/MotionArtwork');
+
+ type HLSError = {
+ type: string;
+ message: string;
+ details: string;
+ fatal: boolean;
+ handled: boolean;
+ };
+
+ type MotionArtworkError = {
+ type: string;
+ reason: string;
+ fatal: boolean;
+ error?: Error;
+ };
+
+ /** HTML `id` attribute for the <video /> element */
+ export let id: string;
+
+ /** Source URL for the video, an HLS playlist ending in .m3u8 */
+ export let src: string;
+
+ /** Poster image to show while the video is loading */
+ export let poster: string | undefined;
+
+ /** If the video should loop from end to start. */
+ export let loop: boolean = true;
+
+ /** If the audio should be muted on the video. */
+ export let muted: boolean = true;
+
+ /** If the video should be paused when initially loaded. */
+ export let paused: boolean = true;
+
+ /** The constructor to use for creating an Hls playback session. */
+ export let HLS: Window['Hls'] = window.Hls;
+
+ /** RTCReportingAgent instance for RTC reporting on video playback. */
+ export let reportingAgent: any = undefined;
+
+ /** HTMLVideoElement used by HLS.js to render the video */
+ export let videoElement: HTMLVideoElement | null = null;
+
+ /** Internal error state for the component */
+ let errorState: MotionArtworkError | undefined;
+
+ let hlsSession: Window['Hls'] | undefined;
+
+ /** Dispatcher for errors. */
+ const dispatch = createEventDispatcher<{ error: MotionArtworkError }>();
+
+ function handleError(details: MotionArtworkError) {
+ logger.error(
+ `Error playing MotionArtwork with HLS: ${details?.reason}`,
+ details?.error,
+ );
+
+ errorState = {
+ type: details.type,
+ reason: details.reason,
+ fatal: details.fatal,
+ error: details?.error,
+ };
+
+ dispatch('error', errorState);
+ }
+
+ const hlsSupported = HLS?.isSupported() ?? false;
+
+ onMount(function () {
+ if (!hlsSupported) {
+ handleError({
+ type: 'runtime',
+ reason: 'unsupported',
+ fatal: true,
+ });
+ return;
+ }
+
+ // Create a new HLS.js playback session
+ hlsSession = new HLS({
+ debug: false,
+ debugLevel: 'error',
+ enablePerformanceLogging: false,
+ nativeControlsEnabled: false,
+
+ appData: {
+ reportingAgent: reportingAgent,
+ serviceName: reportingAgent?.ServiceName,
+ },
+ });
+
+ hlsSession.on(
+ HLS.Events.ERROR,
+ function (_event: string, error: HLSError) {
+ handleError({
+ type: 'hls',
+ reason: error.message,
+ fatal: error.fatal,
+ error: error as unknown as Error,
+ });
+ },
+ );
+
+ // Direct HLS.js to the VideoElement to use and start loading the video source
+ hlsSession.attachMedia(videoElement);
+ hlsSession.loadSource(src, {
+ /* HLS.js loading options go here */
+ });
+ });
+
+ onDestroy(() => {
+ // Stop the video, release resources, and destroy the HLS context
+ hlsSession?.destroy();
+ });
+</script>
+
+{#if errorState !== undefined}
+ <slot name="error" error={errorState} {poster} />
+{:else}
+ <!-- svelte-ignore a11y-media-has-caption -->
+ <video
+ {id}
+ {loop}
+ {poster}
+ preload="none"
+ data-loop={true}
+ playsinline={true}
+ controls={false}
+ bind:this={videoElement}
+ bind:muted
+ bind:paused
+ on:play
+ on:ended
+ on:loadedmetadata
+ />
+{/if}
+
+<style>
+ video {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ object-position: center center;
+ aspect-ratio: var(--aspect-ratio);
+ }
+</style>
diff --git a/src/components/Page.svelte b/src/components/Page.svelte
new file mode 100644
index 0000000..5b44c06
--- /dev/null
+++ b/src/components/Page.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import {
+ type Page,
+ hasVisionProUrl,
+ isAppEventDetailPage,
+ isArticlePage,
+ isChartsHubPage,
+ isGenericPage,
+ isSearchLandingPage,
+ isShelfBasedProductPage,
+ isTopChartsPage,
+ isTodayPage,
+ isSearchResultsPage,
+ isStaticMessagePage,
+ isSeeAllPage,
+ isErrorPage,
+ } from '~/jet/models';
+
+ import AppEventDetailPage from './pages/AppEventDetailPage.svelte';
+ import ArticlePage from './pages/ArticlePage.svelte';
+ import ChartsHubPage from './pages/ChartsHubPage.svelte';
+ import DefaultPage from './pages/DefaultPage.svelte';
+ import ErrorPage from './pages/ErrorPage.svelte';
+ import ProductPage from './pages/ProductPage.svelte';
+ import VisionProPage from './pages/VisionProPage.svelte';
+ import StaticMessagePageComponent from './pages/StaticMessagePage.svelte';
+ import SearchLandingPage from './pages/SearchLandingPage.svelte';
+ import SearchResultsPage from './pages/SearchResultsPage.svelte';
+ import TopChartsPage from './pages/TopChartsPage.svelte';
+ import TodayPage from './pages/TodayPage.svelte';
+ import SeeAllPage from './pages/SeeAllPage.svelte';
+ import MetaTags from '~/components/structure/MetaTags.svelte';
+ import PageModal from '~/components/PageModal.svelte';
+
+ export let page: Page;
+</script>
+
+<MetaTags {page} />
+
+<PageModal />
+
+{#if isAppEventDetailPage(page)}
+ <AppEventDetailPage {page} />
+{:else if isArticlePage(page)}
+ <ArticlePage {page} />
+{:else if isChartsHubPage(page)}
+ <ChartsHubPage {page} />
+{:else if isSearchLandingPage(page)}
+ <SearchLandingPage {page} />
+{:else if isSearchResultsPage(page)}
+ <SearchResultsPage {page} />
+{:else if isShelfBasedProductPage(page)}
+ <ProductPage {page} />
+{:else if isTopChartsPage(page)}
+ <TopChartsPage {page} />
+{:else if isGenericPage(page) && hasVisionProUrl(page)}
+ <VisionProPage {page} />
+{:else if isTodayPage(page)}
+ <TodayPage {page} />
+{:else if isStaticMessagePage(page)}
+ <StaticMessagePageComponent {page} />
+{:else if isSeeAllPage(page)}
+ <SeeAllPage {page} />
+{:else if isErrorPage(page)}
+ <ErrorPage {page} />
+{:else}
+ <DefaultPage {page} />
+{/if}
diff --git a/src/components/PageModal.svelte b/src/components/PageModal.svelte
new file mode 100644
index 0000000..9e5ee50
--- /dev/null
+++ b/src/components/PageModal.svelte
@@ -0,0 +1,82 @@
+<script lang="ts">
+ import { onMount, type SvelteComponent } from 'svelte';
+ import type { GenericPage } from '@jet-app/app-store/api/models';
+
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import { getModalPageStore } from '~/stores/modalPage';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import { LICENSE_AGREEMENT_MODAL_ID } from '~/utils/metrics';
+
+ let modalElement: SvelteComponent;
+ let modalPage = getModalPageStore();
+ let page: GenericPage | undefined;
+
+ $: page = $modalPage?.page;
+ $: shelves = page?.shelves ?? [];
+ $: title = page?.title ?? null;
+ $: targetId =
+ $modalPage?.pageDetail === 'licenseAgreement'
+ ? LICENSE_AGREEMENT_MODAL_ID
+ : undefined;
+
+ onMount(() => {
+ return modalPage.clearPage;
+ });
+
+ $: {
+ if ($modalPage) {
+ modalElement?.showModal();
+ } else {
+ handleModalClose();
+ }
+ }
+
+ function handleModalClose() {
+ modalElement?.close();
+ modalPage.clearPage();
+ }
+</script>
+
+<Modal
+ modalTriggerElement={null}
+ bind:this={modalElement}
+ on:close={handleModalClose}
+>
+ <div class="modal-content">
+ {#if page}
+ <ContentModal
+ {title}
+ subtitle={null}
+ on:close={handleModalClose}
+ {targetId}
+ >
+ <svelte:fragment slot="content">
+ {#each shelves as shelf}
+ <ShelfComponent {shelf}>
+ <slot
+ name="marker-shelf"
+ slot="marker-shelf"
+ let:shelf
+ {shelf}
+ />
+ </ShelfComponent>
+ {/each}
+ </svelte:fragment>
+ </ContentModal>
+ {/if}
+ </div>
+</Modal>
+
+<style lang="scss">
+ .modal-content :global(p) {
+ user-select: text;
+ margin-bottom: 15px;
+ overflow-wrap: break-word;
+ }
+
+ :global(.noscroll) {
+ overflow: hidden;
+ touch-action: none;
+ }
+</style>
diff --git a/src/components/PageResolver.svelte b/src/components/PageResolver.svelte
new file mode 100644
index 0000000..9f482aa
--- /dev/null
+++ b/src/components/PageResolver.svelte
@@ -0,0 +1,25 @@
+<script lang="ts">
+ import type { Page } from '~/jet/models';
+
+ import PageComponent from '~/components/Page.svelte';
+ import ErrorComponent from '~/components/Error.svelte';
+ import LoadingSpinner from '@amp/web-app-components/src/components/LoadingSpinner/LoadingSpinner.svelte';
+
+ export let page: Promise<Page> | Page;
+ export let isFirstPage: boolean;
+</script>
+
+{#await page}
+ <div data-testid="page-loading">
+ <!--
+ Delay showing the spinner on initial page load after app boot.
+ After that, the FlowAction handler already waits 500ms before
+ it changes DOM, so we only need to wait 1000ms.
+ -->
+ <LoadingSpinner delay={isFirstPage ? 1500 : 1000} />
+ </div>
+{:then page}
+ <PageComponent {page} />
+{:catch error}
+ <ErrorComponent {error} />
+{/await}
diff --git a/src/components/ProductPageArcadeBanner.svelte b/src/components/ProductPageArcadeBanner.svelte
new file mode 100644
index 0000000..154c115
--- /dev/null
+++ b/src/components/ProductPageArcadeBanner.svelte
@@ -0,0 +1,188 @@
+<script lang="ts">
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+</script>
+
+<aside>
+ <div class="arcade-banner">
+ <div class="metadata-container">
+ <div class="logo-container">
+ <AppleArcadeLogo />
+ </div>
+
+ <h2>
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
+ <br />
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
+ </h2>
+
+ <a href="https://www.apple.com/apple-arcade/" target="_blank">
+ <span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
+ )}
+ </span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
+ )}
+ </a>
+ </div>
+ </div>
+</aside>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .logo-container {
+ width: 62px;
+ margin-bottom: 10px;
+ line-height: 0;
+
+ @media (--range-xsmall-only) {
+ width: 48px;
+ margin-bottom: 8px;
+ }
+ }
+
+ .logo-container :global(path) {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .metadata-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 60%;
+ height: 100%;
+ padding: 0 20px;
+
+ @media (--range-xsmall-only) {
+ align-items: flex-start;
+ justify-content: center;
+ }
+ }
+
+ h2 {
+ margin-bottom: 10px;
+ font: var(--title-1-emphasized);
+
+ @media (--range-xsmall-only) {
+ margin-bottom: 8px;
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ a {
+ display: flex;
+ font: var(--title-3-emphasized);
+
+ @media (--range-xsmall-only) {
+ font: var(--body-emphasized);
+ }
+ }
+
+ a::after {
+ content: '↗';
+ font-weight: normal;
+ margin-inline-start: 4px;
+ }
+
+ a:hover {
+ text-decoration: none;
+ }
+
+ a:hover span {
+ text-decoration: underline;
+ }
+
+ aside {
+ width: 100%;
+ max-width: calc(viewport-content-for(xlarge));
+ height: 152px;
+ margin: 0 auto 32px;
+ padding: 0 var(--bodyGutter);
+
+ @media (--range-xsmall-only) {
+ max-width: 100%;
+ padding: 0;
+ }
+ }
+
+ .arcade-banner {
+ width: 100%;
+ height: 100%;
+ color: var(--systemPrimary-onDark);
+ border-radius: var(--global-border-radius-medium);
+ background: #000;
+ background-repeat: no-repeat;
+ background-position: right;
+ background-size: contain;
+
+ @media (prefers-color-scheme: dark) {
+ border: 1px solid var(--systemQuaternary-onDark);
+ }
+
+ @media (--range-xsmall-only) {
+ border-radius: 0;
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
+ background-size: cover;
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
+ background-position: left;
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
+ background-position: left;
+ }
+ }
+ }
+
+ @media (--range-small-only) {
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
+ background-position: left;
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
+ background-position: left;
+ }
+ }
+ }
+
+ @media (--range-medium-up) {
+ background-image: url('/assets/images/arcade/upsell/banner-980@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-980@1x_RTL.png');
+ background-position: left;
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/banner-980@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/banner-980@2x_RTL.png');
+ background-position: left;
+ }
+ }
+ }
+ }
+</style>
diff --git a/src/components/ProductPageArcadeFooter.svelte b/src/components/ProductPageArcadeFooter.svelte
new file mode 100644
index 0000000..0cd9b65
--- /dev/null
+++ b/src/components/ProductPageArcadeFooter.svelte
@@ -0,0 +1,159 @@
+<script lang="ts">
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+</script>
+
+<article>
+ <div class="metadata-container">
+ <div class="logo-container">
+ <AppleArcadeLogo />
+ </div>
+
+ <h2>
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
+ <br />
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
+ <br />
+ {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
+ </h2>
+
+ <a href="https://www.apple.com/apple-arcade/" target="_blank">
+ <span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
+ )}
+ </span>
+ {$i18n.t(
+ 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
+ )}
+ </a>
+ </div>
+</article>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .logo-container {
+ width: 72px;
+ margin-bottom: 20px;
+ line-height: 0;
+
+ @media (--range-xsmall-only) {
+ width: 62px;
+ margin-bottom: 16px;
+ }
+ }
+
+ .logo-container :global(path) {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .metadata-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 60%;
+ height: 100%;
+ padding: 40px;
+
+ @media (--range-xsmall-only) {
+ align-items: center;
+ justify-content: end;
+ width: unset;
+ text-align: center;
+ }
+ }
+
+ h2 {
+ margin-bottom: 20px;
+ font: var(--header-emphasized);
+ line-height: 54px;
+
+ @media (--range-xsmall-only) {
+ font: var(--title-1-emphasized);
+ }
+ }
+
+ a {
+ display: flex;
+ font: var(--title-3-emphasized);
+ }
+
+ a::after {
+ content: '↗';
+ font-weight: normal;
+ margin-inline-start: 4px;
+ }
+
+ a:hover {
+ text-decoration: none;
+ }
+
+ a:hover span {
+ text-decoration: underline;
+ }
+
+ article {
+ flex-grow: 1;
+ width: 100%;
+ max-width: calc(viewport-content-for(xlarge) - var(--bodyGutter) * 2);
+ aspect-ratio: 2.55;
+ margin: 0 auto;
+ color: var(--systemPrimary-onDark);
+ background: #000;
+ background-size: cover;
+
+ @media (--range-xsmall-only) {
+ max-width: 338px;
+ aspect-ratio: 35/48;
+ border-radius: var(--global-border-radius-medium);
+ background-image: url('/assets/images/arcade/upsell/footer-280@1x.png');
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/footer-280@2x.png');
+ }
+ }
+
+ @media (--range-small-only) {
+ aspect-ratio: 173/96;
+ background-image: url('/assets/images/arcade/upsell/footer-692@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-692@1x_RTL.png');
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/footer-692@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-692@2x_RTL.png');
+ }
+ }
+ }
+
+ @media (--range-medium-up) {
+ background-image: url('/assets/images/arcade/upsell/footer-980@1x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-980@1x_RTL.png');
+ }
+
+ @media (resolution >= 192dpi) {
+ background-image: url('/assets/images/arcade/upsell/footer-980@2x_LTR.png');
+
+ @include rtl {
+ background-image: url('/assets/images/arcade/upsell/footer-980@2x_RTL.png');
+ }
+ }
+ }
+
+ @media (--range-xlarge-up) {
+ border-radius: var(--global-border-radius-medium);
+ }
+ }
+</style>
diff --git a/src/components/SFSymbol.svelte b/src/components/SFSymbol.svelte
new file mode 100644
index 0000000..998ab06
--- /dev/null
+++ b/src/components/SFSymbol.svelte
@@ -0,0 +1,51 @@
+<!--
+@component
+Renders a supported "SF Symbol" from the icons available in `~/sf-symbols`
+-->
+<script lang="ts" context="module">
+ import type { ComponentType } from 'svelte';
+
+ const iconComponents = import.meta.glob('~/sf-symbols/*.svg', {
+ eager: true,
+ import: 'default',
+ });
+
+ const iconNameToComponent: Record<string, ComponentType | undefined> =
+ Object.fromEntries(
+ Object.entries(iconComponents).map(
+ ([fullPathToIcon, iconComponent]) => {
+ const iconName = fullPathToIcon
+ .replace('/src/sf-symbols/', '')
+ .replace('.svg', '');
+
+ return [iconName, iconComponent as ComponentType];
+ },
+ ),
+ );
+
+ /**
+ * The list of all supported icons
+ *
+ * This is exposed only for testing/Storybook purposes
+ */
+ export const __iconNames = Object.keys(iconNameToComponent);
+
+ export function getIconComponentByName(iconName: string) {
+ return iconNameToComponent[iconName];
+ }
+</script>
+
+<script lang="ts">
+ /**
+ * The name of the SF Symbol to render
+ *
+ * Must match the name of an `.svg` file in `~/sf-symbols`. If a file with a matching
+ * name does not exist, nothing will be rendered
+ */
+ export let name: string;
+ export let ariaHidden: boolean = false;
+
+ $: icon = getIconComponentByName(name);
+</script>
+
+<svelte:component this={icon} aria-hidden={ariaHidden ? 'true' : 'false'} />
diff --git a/src/components/ShareArrowButton.svelte b/src/components/ShareArrowButton.svelte
new file mode 100644
index 0000000..7b822fc
--- /dev/null
+++ b/src/components/ShareArrowButton.svelte
@@ -0,0 +1,90 @@
+<script lang="ts" context="module">
+ export function isShareSupported() {
+ return (
+ typeof navigator !== 'undefined' &&
+ typeof navigator.share === 'function'
+ );
+ }
+</script>
+
+<script lang="ts">
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let url: string;
+ export let withLabel: boolean = false;
+
+ const i18n = getI18n();
+
+ $: isShareSheetOpen = false;
+
+ async function handleShareClick() {
+ isShareSheetOpen = !isShareSheetOpen;
+
+ try {
+ await navigator.share({ url });
+ isShareSheetOpen = false;
+ } catch {
+ isShareSheetOpen = false;
+ }
+ }
+</script>
+
+<button
+ on:click={handleShareClick}
+ aria-label={$i18n.t('ASE.Web.AppStore.Share.Button.AccessibilityValue')}
+ class:active={isShareSheetOpen}
+ class:with-label={withLabel}
+>
+ <SFSymbol name="square.and.arrow.up" ariaHidden={true} />
+
+ {#if withLabel}
+ {$i18n.t('ASE.Web.AppStore.Share.Button.Value')}
+ {/if}
+</button>
+
+<style lang="scss">
+ button {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+ width: var(--share-arrow-size, 32px);
+ height: var(--share-arrow-size, 32px);
+ border-radius: var(--share-arrow-size, 32px);
+ background: var(--systemQuaternary-onDark);
+ transition: background-color 210ms ease-out;
+ mix-blend-mode: plus-lighter;
+ }
+
+ button.with-label {
+ display: flex;
+ align-items: center;
+ width: auto;
+ padding: 0 16px;
+ gap: 8px;
+ font: var(--body-emphasized);
+
+ :global(svg) {
+ height: 16px;
+ width: auto;
+ top: -2px;
+ position: relative;
+ }
+ }
+
+ button.active,
+ button:hover {
+ // stylelint-disable color-function-notation
+ background-color: rgb(from var(--systemTertiary-onDark) r g b/.13);
+ // stylelint-enable color-function-notation
+ }
+
+ button :global(svg) {
+ width: 37%;
+ fill: var(--systemPrimary-onDark);
+ overflow: visible;
+ }
+</style>
diff --git a/src/components/Shelf/Title.svelte b/src/components/Shelf/Title.svelte
new file mode 100644
index 0000000..e68f4b1
--- /dev/null
+++ b/src/components/Shelf/Title.svelte
@@ -0,0 +1,112 @@
+<!--
+@component
+
+Renders the "Title" and "See All action" for a `Shelf`
+
+### Supported CSS Variables
+
+- `--shelf-title-font`: overrides the font used for the "title" element
+
+-->
+<script lang="ts">
+ import { type Opt, isSome } from '@jet/environment/types/optional';
+ import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
+
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import LinkWrapper from '../LinkWrapper.svelte';
+
+ export let title: string;
+ export let subtitle: Opt<string> = undefined;
+ export let seeAllAction: Opt<Action> = undefined;
+</script>
+
+<div class="title-action-wrapper" class:with-subtitle={!!subtitle}>
+ <LinkWrapper action={seeAllAction} label={title}>
+ <div class="link-contents">
+ <h2 class="shelf-title" data-test-id="shelf-title">{title}</h2>
+
+ {#if isSome(seeAllAction) && isFlowAction(seeAllAction)}
+ <span
+ class="chevron-container"
+ data-test-id="shelf-see-all-chevron"
+ aria-hidden="true"
+ >
+ <SFSymbol name="chevron.forward" />
+ </span>
+ {/if}
+ </div>
+ </LinkWrapper>
+</div>
+
+{#if subtitle}
+ <p>{subtitle}</p>
+{/if}
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .title-action-wrapper {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ margin: 0 var(--bodyGutter) 13px;
+ }
+
+ .title-action-wrapper.with-subtitle {
+ margin-bottom: 3px;
+ }
+
+ .title-action-wrapper :global(a:hover) {
+ text-decoration: none;
+ }
+
+ p {
+ font: var(--title-3-tall);
+ color: var(--systemSecondary);
+ margin: 0 var(--bodyGutter) 13px;
+ }
+
+ h2 {
+ color: var(--systemPrimary, #000);
+ font: var(--shelf-title-font, var(--title-2-emphasized));
+ text-wrap: pretty;
+ }
+
+ .link-contents {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ }
+
+ .chevron-container {
+ line-height: 0;
+ padding: 6px 0 4px;
+ display: block;
+ }
+
+ .chevron-container :global(svg) {
+ height: 12px;
+ display: block;
+ translate: 0 0;
+ transition: translate 210ms ease-out;
+
+ @include rtl {
+ transform: rotate(180deg);
+ }
+ }
+
+ .chevron-container :global(svg path:not([fill='none'])) {
+ fill: var(--systemGray2);
+ }
+
+ .link-contents:hover .chevron-container :global(svg) {
+ translate: 1px 0;
+
+ @include rtl {
+ transform: rotate(180deg);
+ translate: -1px 0;
+ }
+ }
+</style>
diff --git a/src/components/Shelf/Wrapper.svelte b/src/components/Shelf/Wrapper.svelte
new file mode 100644
index 0000000..850b0d0
--- /dev/null
+++ b/src/components/Shelf/Wrapper.svelte
@@ -0,0 +1,81 @@
+<script lang="ts">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+
+ export let shelf: Shelf | undefined = undefined;
+
+ /**
+ * Whether or not to automatically display the shelf "centered" at the normal
+ * page width for the App Store
+ *
+ * When `false`, the shelf is not constrained horizontally in any way
+ *
+ * The value of this property may be ignored when the shelf's `.presentationHints`
+ * indicate that it is being rendered in a context where "centering" would not be
+ * appropriate
+ *
+ * @default true
+ */
+ export let centered: boolean = false;
+
+ export let withTopBorder: boolean = false;
+ export let withTopMargin: boolean = false;
+ export let withPaddingTop: boolean = true;
+ export let withBottomPadding: boolean = true;
+
+ $: seeAllAction =
+ shelf?.header?.titleAction ??
+ shelf?.header?.accessoryAction ??
+ shelf?.seeAllAction;
+</script>
+
+<section
+ id={shelf?.id}
+ data-test-id="shelf-wrapper"
+ class="shelf"
+ class:centered
+ class:border-top={withTopBorder}
+ class:margin-top={withTopMargin}
+ class:padding-top={withPaddingTop}
+ class:padding-bottom={withBottomPadding}
+>
+ {#if $$slots['title']}
+ <slot name="title" />
+ {:else if shelf?.header?.title}
+ <ShelfTitle
+ title={shelf.header.title}
+ subtitle={shelf.header.subtitle}
+ {seeAllAction}
+ />
+ {:else if shelf?.title}
+ <ShelfTitle
+ title={shelf.title}
+ subtitle={shelf.subtitle}
+ {seeAllAction}
+ />
+ {/if}
+
+ <slot />
+</section>
+
+<style>
+ .padding-top {
+ padding-top: 13px;
+ }
+
+ .centered {
+ margin: 0 var(--bodyGutter);
+ }
+
+ .margin-top {
+ margin-top: 13px;
+ }
+
+ .border-top {
+ border-top: 1px solid var(--systemGray4);
+ }
+
+ .shelf.padding-bottom {
+ padding-bottom: 32px;
+ }
+</style>
diff --git a/src/components/ShelfItemLayout.svelte b/src/components/ShelfItemLayout.svelte
new file mode 100644
index 0000000..ef1d07c
--- /dev/null
+++ b/src/components/ShelfItemLayout.svelte
@@ -0,0 +1,103 @@
+<!--
+@component
+Renders a set of `Shelf` items in either a horizontal shelf
+or a grid, depending on the `shelf` configuration
+
+Note: when configuring the `gridType` property, a single value will be used
+for both the shelf-based or grid-based item layouts. If two different grid types
+are needed instead, `gridTypeForShelf` and `gridTypeForGrid` are needed instead;
+these properties cannot be used alongside the general-purpose `gridType`.
+-->
+<script lang="ts" generics="Item">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+
+ import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
+
+ import type { XOR } from '~/utils/types';
+ import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
+ import Grid from '~/components/Grid.svelte';
+
+ /**
+ * The sub-set of {@linkcode Shelf} that is necesary to render this component
+ */
+ interface RequiredShelf
+ extends Pick<Shelf, 'rowsPerColumn' | 'isHorizontal'> {
+ items: Item[];
+ }
+
+ interface $$Slots {
+ default: {
+ item: Item;
+ };
+ }
+
+ /**
+ * Represents the `gridType` properties of this component
+ *
+ * Either a `gridType` that will be used for both the shelf or grid
+ * layouts can be provided, OR specific properties for the grid type
+ * for the shelf and grid respectively; this `XOR` here prevents
+ * these approachs from being mixed-and-matched.
+ */
+ type GeneralOrIndividualGridType = XOR<
+ {
+ gridType: GridType;
+ },
+ {
+ gridTypeForGrid: GridType;
+ gridTypeForShelf: GridType;
+ }
+ >;
+
+ type $$Props = GeneralOrIndividualGridType & {
+ shelf: RequiredShelf;
+ rowsPerColumnOverride?: number | null;
+ };
+
+ /**
+ * The shelf to render items for
+ */
+ export let shelf: RequiredShelf;
+
+ /**
+ * An optional override of the shelfs `rowsPerColumn` property
+ */
+ export let rowsPerColumnOverride: number | null = null;
+
+ /**
+ * Determine the grid type configuration for the shelf or grid layouts
+ * based on the mutually-exclusive properties of {@linkcode GeneralOrIndividualGridType}
+ */
+ function extractGridTypes(props: $$Props) {
+ if (typeof props.gridType === 'string') {
+ return {
+ gridTypeForShelf: props.gridType,
+ gridTypeForGrid: props.gridType,
+ };
+ } else {
+ return props;
+ }
+ }
+
+ $: ({ gridTypeForShelf, gridTypeForGrid } = extractGridTypes(
+ $$props as $$Props,
+ ));
+
+ $: isHorizontal = shelf.isHorizontal;
+ $: gridRows = rowsPerColumnOverride ?? shelf.rowsPerColumn ?? undefined;
+</script>
+
+{#if isHorizontal}
+ <HorizontalShelf
+ items={shelf.items}
+ {gridRows}
+ gridType={gridTypeForShelf}
+ let:item
+ >
+ <slot {item} />
+ </HorizontalShelf>
+{:else}
+ <Grid items={shelf.items} gridType={gridTypeForGrid} let:item>
+ <slot {item} />
+ </Grid>
+{/if}
diff --git a/src/components/StarRating.svelte b/src/components/StarRating.svelte
new file mode 100644
index 0000000..84da44b
--- /dev/null
+++ b/src/components/StarRating.svelte
@@ -0,0 +1,80 @@
+<script lang="ts" context="module">
+ export function calculateStarFillPercentages(rating: number) {
+ return [1, 2, 3, 4, 5].map((position) => {
+ if (position <= Math.floor(rating)) {
+ return 100;
+ }
+
+ if (position > Math.ceil(rating)) {
+ return 0;
+ }
+
+ return Math.round((rating % 1) * 100);
+ });
+ }
+</script>
+
+<script lang="ts">
+ import StarFilledIcon from '@amp/web-app-components/assets/icons/star-filled.svg';
+ import StarHollowIcon from '@amp/web-app-components/assets/icons/star-hollow.svg';
+ import { getI18n } from '~/stores/i18n';
+
+ export let rating: number;
+
+ const i18n = getI18n();
+
+ $: starFillPercentages = calculateStarFillPercentages(rating);
+ $: label = $i18n.t('ASE.Web.AppStore.Review.StarsAria', {
+ count: rating,
+ });
+</script>
+
+<ol class="stars" aria-label={label}>
+ {#each starFillPercentages as fillPercent}
+ <li class="star">
+ {#if fillPercent === 100}
+ <StarFilledIcon />
+ {:else if fillPercent === 0}
+ <StarHollowIcon />
+ {:else}
+ <div
+ class="partial-star"
+ style:--partial-star-width={`${fillPercent}%`}
+ >
+ <StarFilledIcon />
+ </div>
+
+ <StarHollowIcon />
+ {/if}
+ </li>
+ {/each}
+</ol>
+
+<style>
+ .stars {
+ display: flex;
+ }
+
+ .star {
+ position: relative;
+ margin-inline-end: 2px;
+ line-height: 0;
+ }
+
+ .star :global(svg) {
+ height: var(--star-size, 10px);
+ width: var(--star-size, 10px);
+ fill: var(--fill-color, rgb(127, 127, 127));
+ }
+
+ .partial-star {
+ position: absolute;
+ overflow: hidden;
+ width: var(--partial-star-width);
+ fill: var(--fill-color, rgb(127, 127, 127));
+ }
+
+ .partial-star :global(path) {
+ stroke: transparent;
+ }
+</style>
diff --git a/src/components/SystemImage.svelte b/src/components/SystemImage.svelte
new file mode 100644
index 0000000..40723dd
--- /dev/null
+++ b/src/components/SystemImage.svelte
@@ -0,0 +1,52 @@
+<!--
+@component
+Renders an `Artwork` view model that references an SF Symbol through a `systemimage://` or `resource://` template URL
+-->
+<script lang="ts" context="module">
+ import type { Artwork } from '@jet-app/app-store/api/models';
+
+ const systemImagePrefix = 'systemimage://';
+ const resourcePrefix = 'resource://';
+
+ type SystemImageTemplate = `${typeof systemImagePrefix}${string}`;
+ type ResourceTemplate = `${typeof resourcePrefix}${string}`;
+
+ /**
+ * An {@linkcode Artwork} that references a system image
+ */
+ interface FullSystemImageArtwork extends Artwork {
+ template: SystemImageTemplate | ResourceTemplate;
+ }
+
+ /**
+ * The sub-set of {@linkcode FullSystemImageArtwork} required to render
+ * the icon
+ */
+ type SystemImageArtwork = Pick<FullSystemImageArtwork, 'template'>;
+
+ /**
+ * Determine if some {@linkcode Artwork} represents a "system image"
+ */
+ export function isSystemImageArtwork(
+ artwork: Artwork,
+ ): artwork is FullSystemImageArtwork {
+ return (
+ artwork.template.startsWith(systemImagePrefix) ||
+ artwork.template.startsWith(resourcePrefix)
+ );
+ }
+
+ export function getIconNameFromTemplate(template: string) {
+ return new URL(template).host;
+ }
+</script>
+
+<script lang="ts">
+ import SFSymbol from '~/components/SFSymbol.svelte';
+
+ export let artwork: SystemImageArtwork;
+
+ $: name = getIconNameFromTemplate(artwork.template);
+</script>
+
+<SFSymbol {name} />
diff --git a/src/components/VideoPlayer.svelte b/src/components/VideoPlayer.svelte
new file mode 100644
index 0000000..8012b9f
--- /dev/null
+++ b/src/components/VideoPlayer.svelte
@@ -0,0 +1,412 @@
+<script lang="ts">
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import MotionArtwork from '~/components/MotionArtwork.svelte';
+ import { getJet } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+ import type { Video } from '@jet-app/app-store/api/models';
+ import {
+ MetricsActionDetails,
+ MetricsActionType,
+ type MetricsActionDetailItem,
+ type MetricsActionTypeItem,
+ } from '~/constants/media-metrics';
+
+ /** HTML `id` attribute for the <video /> element */
+ export let id: string;
+
+ /** Source URL for the video, an HLS playlist ending in .m3u8 */
+ export let src: string;
+
+ /** Poster image to show while the video is loading */
+ export let poster: string | undefined;
+
+ /** If the video should play automatically when in view */
+ export let autoplay: boolean = false;
+
+ /* The whole-number percentage amount of the video needs to be in view before autoplay kicks in */
+ export let autoplayVisibilityThreshold: number = 0;
+
+ /** If the video should loop from end to start. */
+ export let loop: boolean = false;
+
+ /** If the audio should be muted on the video. */
+ export let muted: boolean = true;
+
+ /** If our controls should be shown in the video player. */
+ export let useControls: boolean = true;
+
+ /** The constructor to use for creating an Hls playback session. */
+ export let HLS: Window['Hls'] = window.Hls;
+
+ /**
+ * If we should bypass the `poster` attribute on the `video` tag, in favor of having the poster
+ * image overlaid as it's own DOM element, which covers an HLS playback bug in Safari, wherein
+ * the video is seeked to the first frame once the metadata is loaded, thus removing the poster.
+ */
+ export let shouldSuperimposePosterImage: boolean = false;
+
+ /** an optional metric template provided by jet */
+ export let metricsTemplate:
+ | Record<string, unknown>
+ | Video['templateMediaEvent'] = {};
+
+ export function play(isAutoPlay = true) {
+ videoRef?.play();
+ recordMediaEvent(
+ MetricsActionType.PLAY,
+ isAutoPlay
+ ? MetricsActionDetails.AUTOPLAY
+ : MetricsActionDetails.PLAY,
+ );
+ }
+
+ export function pause(isAutoPause = true) {
+ recordMediaEvent(
+ MetricsActionType.STOP,
+ isAutoPause
+ ? MetricsActionDetails.AUTOPAUSE
+ : MetricsActionDetails.PAUSE,
+ );
+
+ videoRef?.pause();
+ }
+
+ let isPaused: boolean = !autoplay;
+ let isMuted: boolean = muted;
+ let shouldShowReplayControl: boolean = false;
+ let shouldShowPlaybackControls: boolean = true;
+ let hasPlaybackBeenInitiated: boolean = false;
+ let videoRef: HTMLVideoElement | null = null;
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ const handleFullScreenButtonClick = () => {
+ videoRef?.requestFullscreen();
+ };
+
+ const handleReplayButtonClick = () => {
+ if (videoRef) {
+ videoRef.currentTime = 0;
+ videoRef.play();
+ shouldShowPlaybackControls = true;
+ }
+ };
+
+ const handlePlayButtonClick = () => {
+ if (isPaused) {
+ play(false);
+ } else {
+ pause(false);
+ }
+ };
+
+ const handleMuteButtonClick = () => {
+ isMuted = !isMuted;
+ };
+
+ const handleVideoEnded = () => {
+ if (!loop) {
+ shouldShowPlaybackControls = true;
+
+ if (videoRef) {
+ videoRef.currentTime = 1;
+ videoRef.pause();
+ }
+
+ recordMediaEvent(
+ MetricsActionType.STOP,
+ MetricsActionDetails.COMPLETE,
+ );
+ }
+ };
+
+ const handleVideoPlay = () => {
+ // Display the replay button after the first play
+ shouldShowReplayControl = true;
+ hasPlaybackBeenInitiated = true;
+ };
+
+ // metric events that are waiting for loadMetadata from video element
+ let queuedMetricEvents: Array<() => void> = [];
+
+ // flush any metric events once load metadata has been called
+ const flushMetricEvents = () => {
+ queuedMetricEvents.forEach((recordFn) => recordFn());
+
+ queuedMetricEvents = [];
+ };
+
+ const recordMediaEvent = (
+ actionType: MetricsActionTypeItem,
+ actionDetail: MetricsActionDetailItem,
+ ) => {
+ if (!metricsTemplate?.fields) {
+ return;
+ }
+
+ const recordEvent = () => {
+ const duration = Math.floor(videoRef?.duration ?? 0) * 1000;
+ const position = Math.min(
+ Math.floor((videoRef?.currentTime ?? 0) * 1000),
+ duration,
+ );
+ jet.recordCustomMetricsEvent({
+ ...(metricsTemplate?.fields ?? {}),
+ actionType: actionType,
+ actionDetails: actionDetail,
+ url: src,
+ duration,
+ position,
+ topic: metricsTemplate?.topic ?? '',
+ });
+ };
+
+ if (Number.isNaN(videoRef?.duration)) {
+ queuedMetricEvents.push(() => recordEvent());
+ } else {
+ recordEvent();
+ }
+ };
+
+ const isVideoPlaying = (video: HTMLVideoElement | null) => {
+ if (!video) {
+ return false;
+ }
+ return !!(
+ video.currentTime > 0 &&
+ !video.paused &&
+ !video.ended &&
+ video.readyState > 2
+ );
+ };
+
+ const intersectionObserverConfig = {
+ threshold: autoplayVisibilityThreshold,
+ callback: (isIntersectingViewport: boolean) => {
+ if (isIntersectingViewport) {
+ play();
+ } else if (isVideoPlaying(videoRef)) {
+ pause();
+ }
+ },
+ };
+</script>
+
+<div
+ class="video-container"
+ use:intersectionObserver={autoplay ? intersectionObserverConfig : undefined}
+>
+ <div class="video">
+ <MotionArtwork
+ {id}
+ {HLS}
+ {src}
+ {loop}
+ poster={!shouldSuperimposePosterImage ? poster : undefined}
+ bind:muted={isMuted}
+ bind:paused={isPaused}
+ bind:videoElement={videoRef}
+ on:play={handleVideoPlay}
+ on:ended={handleVideoEnded}
+ on:loadedmetadata={flushMetricEvents}
+ />
+ </div>
+
+ {#if shouldSuperimposePosterImage && !hasPlaybackBeenInitiated}
+ <img
+ src={poster}
+ class="fake-poster"
+ aria-hidden="true"
+ loading="lazy"
+ alt=""
+ />
+ {/if}
+
+ {#if useControls}
+ <div class="video-control">
+ {#if shouldShowReplayControl}
+ <button
+ class="video-control-replay"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Replay',
+ )}
+ on:click={handleReplayButtonClick}
+ >
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-replay.png"
+ alt={$i18n.t('ASE.Web.AppStore.VideoPlayer.AX.Replay')}
+ aria-hidden="true"
+ />
+ </button>
+ {/if}
+
+ {#if shouldShowPlaybackControls}
+ <div class="video-control-playback">
+ <button
+ class="video-control-play"
+ aria-label={$i18n.t(
+ isPaused
+ ? 'ASE.Web.AppStore.VideoPlayer.AX.Play'
+ : 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
+ )}
+ on:click={handlePlayButtonClick}
+ >
+ {#if isPaused}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-play.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Play',
+ )}
+ aria-hidden="true"
+ />
+ {:else}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-pause.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
+ )}
+ aria-hidden="true"
+ />
+ {/if}
+ </button>
+
+ <button
+ class="video-control-unmute"
+ aria-label={$i18n.t(
+ isMuted
+ ? 'ASE.Web.AppStore.VideoPlayer.AX.Unmute'
+ : 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
+ )}
+ on:click={handleMuteButtonClick}
+ >
+ {#if isMuted}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-volume-muted.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
+ )}
+ aria-hidden="true"
+ />
+ {:else}
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-volume.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Unmute',
+ )}
+ aria-hidden="true"
+ />
+ {/if}
+ </button>
+
+ <button
+ class="video-control-fullscreen"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
+ )}
+ on:click={handleFullScreenButtonClick}
+ >
+ <img
+ class="btn-img"
+ src="/assets/images/video-control/video-control-fullscreen.png"
+ alt={$i18n.t(
+ 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
+ )}
+ aria-hidden="true"
+ />
+ </button>
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
+
+<style>
+ .video-container {
+ --button-size: 32px;
+ display: grid;
+ position: relative;
+ container-type: inline-size;
+ container-name: video-container;
+ width: 100%;
+ height: 100%;
+ background-color: var(--systemQuaternary);
+ }
+
+ .video {
+ width: 100%;
+ height: 100%;
+ grid-column: 1;
+ grid-row: 1;
+ line-height: 0;
+ }
+
+ .video-control {
+ grid-column: 1;
+ grid-row: 1;
+ display: inline-flex;
+ justify-content: space-between;
+ z-index: 1;
+ align-self: end;
+ color: white;
+ margin: 0 12px 12px;
+ }
+
+ .video-control::after {
+ position: absolute;
+ content: '';
+ z-index: -1;
+ bottom: 0;
+ left: 0;
+ display: block;
+ box-sizing: border-box;
+ width: 100%;
+ height: calc(var(--button-size) * 2);
+ background: linear-gradient(
+ 0deg,
+ rgb(0, 0, 0, 0.68),
+ rgb(0, 0, 0, 0.2),
+ transparent
+ );
+ mask-image: linear-gradient(360deg, #000 47%, transparent);
+ }
+
+ .video-control-playback {
+ display: inline-flex;
+ margin-inline-start: auto;
+ gap: 6px;
+ }
+
+ .btn-img {
+ height: var(--button-size);
+ width: var(--button-size);
+ border-radius: 50%;
+ border: 1px solid var(--systemQuaternary-onDark);
+ background: rgba(0, 0, 0, 0.11);
+ backdrop-filter: blur(20px);
+ object-fit: cover;
+ transition: background 105ms ease-out;
+ }
+
+ .btn-img:hover {
+ background: rgba(0, 0, 0, 0.05);
+ }
+
+ @container video-container (max-width: 500px) {
+ .btn-img {
+ --button-size: 24px;
+ }
+ }
+
+ .fake-poster {
+ width: 100%;
+ position: absolute;
+ top: 0;
+ left: 0;
+ }
+</style>
diff --git a/src/components/decorators/HlsJSDecorator.svelte b/src/components/decorators/HlsJSDecorator.svelte
new file mode 100644
index 0000000..591cb0d
--- /dev/null
+++ b/src/components/decorators/HlsJSDecorator.svelte
@@ -0,0 +1,67 @@
+<script lang="ts" context="module">
+ // This store is used to keep track of in-flight requests, ensuring that we don't attempt
+ // to load the same src (which is stored in the Map key) multiple times.
+ const inFlightRequests = new Map<string, Promise<void>>();
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { generateHLSJSURL } from '~/config/hlsjs';
+ import { generateRTCJSURL } from '~/config/rtcjs';
+
+ export let version: string | undefined = undefined;
+
+ let hlsjsSourceURL = generateHLSJSURL(version).toString();
+ let rtcjsSourceURL = generateRTCJSURL(version).toString();
+
+ function loadScript(src: string): Promise<void> {
+ // If we have an in-flight request for this `src`, return it.
+ const inFlightRequest = inFlightRequests.get(src);
+ if (inFlightRequest) {
+ return inFlightRequest;
+ }
+
+ const promise = new Promise<void>(function (resolve, reject) {
+ const scriptElement = document.createElement('script');
+ scriptElement.src = src;
+ scriptElement.onload = () => resolve();
+ scriptElement.onerror = () => {
+ // If a script fails to load due to a network blip, we remove it from the store,
+ // so that the next attempt in an `onMount` will try to load the `src` again.
+ inFlightRequests.delete(src);
+ reject();
+ };
+
+ document.head.appendChild(scriptElement);
+ });
+
+ // Add the given `src` to the store so we can keep track of in-flight requests.
+ inFlightRequests.set(src, promise);
+
+ return promise;
+ }
+
+ let loading: Promise<[void, void]> | undefined;
+
+ onMount(() => {
+ loading = Promise.all([
+ window.Hls ?? loadScript(hlsjsSourceURL),
+ window.rtc ?? loadScript(rtcjsSourceURL),
+ ]);
+ });
+</script>
+
+{#if loading}
+ {#await loading}
+ <slot name="loading-component" />
+ {:then}
+ <slot HLS={window.Hls} RTC={window.rtc} />
+ {:catch}
+ <div>
+ <p>
+ Failed to load HLS.js {version} from
+ <a href={hlsjsSourceURL}>{hlsjsSourceURL}</a>
+ </p>
+ </div>
+ {/await}
+{/if}
diff --git a/src/components/hero/AppLockupDetail.svelte b/src/components/hero/AppLockupDetail.svelte
new file mode 100644
index 0000000..e4abe47
--- /dev/null
+++ b/src/components/hero/AppLockupDetail.svelte
@@ -0,0 +1,109 @@
+<!--
+@component
+Component for rendering App information into the `details` slot
+of the `Hero.svelte` component
+-->
+<script lang="ts">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+
+ import { getI18n } from '~/stores/i18n';
+ import AppIcon from '~/components/AppIcon.svelte';
+
+ const i18n = getI18n();
+
+ export let lockup: Lockup;
+ export let isOnDarkBackground: boolean = true;
+</script>
+
+<div class="lockup-container">
+ {#if lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={lockup.icon} profile="app-icon-small" />
+ </div>
+ {/if}
+
+ <div class="text-container">
+ {#if lockup.heading}
+ <LineClamp clamp={1}>
+ <h4>{lockup.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if lockup.title}
+ <LineClamp clamp={2}>
+ <h3>{lockup.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if lockup.subtitle}
+ <LineClamp clamp={1}>
+ <p>{lockup.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <div class="button-container">
+ <span
+ class="get-button"
+ class:transparent={isOnDarkBackground}
+ class:dark-gray={!isOnDarkBackground}
+ >
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+</div>
+
+<style lang="scss">
+ .lockup-container {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ max-width: 350px;
+ margin-top: 20px;
+ padding-top: 20px;
+ color: var(--hero-primary-color, var(--systemPrimary-onDark));
+ border-top: 1px solid
+ var(--hero-divider-color, var(--systemQuaternary-onDark));
+
+ @media (--range-xsmall-down) {
+ text-align: left;
+ padding: 20px 0 10px;
+ max-width: unset;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 64px;
+ margin-inline-end: 16px;
+ }
+
+ .text-container {
+ width: 100%;
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ font: var(--title-3-emphasized);
+ text-wrap: pretty;
+ }
+
+ h4 {
+ color: var(--hero-secondary-color, var(--systemSecondary-onDark));
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
+ }
+
+ p {
+ mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
+ }
+
+ .button-container {
+ --get-button-font: var(--title-3-bold);
+ position: relative;
+ z-index: 1;
+ }
+</style>
diff --git a/src/components/hero/Carousel.svelte b/src/components/hero/Carousel.svelte
new file mode 100644
index 0000000..218813b
--- /dev/null
+++ b/src/components/hero/Carousel.svelte
@@ -0,0 +1,132 @@
+<!--
+@component
+Component for rendering a carousel of `Hero.svelte` components in a way taht's decoupled from
+any particular data model
+-->
+<script lang="ts" generics="Item">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { Artwork, Shelf } from '@jet-app/app-store/api/models';
+
+ import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
+ import mediaQueries from '~/utils/media-queries';
+ import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
+ import HeroCarouselBackgroundPortal, {
+ id as portalId,
+ } from '~/components/hero/CarouselBackgroundPortal.svelte';
+ import AmbientBackgroundArtwork from '~/components/AmbientBackgroundArtwork.svelte';
+ import portal from '~/utils/portal';
+ import { carouselMediaStyle } from '~/stores/carousel-media-style';
+
+ interface $$Slots {
+ default: {
+ /**
+ * The `Item` to render as a `Hero` in the carousel
+ */
+ item: Item;
+ };
+ }
+
+ /**
+ * The shelf being rendered
+ *
+ * Used to derrive any shelf-specific presentation
+ */
+ export let shelf: Shelf;
+
+ /**
+ * The items to render in the hero carousel
+ *
+ * This is decoupled from `shelf` to avoid assuming that `shelf.items` is, itself,
+ * the set of items that we need to present; some shelves model their items as chilren
+ * of the first shelf item.
+ */
+ export let items: Item[];
+
+ /**
+ * Callback that determines the "background artwork" to use behind the
+ * active `Hero` for the given `Item`
+ */
+ export let deriveBackgroundArtworkFromItem: (item: Item) => Opt<Artwork>;
+
+ $: gridRows = shelf.rowsPerColumn ?? undefined;
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+
+ let activeIndex: number | undefined = 0;
+
+ function createIntersectionObserverCallback(index: number) {
+ return (isIntersectingViewport: boolean) => {
+ if (isIntersectingViewport) {
+ // Many different types of `item`s can be rendered in this Carousel, and all those
+ // different items have different ways of determining whether or not the background
+ // is dark or light, so we are running through all the options here.
+ const { style, mediaOverlayStyle, isMediaDark } = items[
+ index
+ ] as any;
+ const fallbackStyle = 'dark';
+ let derivedStyle;
+
+ if (typeof isMediaDark !== 'undefined') {
+ derivedStyle = isMediaDark ? 'dark' : 'light';
+ }
+
+ carouselMediaStyle.set(
+ style || mediaOverlayStyle || derivedStyle || fallbackStyle,
+ );
+
+ activeIndex = index;
+ }
+ };
+ }
+</script>
+
+<HeroCarouselBackgroundPortal />
+
+<ShelfWrapper {shelf} --shelfGridGutterWidth="0">
+ <HorizontalShelf
+ {gridRows}
+ {items}
+ --shelfScrollPaddingInline="0"
+ --grid-max-content-xsmall={!$sidebarIsHidden
+ ? 'calc(100% + 50px)'
+ : '100vw'}
+ gridType="Spotlight"
+ let:item
+ let:index
+ >
+ {#if isXSmallViewport}
+ <div
+ use:intersectionObserver={{
+ callback: createIntersectionObserverCallback(index),
+ threshold: 0.5,
+ }}
+ >
+ <slot {item} />
+ </div>
+ {:else}
+ <div
+ use:intersectionObserver={{
+ callback: createIntersectionObserverCallback(index),
+ threshold: 0,
+ }}
+ >
+ {#if !import.meta.env.SSR}
+ {@const backgroundArtwork =
+ deriveBackgroundArtworkFromItem(item)}
+
+ {#if backgroundArtwork}
+ <div use:portal={portalId}>
+ <AmbientBackgroundArtwork
+ artwork={backgroundArtwork}
+ active={activeIndex === index}
+ />
+ </div>
+ {/if}
+ {/if}
+
+ <slot {item} />
+ </div>
+ {/if}
+ </HorizontalShelf>
+</ShelfWrapper>
diff --git a/src/components/hero/CarouselBackgroundPortal.svelte b/src/components/hero/CarouselBackgroundPortal.svelte
new file mode 100644
index 0000000..4580ce0
--- /dev/null
+++ b/src/components/hero/CarouselBackgroundPortal.svelte
@@ -0,0 +1,17 @@
+<script lang="ts" context="module">
+ export const id = 'hero-carousel-shelf-background-portal';
+</script>
+
+<div {id} />
+
+<style>
+ #hero-carousel-shelf-background-portal {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow-x: hidden;
+ z-index: -1;
+ }
+</style>
diff --git a/src/components/hero/Hero.svelte b/src/components/hero/Hero.svelte
new file mode 100644
index 0000000..f643ffa
--- /dev/null
+++ b/src/components/hero/Hero.svelte
@@ -0,0 +1,536 @@
+<!--
+@component
+Component for rendering an item in a "Hero Carousel" without coupling to any specific data model
+-->
+<script lang="ts">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type {
+ Action,
+ Artwork as ArtworkModel,
+ Color,
+ Video as VideoModel,
+ } from '@jet-app/app-store/api/models';
+
+ import mediaQueries from '~/utils/media-queries';
+ import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+ import {
+ colorAsString,
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+
+ /**
+ * The main text for the carousel item
+ */
+ export let title: Opt<string> = undefined;
+
+ /**
+ * Additional text above the title.
+ * Note: If a slot is defined with the name `eyebrow`, the slot takes precedence.
+ */
+ export let eyebrow: Opt<string> = undefined;
+
+ /**
+ * Additional text below the title
+ */
+ export let subtitle: Opt<string> = undefined;
+
+ /**
+ * Primary accent color for the carousel item
+ */
+ export let backgroundColor: Opt<Color> = undefined;
+
+ /**
+ * Static artwork to display in the carousel item
+ */
+ export let artwork: Opt<ArtworkModel> = undefined;
+
+ /**
+ * Video to display in the carousel item
+ *
+ * Takes precedence over `artwork`
+ */
+ export let video: Opt<VideoModel> = undefined;
+
+ /**
+ * Action to perform when clicking on the carousel item
+ */
+ export let action: Opt<Action> = undefined;
+
+ /**
+ * Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container
+ */
+ export let pinArtworkToHorizontalEnd: boolean = false;
+
+ /**
+ * Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default)
+ */
+ export let pinArtworkToVerticalMiddle: boolean = false;
+
+ /**
+ * Whether the text (e.g. title, description, etc) should be pinned to the top of the container
+ */
+ export let pinTextToVerticalStart: boolean = false;
+
+ /**
+ * Allows for the absolute overriding of the profile used for the Hero artwork
+ */
+ export let profileOverride: Opt<NamedProfile> = null;
+
+ export let isMediaDark: boolean = true;
+
+ export let collectionIcons: ArtworkModel[] | undefined = undefined;
+
+ let isPortraitLayout: boolean;
+ let profile: NamedProfile;
+ let collectionIconsBackgroundGradientCssVars: string | undefined =
+ undefined;
+
+ $: isPortraitLayout = $mediaQueries === 'xsmall';
+
+ $: {
+ if (profileOverride) {
+ profile = profileOverride;
+ } else if (isPortraitLayout) {
+ profile = 'large-hero-portrait';
+ } else if (pinArtworkToHorizontalEnd && isRtl()) {
+ profile = 'large-hero-east';
+ } else if (pinArtworkToHorizontalEnd) {
+ profile = 'large-hero-west';
+ } else {
+ profile = 'large-hero';
+ }
+ }
+
+ const color: string = backgroundColor
+ ? colorAsString(backgroundColor)
+ : '#000';
+
+ if (collectionIcons && collectionIcons.length > 1) {
+ // If there are multiple app icons, we build a string of CSS variables from the icons
+ // background colors to fill as many of the lockups quadrants as possible.
+ collectionIconsBackgroundGradientCssVars =
+ getBackgroundGradientCSSVarsFromArtworks(collectionIcons, {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ shouldRemoveGreys: true,
+ });
+ }
+</script>
+
+<LinkWrapper {action} includeExternalLinkArrowIcon={false}>
+ <article
+ data-test-id="hero"
+ class:with-dark-media={isMediaDark}
+ class:with-collection-icons={!artwork && !video && collectionIcons}
+ class:text-pinned-to-vertical-start={pinTextToVerticalStart}
+ >
+ {#if video || artwork}
+ <div
+ class={`image-container ${profile}`}
+ class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd}
+ class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle}
+ style:--color={color}
+ >
+ {#if video && !$prefersReducedMotion}
+ <Video
+ loop
+ autoplay
+ useControls={false}
+ {video}
+ {profile}
+ />
+ {:else if artwork}
+ <Artwork
+ {artwork}
+ {profile}
+ noShelfChevronAnchor={true}
+ useCropCodeFromArtwork={false}
+ withoutBorder={true}
+ />
+ {/if}
+ </div>
+ {:else if collectionIcons}
+ <ul class="app-icons">
+ {#each collectionIcons?.slice(0, 5) as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon
+ icon={collectionIcon}
+ profile="app-icon-large"
+ fixedWidth={false}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ <div
+ class="collection-icons-background-gradient"
+ style={collectionIconsBackgroundGradientCssVars}
+ />
+ {/if}
+
+ <div class="gradient" style="--color: {color};" />
+
+ <slot name="badge" {isPortraitLayout} />
+
+ <div class="metadata-container">
+ {#if $$slots.eyebrow}
+ <h3><slot name="eyebrow" /></h3>
+ {:else if eyebrow}
+ <h3>{eyebrow}</h3>
+ {/if}
+
+ {#if title}
+ <h2>{@html sanitizeHtml(title)}</h2>
+ {/if}
+
+ {#if subtitle}
+ <p class="subtitle">{@html sanitizeHtml(subtitle)}</p>
+ {/if}
+
+ <slot name="details" {isPortraitLayout} />
+ </div>
+ </article>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ article {
+ --hero-primary-color: var(--systemPrimary-onLight);
+ --hero-secondary-color: var(--systemSecondary-onLight);
+ --hero-text-blend-mode: normal;
+ --hero-divider-color: var(--systemQuaternary-onLight);
+ position: relative;
+ display: flex;
+ overflow: hidden;
+ align-items: end;
+ aspect-ratio: 3 / 4;
+ container-name: hero-container;
+ container-type: size;
+
+ @media (--range-small-up) {
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ height: auto;
+ min-height: 360px;
+ max-height: min(60vh, 770px);
+ border-radius: var(--global-border-radius-large);
+ border: 1px solid var(--systemQuaternary);
+ }
+ }
+
+ article.with-dark-media,
+ article.with-collection-icons {
+ --hero-primary-color: var(--systemPrimary-onDark);
+ --hero-secondary-color: var(--systemSecondary-onDark);
+ --hero-divider-color: var(--systemQuaternary-onDark);
+ --hero-text-blend-mode: plus-lighter;
+ }
+
+ .image-container {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ background-color: var(--color);
+ }
+
+ .image-container.pinned-to-vertical-middle {
+ display: flex;
+ align-items: center;
+ }
+
+ .image-container.pinned-to-vertical-middle :global(.video-container),
+ .image-container.pinned-to-vertical-middle :global(.artwork-component) {
+ width: 100%;
+ height: auto;
+ }
+
+ .image-container.pinned-to-horizontal-end :global(.artwork-component) {
+ height: 100%;
+ display: flex;
+ }
+
+ .image-container.pinned-to-horizontal-end :global(.artwork-component img) {
+ height: 100%;
+ width: auto;
+ position: absolute;
+ inset-inline-end: 0;
+
+ @container hero-container (aspect-ratio >= 279/100) {
+ width: 100%;
+ height: auto;
+ }
+ }
+
+ .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl
+ :global(.artwork-component img) {
+ inset-inline-start: 0;
+ }
+
+ // This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of
+ // 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the
+ // artwork full-width rather than full-height. This should eventually be fixed when Editorial
+ // can prescribe us only 16x9 (1.77:1) hero images.
+ .image-container.pinned-to-horizontal-end.large-hero-story-card,
+ .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl {
+ @container hero-container (aspect-ratio >= 225/100) {
+ :global(.artwork-component img) {
+ width: 100%;
+ height: auto;
+ }
+ }
+ }
+
+ .metadata-container {
+ position: absolute;
+ width: 40%;
+ padding-bottom: 40px;
+ padding-inline-start: 40px;
+ text-wrap: pretty;
+ color: var(--hero-primary-color);
+
+ @media (--range-small-only) {
+ width: 50%;
+ padding: 0 20px 20px;
+ }
+
+ @media (--range-xsmall-down) {
+ width: 100%;
+ padding: 0 20px 20px;
+ text-align: center;
+ }
+ }
+
+ .text-pinned-to-vertical-start .metadata-container {
+ @media (--range-small-only) {
+ top: 20px;
+ }
+
+ @media (--range-medium-up) {
+ top: 40px;
+ }
+ }
+
+ h2 {
+ position: relative;
+ z-index: 1;
+ text-wrap: balance;
+ font: var(--header-emphasized);
+
+ @media (--range-xsmall-down) {
+ font: var(--title-1-emphasized);
+ }
+ }
+
+ @container hero-container (height < 420px) {
+ h2 {
+ font: var(--large-title-emphasized);
+ }
+ }
+
+ h3 {
+ margin-bottom: 8px;
+ position: relative;
+ z-index: 1;
+ color: var(--hero-secondary-color);
+ font: var(--callout-emphasized-tall);
+ mix-blend-mode: var(--hero-text-blend-mode);
+
+ @media (--range-xsmall-down) {
+ margin-bottom: 4px;
+ }
+ }
+
+ p {
+ mix-blend-mode: var(--hero-text-blend-mode);
+ }
+
+ .subtitle {
+ margin-top: 8px;
+ position: relative;
+ z-index: 1;
+ font: var(--body-tall);
+ color: var(--hero-secondary-color);
+ }
+
+ .gradient {
+ --rotation: 55deg;
+
+ &:dir(rtl) {
+ --rotation: -55deg;
+ mask-image: radial-gradient(
+ ellipse 127% 130% at 95% 100%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 24%,
+ rgba(0, 0, 0, 0.66) 32%,
+ transparent 40%
+ ),
+ linear-gradient(
+ -129deg,
+ rgb(0, 0, 0) 0%,
+ rgba(255, 255, 255, 0) 55%
+ );
+ }
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ height: 100%;
+ // stylelint-disable color-function-notation
+ background: linear-gradient(
+ var(--rotation),
+ rgb(from var(--color) r g b / 0.25) 0%,
+ transparent 50%
+ );
+ // stylelint-enable color-function-notation
+ filter: saturate(1.5) brightness(0.9);
+ backdrop-filter: blur(40px);
+ mask-image: radial-gradient(
+ ellipse 127% 130% at 5% 100%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 24%,
+ rgba(0, 0, 0, 0.66) 32%,
+ transparent 40%
+ ),
+ linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%);
+
+ @media (--range-xsmall-down) {
+ --rotation: 0deg;
+ mask-image: linear-gradient(
+ var(--rotation),
+ rgb(0, 0, 0) 28%,
+ rgba(0, 0, 0, 0) 56%
+ );
+ }
+ }
+
+ // When the text is pinned to the top of the lockup, we use a different gradient for legibility
+ article.text-pinned-to-vertical-start .gradient {
+ --rotation: -170deg;
+ mask-image: radial-gradient(
+ ellipse 118% 121% at 100% 0%,
+ rgb(0, 0, 0) 18%,
+ rgb(0, 0, 0.33) 22%,
+ rgba(0, 0, 0, 0.66) 33%,
+ transparent 43%
+ );
+ }
+
+ .app-icons {
+ display: grid;
+ align-self: center;
+ width: 90%;
+ grid-template-rows: auto auto;
+ grid-auto-flow: column;
+ gap: 24px;
+ margin-inline-start: -4%;
+ position: absolute;
+ inset-inline-end: 24px;
+
+ @media (--range-small-up) {
+ width: 44%;
+ }
+ }
+
+ .app-icons li:nth-child(even) {
+ inset-inline-start: 44%;
+ }
+
+ .app-icon-container {
+ position: relative;
+ flex-shrink: 0;
+ max-width: 200px;
+ }
+
+ @property --top-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 20%;
+ }
+
+ @property --bottom-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 40%;
+ }
+
+ @property --top-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 55%;
+ }
+
+ @property --bottom-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 50%;
+ }
+
+ .collection-icons-background-gradient {
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) var(--top-left-stop),
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) var(--bottom-left-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 66% -175%,
+ var(--top-right, #000) var(--top-right-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) var(--bottom-right-stop),
+ transparent 100%
+ );
+ animation: collection-icons-background-gradient-shift 16s infinite
+ alternate-reverse;
+ animation-play-state: paused;
+
+ @media (--range-small-up) {
+ animation-play-state: running;
+ }
+ }
+
+ @keyframes collection-icons-background-gradient-shift {
+ 0% {
+ --top-left-stop: 20%;
+ --bottom-left-stop: 40%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 50%;
+ background-size: 100% 100%;
+ }
+
+ 50% {
+ --top-left-stop: 25%;
+ --bottom-left-stop: 15%;
+ --top-right-stop: 70%;
+ --bottom-right-stop: 30%;
+ background-size: 130% 130%;
+ }
+
+ 100% {
+ --top-left-stop: 15%;
+ --bottom-left-stop: 20%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 20%;
+ background-size: 110% 110%;
+ }
+ }
+</style>
diff --git a/src/components/icons/AppStoreLogo.svg b/src/components/icons/AppStoreLogo.svg
new file mode 100644
index 0000000..185032f
--- /dev/null
+++ b/src/components/icons/AppStoreLogo.svg
@@ -0,0 +1 @@
+export default "__VITE_ASSET__PaJpmjhr__" \ No newline at end of file
diff --git a/src/components/icons/AppleArcadeLogo.svg b/src/components/icons/AppleArcadeLogo.svg
new file mode 100644
index 0000000..52902b2
--- /dev/null
+++ b/src/components/icons/AppleArcadeLogo.svg
@@ -0,0 +1 @@
+export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20384%2080'%20preserveAspectRatio='xMinYMin%20meet'%20%3e%3cpath%20fill='currentColor'%20d='M43.873%2012.699C46.606%209.28%2048.461%204.69%2047.972%200c-4.001.199-8.883%202.64-11.71%206.06-2.538%202.93-4.784%207.712-4.198%2012.206%204.49.39%208.978-2.245%2011.81-5.567M47.92%2019.144c-6.521-.389-12.067%203.701-15.182%203.701-3.116%200-7.885-3.506-13.044-3.411-6.714.098-12.945%203.895-16.352%209.933-7.008%2012.079-1.85%2029.996%204.966%2039.833%203.31%204.867%207.298%2010.226%2012.553%2010.034%204.966-.195%206.912-3.216%2012.948-3.216%206.032%200%207.785%203.216%2013.041%203.118%205.451-.097%208.859-4.869%2012.168-9.74%203.797-5.549%205.351-10.906%205.449-11.2-.098-.097-10.511-4.092-10.608-16.07-.098-10.03%208.176-14.801%208.565-15.097-4.672-6.91-11.972-7.69-14.503-7.885'%20/%3e%3cpath%20fill='currentColor'%20d='M115.598%2058.881H87.752L81.07%2078.627H69.273L95.651%205.569h12.252l26.377%2073.058h-12l-6.682-19.746zm-24.96-9.113h22.074L101.827%2017.72h-.304L90.638%2049.768zM140.503%2025.365h10.43v9.062h.253c1.773-6.226%206.531-9.923%2012.81-9.923%201.569%200%202.936.253%203.746.406v10.175c-.86-.354-2.784-.607-4.911-.607-7.038%200-11.391%204.71-11.391%2012.252v31.897h-10.937V25.365zM207.744%2043.693c-1.114-5.671-5.367-10.177-12.505-10.177-8.455%200-14.025%207.037-14.025%2018.48%200%2011.695%205.62%2018.48%2014.126%2018.48%206.734%200%2011.138-3.696%2012.404-9.873h10.53c-1.164%2011.34-10.227%2019.036-23.035%2019.036-15.24%200-25.162-10.43-25.162-27.643%200-16.91%209.923-27.593%2025.06-27.593%2013.72%200%2022.074%208.81%2023.036%2019.29h-10.43zM223.9%2063.489c0-9.317%207.14-15.037%2019.797-15.746l14.58-.86v-4.101c0-5.924-4-9.468-10.682-9.468-6.329%200-10.278%203.037-11.24%207.797h-10.328c.607-9.62%208.81-16.708%2021.973-16.708%2012.91%200%2021.163%206.835%2021.163%2017.517v36.707h-10.48v-8.76h-.254c-3.088%205.925-9.821%209.67-16.808%209.67-10.43%200-17.72-6.48-17.72-16.048zm34.378-4.81v-4.202l-13.113.81c-6.532.456-10.227%203.341-10.227%207.898%200%204.657%203.848%207.695%209.72%207.695%207.645%200%2013.62-5.265%2013.62-12.2zM276.853%2051.996c0-16.809%208.91-27.492%2022.276-27.492%207.645%200%2013.721%203.848%2016.707%209.721h.204V5.57h10.986v73.058h-10.632v-9.063h-.203c-3.139%206.075-9.214%209.974-16.96%209.974-13.468%200-22.378-10.734-22.378-27.542zm11.189%200c0%2011.239%205.417%2018.277%2014.075%2018.277%208.404%200%2014.023-7.139%2014.023-18.277%200-11.037-5.619-18.277-14.023-18.277-8.658%200-14.075%207.088-14.075%2018.277zM382.956%2062.982c-1.519%209.72-10.734%2016.657-22.935%2016.657-15.644%200-25.111-10.58-25.111-27.39%200-16.707%209.619-27.846%2024.656-27.846%2014.783%200%2023.997%2010.43%2023.997%2026.58v3.747h-37.616v.658c0%209.265%205.568%2015.39%2014.327%2015.39%206.228%200%2010.835-3.138%2012.303-7.796h10.379zm-36.96-15.897h26.631c-.252-8.15-5.417-13.873-13.061-13.873-7.646%200-13.012%205.823-13.57%2013.873z'%20/%3e%3c/svg%3e" \ No newline at end of file
diff --git a/src/components/jet/Video.svelte b/src/components/jet/Video.svelte
new file mode 100644
index 0000000..8d2e4f3
--- /dev/null
+++ b/src/components/jet/Video.svelte
@@ -0,0 +1,66 @@
+<script lang="ts">
+ import type { Video } from '@jet-app/app-store/api/models';
+ import VideoPlayer from '../VideoPlayer.svelte';
+ import HlsJsDecorator from '../decorators/HlsJSDecorator.svelte';
+ import { buildPoster } from '~/utils/video-poster';
+ import { generateUuid } from '@amp/web-apps-utils/src';
+ import type { NamedProfile } from 'src/config/components/artwork';
+ import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
+ import mediaQueries from '~/utils/media-queries';
+ import { colorAsString } from '~/utils/color';
+
+ export let video: Video;
+ export let autoplay: boolean = false;
+ export let loop: boolean = false;
+ export let muted: boolean = true;
+ export let useControls: boolean = true;
+ export let profile: NamedProfile | Profile;
+ export let autoplayVisibilityThreshold: number = 0;
+ export let videoPlayerRef: InstanceType<typeof VideoPlayer> | null = null;
+ export let shouldSuperimposePosterImage: boolean = false;
+
+ $: poster =
+ video.preview && buildPoster(video.preview, profile, $mediaQueries);
+ $: backgroundColor = video.preview.backgroundColor
+ ? colorAsString(video.preview.backgroundColor)
+ : '#f1f1f1';
+
+ $: metricsTemplate = video?.templateMediaEvent ?? {};
+ const uuid = generateUuid();
+</script>
+
+<HlsJsDecorator let:HLS>
+ <VideoPlayer
+ {HLS}
+ {loop}
+ {muted}
+ {autoplay}
+ {useControls}
+ {autoplayVisibilityThreshold}
+ {metricsTemplate}
+ {shouldSuperimposePosterImage}
+ id={uuid}
+ src={video.videoUrl}
+ poster={poster ?? undefined}
+ --aspect-ratio={video.preview.width / video.preview.height}
+ bind:this={videoPlayerRef}
+ />
+
+ <div
+ class="loader"
+ slot="loading-component"
+ style:--aspect-ratio={video.preview.width / video.preview.height}
+ style:--background-image={`url(${poster})`}
+ style:--background-color={backgroundColor}
+ />
+</HlsJsDecorator>
+
+<style>
+ .loader {
+ aspect-ratio: var(--aspect-ratio);
+ width: 100%;
+ background-image: var(--background-image);
+ background-color: var(--background-color);
+ background-size: cover;
+ }
+</style>
diff --git a/src/components/jet/action/ExternalUrlAction.svelte b/src/components/jet/action/ExternalUrlAction.svelte
new file mode 100644
index 0000000..e8a2ad6
--- /dev/null
+++ b/src/components/jet/action/ExternalUrlAction.svelte
@@ -0,0 +1,52 @@
+<script lang="ts">
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+ import type { ExternalUrlAction } from '@jet-app/app-store/api/models';
+ import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
+ import { getJetPerform } from '~/jet';
+
+ type AllowedAnchorAttributes = Omit<
+ HTMLAnchorAttributes,
+ // The `href` attribute is not allowed because it will be provided
+ // by the `ExternalUrlAction`
+ 'href'
+ >;
+
+ interface $$Props extends AllowedAnchorAttributes {
+ destination: ExternalUrlAction;
+ includeArrowIcon?: boolean;
+ }
+
+ const perform = getJetPerform();
+
+ export let destination: ExternalUrlAction;
+ export let includeArrowIcon: boolean = true;
+
+ function handleClickAction() {
+ perform(destination);
+ }
+</script>
+
+<a
+ {...$$restProps}
+ data-test-id="external-link"
+ href={destination.url}
+ target="_blank"
+ rel="nofollow noopener noreferrer"
+ on:click={handleClickAction}
+>
+ <slot />
+ {#if includeArrowIcon}
+ <ArrowIcon class="external-link-arrow" aria-hidden="true" />
+ {/if}
+</a>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ a :global(.external-link-arrow) {
+ @include rtl {
+ transform: rotate(-90deg);
+ }
+ }
+</style>
diff --git a/src/components/jet/action/FlowAction.svelte b/src/components/jet/action/FlowAction.svelte
new file mode 100644
index 0000000..3e55e82
--- /dev/null
+++ b/src/components/jet/action/FlowAction.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+ import type { FlowAction } from '@jet-app/app-store/api/models';
+ import { getJetPerform } from '~/jet';
+
+ type AllowedAnchorAttributes = Omit<
+ HTMLAnchorAttributes,
+ // The `href` attribute is not allowed because it will be provided
+ // by the `FlowAction`
+ 'href'
+ >;
+
+ interface $$Props extends AllowedAnchorAttributes {
+ destination: FlowAction;
+ }
+
+ const perform = getJetPerform();
+
+ export let destination: FlowAction;
+
+ // Web cannot support internal protocols, so this guard prevents
+ // them from showing up in anchor tags.
+ $: pageUrl = destination.pageUrl?.includes('x-as3-internal:')
+ ? '#'
+ : destination?.pageUrl;
+
+ function onClick(event: MouseEvent) {
+ event.preventDefault();
+
+ perform(destination);
+ }
+</script>
+
+<a
+ {...$$restProps}
+ href={pageUrl}
+ data-test-id="internal-link"
+ on:click={onClick}
+>
+ <slot />
+</a>
diff --git a/src/components/jet/action/ShelfBasedPageScrollAction.svelte b/src/components/jet/action/ShelfBasedPageScrollAction.svelte
new file mode 100644
index 0000000..9c1c13e
--- /dev/null
+++ b/src/components/jet/action/ShelfBasedPageScrollAction.svelte
@@ -0,0 +1,51 @@
+<script lang="ts" context="module">
+ import type {
+ Action,
+ ShelfBasedPageScrollAction,
+ } from '@jet-app/app-store/api/models';
+
+ export function isShelfBasedPageScrollAction(
+ action: Action,
+ ): action is ShelfBasedPageScrollAction {
+ return (
+ action.$kind === 'ShelfBasedPageScrollAction' && 'shelfId' in action
+ );
+ }
+</script>
+
+<script lang="ts">
+ import type { HTMLAnchorAttributes } from 'svelte/elements';
+
+ interface $$Props extends HTMLAnchorAttributes {
+ destination: ShelfBasedPageScrollAction;
+ }
+
+ export let destination: ShelfBasedPageScrollAction;
+
+ function handleLinkClick(e: Event) {
+ const anchorElement = e.currentTarget as HTMLAnchorElement;
+ const hash = anchorElement.hash;
+ const elementToScrollTo = document.querySelector(hash);
+ if (!elementToScrollTo) {
+ return;
+ }
+ elementToScrollTo.scrollIntoView({
+ behavior: 'smooth',
+ block: 'start',
+ });
+ history.replaceState(null, '', hash);
+ }
+</script>
+
+{#if destination.shelfId}
+ <a
+ {...$$restProps}
+ data-test-id="scroll-link"
+ href={`#${destination.shelfId}`}
+ on:click|preventDefault|stopPropagation={handleLinkClick}
+ >
+ <slot />
+ </a>
+{:else}
+ <slot />
+{/if}
diff --git a/src/components/jet/badge/ContentRatingBadge.svelte b/src/components/jet/badge/ContentRatingBadge.svelte
new file mode 100644
index 0000000..ff3a2c3
--- /dev/null
+++ b/src/components/jet/badge/ContentRatingBadge.svelte
@@ -0,0 +1,61 @@
+<script lang="ts" context="module">
+ import type { Badge, BadgeType } from '@jet-app/app-store/api/models';
+
+ const ARTWORK_TYPE: BadgeType = 'artwork';
+ const CONTENT_RATING_TYPE: BadgeType = 'contentRating';
+ const CONTENT_RATING_KEY = 'contentRating';
+
+ interface ContentRatingBadge extends Badge {
+ type: typeof CONTENT_RATING_TYPE;
+ }
+
+ export function isContentRatingBadge(
+ badge: Badge,
+ ): badge is ContentRatingBadge {
+ return (
+ badge.type === CONTENT_RATING_TYPE ||
+ (badge.key === CONTENT_RATING_KEY && badge.type === ARTWORK_TYPE)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let badge: ContentRatingBadge;
+
+ $: ({ artwork, accessibilityTitle } = badge);
+</script>
+
+{#if artwork && isSystemImageArtwork(artwork)}
+ <div class="pictogram-container" aria-label={accessibilityTitle}>
+ <SystemImage {artwork} />
+ </div>
+{:else}
+ <span>
+ {badge.content.contentRating}
+ </span>
+{/if}
+
+<style>
+ span {
+ height: 25px;
+ margin: 4px 0 2px;
+ font: var(--title-1-emphasized);
+ color: var(--color);
+ }
+
+ .pictogram-container {
+ height: 25px;
+ padding: 2px;
+ aspect-ratio: 1/1;
+ margin: 4px 0 2px;
+ }
+
+ .pictogram-container :global(svg) {
+ width: 21px;
+ height: 21px;
+ }
+</style>
diff --git a/src/components/jet/item/AccessibilityFeaturesItem.svelte b/src/components/jet/item/AccessibilityFeaturesItem.svelte
new file mode 100644
index 0000000..bcbeb6c
--- /dev/null
+++ b/src/components/jet/item/AccessibilityFeaturesItem.svelte
@@ -0,0 +1,159 @@
+<script lang="ts">
+ import type { AccessibilityFeatures } from '@jet-app/app-store/api/models';
+
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let item: AccessibilityFeatures;
+ export let isDetailView: boolean = false;
+</script>
+
+<article
+ class:is-detail-view={isDetailView}
+ role={isDetailView ? 'presentation' : 'article'}
+>
+ {#if !isDetailView}
+ {#if item.artwork && isSystemImageArtwork(item.artwork)}
+ <span class="icon-container" aria-hidden="true">
+ <SystemImage artwork={item.artwork} />
+ </span>
+ {/if}
+ <h2>{item.title}</h2>
+ {/if}
+
+ <ul class:grid={item.features.length > 1 && !isDetailView}>
+ {#each item.features as feature}
+ <li>
+ {#if isSystemImageArtwork(feature.artwork)}
+ <span class="feature-icon-container" aria-hidden="true">
+ <SystemImage artwork={feature.artwork} />
+ </span>
+ {/if}
+ <div class="feature-content">
+ <h3 class="feature-title">{feature.title}</h3>
+ {#if feature.description}
+ <span class="feature-description">
+ {feature.description}
+ </span>
+ {/if}
+ </div>
+ </li>
+ {/each}
+ </ul>
+</article>
+
+<style lang="scss">
+ @use 'amp/stylekit/core/border-radiuses' as *;
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ article {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 30px;
+ gap: 8px;
+ text-align: center;
+ font: var(--body-tall);
+ border-radius: $global-border-radius-rounded-large;
+ background-color: var(--systemQuinary);
+
+ &.is-detail-view {
+ padding: 0;
+ text-align: start;
+ background-color: transparent;
+ }
+ }
+
+ .icon-container {
+ width: 30px;
+ margin: 0 auto;
+ }
+
+ .icon-container :global(svg) {
+ width: 100%;
+ fill: var(--keyColor);
+ }
+
+ h2 {
+ font: var(--title-3-emphasized);
+ margin-bottom: 8px;
+ }
+
+ ul {
+ display: flex;
+ flex-direction: column;
+ gap: 25px;
+ }
+
+ ul.grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ li {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: start;
+ padding: 4px 0;
+ gap: 8px;
+
+ .is-detail-view & {
+ gap: 10px;
+ justify-content: start;
+ align-items: flex-start;
+ }
+ }
+
+ .grid li {
+ justify-content: start;
+ }
+
+ .feature-icon-container {
+ display: inline-flex;
+
+ @media (prefers-color-scheme: dark) {
+ filter: invert(1);
+ }
+
+ .is-detail-view & {
+ display: flex;
+ align-items: center;
+
+ @media (prefers-color-scheme: dark) {
+ filter: none;
+ }
+ }
+ }
+
+ .feature-icon-container :global(svg) {
+ width: 20px;
+
+ .is-detail-view & {
+ width: 30px;
+ fill: var(--keyColor);
+ }
+ }
+
+ .feature-content {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .feature-title {
+ font: var(--body-tall);
+
+ .is-detail-view & {
+ color: var(--systemPrimary);
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .feature-description {
+ color: var(--systemSecondary);
+ font: var(--body);
+ }
+</style>
diff --git a/src/components/jet/item/AccessibilityParagraphItem.svelte b/src/components/jet/item/AccessibilityParagraphItem.svelte
new file mode 100644
index 0000000..836b52f
--- /dev/null
+++ b/src/components/jet/item/AccessibilityParagraphItem.svelte
@@ -0,0 +1,22 @@
+<script lang="ts">
+ import type { AccessibilityParagraph } from '@jet-app/app-store/api/models';
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ export let item: AccessibilityParagraph;
+</script>
+
+<div>
+ <p>
+ <LinkableTextItem item={item.text} />
+ </p>
+</div>
+
+<style>
+ p {
+ font: var(--body-tall);
+ }
+
+ p :global(a) {
+ color: var(--keyColor);
+ }
+</style>
diff --git a/src/components/jet/item/Annotation/AnnotationItem.svelte b/src/components/jet/item/Annotation/AnnotationItem.svelte
new file mode 100644
index 0000000..38bb269
--- /dev/null
+++ b/src/components/jet/item/Annotation/AnnotationItem.svelte
@@ -0,0 +1,17 @@
+<script lang="ts">
+ import { type Annotation } from '@jet-app/app-store/api/models';
+ import ModernAnnotationItemRenderer from '~/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte';
+ import LegacyAnnotationRenderer from '~/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte';
+
+ export let item: Annotation;
+
+ $: ({ items, items_V3, linkAction, summary } = item);
+
+ $: shouldRenderModernAnnotation = items_V3.length > 0;
+</script>
+
+{#if shouldRenderModernAnnotation}
+ <ModernAnnotationItemRenderer items={items_V3} {summary} />
+{:else}
+ <LegacyAnnotationRenderer {items} {linkAction} />
+{/if}
diff --git a/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte
new file mode 100644
index 0000000..fc6586f
--- /dev/null
+++ b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte
@@ -0,0 +1,146 @@
+<script lang="ts">
+ import { isSome } from '@jet/environment';
+ import {
+ type AnnotationItem,
+ type Action,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let items: AnnotationItem[];
+ export let linkAction: Action | undefined;
+
+ const shouldRenderAsDefinitionList = (items: AnnotationItem[]) =>
+ !!items[0]?.heading;
+
+ const shouldRenderAsOrderedList = (items: AnnotationItem[]) =>
+ !!items[0]?.textPairs;
+
+ const shouldRenderAsUnorderedList = (items: AnnotationItem[]) =>
+ !items[0]?.text;
+
+ const shouldRenderAsDefinitionListWithHeading = (items: AnnotationItem[]) =>
+ items[0]?.text && items[1]?.heading;
+</script>
+
+{#if shouldRenderAsDefinitionList(items)}
+ <dl class="secondary-definition-list">
+ {#each items as annotationItem}
+ <dt>{annotationItem.heading}</dt>
+ <dd>{annotationItem.text}</dd>
+ {/each}
+ </dl>
+{:else if shouldRenderAsOrderedList(items)}
+ <ol>
+ {#each items as annotationItem}
+ {#if annotationItem.textPairs}
+ {#each annotationItem.textPairs as [text, subtext]}
+ <li>
+ <span class="text">{text}</span>
+ <span class="subtext">{subtext}</span>
+ </li>
+ {/each}
+ {:else}
+ <li>{annotationItem.text}</li>
+ {/if}
+ {/each}
+ </ol>
+{:else if shouldRenderAsUnorderedList(items)}
+ <ul>
+ {#each items as annotationItem}
+ <li>
+ <span class="text">
+ {annotationItem.text}
+ </span>
+ </li>
+ {/each}
+ </ul>
+{:else if shouldRenderAsDefinitionListWithHeading(items)}
+ {@const [heading, ...remainingItems] = items}
+ <dd>
+ <p class="secondary-definition-list-heading">{heading.text}</p>
+
+ <dl class="secondary-definition-list">
+ {#each remainingItems as annotationItem}
+ <dt>{annotationItem.heading}</dt>
+ <dd>{annotationItem.text}</dd>
+ {/each}
+ </dl>
+ </dd>
+{:else}
+ <dd>
+ <ul>
+ {#each items as annotationItem}
+ <li>{annotationItem.text}</li>
+ {/each}
+ </ul>
+ {#if isSome(linkAction) && isFlowAction(linkAction)}
+ <LinkWrapper action={linkAction}>
+ {linkAction.title}
+ </LinkWrapper>
+ {/if}
+ </dd>
+{/if}
+
+<style>
+ dt {
+ color: var(--systemSecondary);
+ font: var(--body-tall);
+ }
+
+ dd {
+ white-space: pre-line;
+ font: var(--body-tall);
+ }
+
+ ol {
+ counter-reset: section;
+ }
+
+ ol li {
+ display: table-row;
+ font: var(--body-tall);
+ }
+
+ ol li::before {
+ counter-increment: section;
+ content: counter(section) '.';
+ display: table-cell;
+ padding-inline-end: 6px;
+ }
+
+ ol li .text {
+ display: table-cell;
+ width: 100%;
+ }
+
+ ol li .subtext {
+ display: table-cell;
+ }
+
+ .secondary-definition-list-heading {
+ margin-bottom: 16px;
+ }
+
+ .secondary-definition-list dt {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ }
+
+ .secondary-definition-list dd:not(:last-of-type) {
+ margin-bottom: 16px;
+ }
+
+ dd li:not(:last-of-type) {
+ margin-bottom: 16px;
+ }
+
+ dd :global(a) {
+ color: var(--keyColor);
+ text-decoration: none;
+ }
+
+ dd :global(a:hover) {
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte
new file mode 100644
index 0000000..20611d3
--- /dev/null
+++ b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte
@@ -0,0 +1,114 @@
+<script lang="ts">
+ import type { AnnotationItem_V3 } from '@jet-app/app-store/api/models';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let items: AnnotationItem_V3[];
+ export let summary: string | undefined;
+
+ const formatStyledText = (text: string): string => {
+ return (
+ text
+ // Replace \n with <br>
+ .replace(/\n/g, '<br>')
+ // Replace **text** with <strong>text</strong>
+ .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
+ );
+ };
+</script>
+
+<ul>
+ {#each items as annotationItem}
+ <li>
+ {#if annotationItem.$kind === 'textEncapsulation'}
+ <div class="text-encapsulation">
+ {annotationItem.text}
+ </div>
+ {:else if annotationItem.$kind === 'linkableText'}
+ <div class="styled-text">
+ {@html sanitizeHtml(
+ formatStyledText(
+ annotationItem.linkableText.styledText.rawText,
+ ),
+ )}
+ </div>
+ {:else if annotationItem.$kind === 'artwork'}
+ {#if isSystemImageArtwork(annotationItem.artwork)}
+ <div class="artwork-wrapper" aria-label={summary}>
+ <SystemImage artwork={annotationItem.artwork} />
+ </div>
+ {/if}
+ {:else if annotationItem.$kind === 'textPair'}
+ <div class="text-pair">
+ <span>{annotationItem.leadingText}</span>
+ <span>
+ {annotationItem.trailingText}
+ </span>
+ </div>
+ {:else if annotationItem.$kind === 'button'}
+ <div class="button-wrapper">
+ <LinkWrapper action={annotationItem.action}>
+ {annotationItem.action.title}
+ </LinkWrapper>
+ </div>
+ {:else if annotationItem.$kind === 'spacer'}
+ <div class="spacer" />
+ {/if}
+ </li>
+ {/each}
+</ul>
+
+<style>
+ li {
+ font: var(--body-tall);
+ }
+
+ .styled-text :global(strong) {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ }
+
+ .text-encapsulation {
+ width: fit-content;
+ color: var(--keyColor);
+ border: 1px solid;
+ border-radius: 3px;
+ padding-inline: 3px;
+ border-color: var(--keyColor);
+ margin-block: 3px;
+ }
+
+ .artwork-wrapper :global(svg) {
+ height: 18px;
+ width: 18px;
+ margin-top: 4px;
+ }
+
+ .spacer {
+ height: 16px;
+ }
+
+ .button-wrapper :global(a) {
+ color: var(--keyColor);
+ text-decoration: none;
+ }
+
+ .button-wrapper :global(a:hover) {
+ text-decoration: underline;
+ }
+
+ .button-wrapper :global(a) :global(.external-link-arrow) {
+ width: 7px;
+ height: 7px;
+ fill: var(--keyColor);
+ margin-top: 3px;
+ }
+
+ .text-pair {
+ display: flex;
+ justify-content: space-between;
+ }
+</style>
diff --git a/src/components/jet/item/AppEventItem.svelte b/src/components/jet/item/AppEventItem.svelte
new file mode 100644
index 0000000..c1e5e5a
--- /dev/null
+++ b/src/components/jet/item/AppEventItem.svelte
@@ -0,0 +1,176 @@
+<script lang="ts">
+ import type { AppEvent } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+ import SmallLockupItem from './SmallLockupItem.svelte';
+
+ export let item: AppEvent;
+ export let isArticleContext: boolean = false;
+
+ $: artwork = item.moduleArtwork;
+ $: video = item.moduleVideo;
+ $: hasLightArtwork = item.mediaOverlayStyle === 'light';
+ $: gradientColor = hasLightArtwork
+ ? 'rgb(240 240 240 / 48%)'
+ : 'rgb(83 83 83 / 48%)';
+ $: shouldShowLockup = !!item.lockup && !item.hideLockupWhenNotInstalled;
+</script>
+
+<div
+ class="app-event-item"
+ class:with-lockup={!!item.lockup && !item.hideLockupWhenNotInstalled}
+>
+ <span class="time-indicator">
+ <AppEventDate appEvent={item} />
+ </span>
+
+ <div class="lockup-container">
+ <HoverWrapper hasChin={shouldShowLockup} --display="block">
+ <LinkWrapper action={item.clickAction}>
+ <div class="text-over-artwork">
+ {#if video}
+ <div class="video-container">
+ <Video
+ {video}
+ autoplay
+ loop={true}
+ useControls={false}
+ profile="app-promotion"
+ />
+ </div>
+ {:else if artwork}
+ <div class="artwork-container">
+ <Artwork
+ {artwork}
+ profile={isArticleContext
+ ? 'app-promotion-in-article'
+ : 'app-promotion'}
+ />
+ </div>
+ {/if}
+
+ <div class="gradient-container">
+ <GradientOverlay
+ --border-radius={0}
+ --color={gradientColor}
+ --height="80%"
+ shouldDarken={!hasLightArtwork}
+ />
+ </div>
+
+ <div class="text-container" class:dark={hasLightArtwork}>
+ <h4>{item.kind}</h4>
+
+ <h3>{item.title}</h3>
+
+ <LineClamp clamp={1}>
+ <p>{item.detail}</p>
+ </LineClamp>
+ </div>
+ </div>
+ </LinkWrapper>
+ </HoverWrapper>
+
+ {#if item.lockup && shouldShowLockup}
+ <div class="small-lockup-container">
+ <SmallLockupItem item={item.lockup} appIconProfile="app-icon" />
+ </div>
+ {/if}
+ </div>
+</div>
+
+<style>
+ .app-event-item {
+ height: 100%;
+ display: grid;
+ grid-template-areas:
+ 'time-indicator'
+ 'lockup';
+ grid-template-rows: 1rem 1fr;
+ gap: 4px;
+ }
+
+ .time-indicator {
+ grid-area: time-indicator;
+ color: var(--keyColor);
+ font-weight: bold;
+ }
+
+ .lockup-container {
+ grid-area: lockup;
+ }
+
+ .text-over-artwork {
+ /* Allow artwork, overlay and text containers to overlap by targeting the same grid area */
+ display: grid;
+ grid-template-areas: 'content';
+ }
+
+ .artwork-container {
+ grid-area: content;
+ border-radius: var(--global-border-radius-large);
+ }
+
+ .video-container {
+ grid-area: content;
+ border-radius: var(--global-border-radius-large);
+ line-height: 0;
+ }
+
+ .app-event-item.with-lockup .artwork-container,
+ .app-event-item.with-lockup .video-container {
+ border-radius: 0;
+ }
+
+ .gradient-container {
+ grid-area: content;
+ z-index: 1;
+ position: relative;
+ }
+
+ .text-container {
+ color: var(--systemPrimary-onDark);
+ padding: 12px 16px;
+ grid-area: content;
+ z-index: 2;
+
+ /* Float text to the bottom of the lockup */
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-end;
+ }
+
+ .text-container.dark {
+ color: var(--systemPrimary-onLight);
+ }
+
+ .small-lockup-container {
+ background: var(--systemPrimary-onDark);
+ border-radius: 0 0 var(--global-border-radius-large)
+ var(--global-border-radius-large);
+ box-shadow: var(--shadow-small);
+ padding: 12px;
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemQuinary-onDark);
+ }
+ }
+
+ h3 {
+ font: var(--title-2-tall);
+ }
+
+ h4 {
+ font: var(--callout-emphasized-tall);
+ }
+
+ p {
+ font: var(--callout-emphasized);
+ }
+</style>
diff --git a/src/components/jet/item/ArcadeFooterItem.svelte b/src/components/jet/item/ArcadeFooterItem.svelte
new file mode 100644
index 0000000..94fe61d
--- /dev/null
+++ b/src/components/jet/item/ArcadeFooterItem.svelte
@@ -0,0 +1,83 @@
+<script lang="ts">
+ import type {
+ ArcadeFooter,
+ Artwork,
+ ImpressionableArtwork,
+ } from '@jet-app/app-store/api/models';
+ import { unwrapOptional as unwrap } from '@jet/environment/types/optional';
+
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import AppIconRiver from '~/components/AppIconRiver.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: ArcadeFooter;
+
+ $: action = unwrap(item.buttonAction);
+
+ function isImpressionableArtwork(
+ item: ImpressionableArtwork | Artwork,
+ ): item is ImpressionableArtwork {
+ return 'art' in item;
+ }
+
+ // Sometimes data used to render an app icon is directly on `icon` but other times, in the case
+ // of `ImpressionableArtwork`, it's on `icon.art`. Here we are plucking the data no matter where it is.
+ const icons = (item.icons ?? []).map((icon) =>
+ isImpressionableArtwork(icon) ? icon.art : icon,
+ );
+</script>
+
+<LinkWrapper {action}>
+ <article>
+ {#if icons.length}
+ <AppIconRiver {icons} />
+ {/if}
+
+ <div class="metadata-container">
+ <div class="logo-container">
+ <AppleArcadeLogo />
+ </div>
+
+ <button class="get-button gray">
+ {action.title}
+ </button>
+ </div>
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ --app-icon-river-speed: 120s;
+ display: flex;
+ overflow: hidden;
+ flex-flow: column;
+ padding: 20px 0 30px;
+ margin-bottom: 20px;
+ text-align: center;
+ border-radius: var(--global-border-radius-large);
+ background: var(--footerBg);
+
+ @media (--range-small-down) {
+ --app-icon-river-icon-width: 88px;
+ }
+
+ @media (--range-medium-up) {
+ --get-button-font: var(--title-3-emphasized);
+ }
+ }
+
+ .metadata-container {
+ display: flex;
+ align-items: center;
+ flex-flow: column;
+ gap: 20px;
+ }
+
+ .logo-container {
+ width: 128px;
+
+ @media (--range-small-down) {
+ width: 88px;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/BannerItem.svelte b/src/components/jet/item/BannerItem.svelte
new file mode 100644
index 0000000..819f621
--- /dev/null
+++ b/src/components/jet/item/BannerItem.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import { isFlowAction, type Banner } from '@jet-app/app-store/api/models';
+ import { isSome } from '@jet/environment';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: Banner;
+</script>
+
+<div class="banner">
+ <p>
+ {item.message}
+ {#if isSome(item.action) && isFlowAction(item.action)}
+ &nbsp;<LinkWrapper action={item.action}>
+ {item.action.title}
+ </LinkWrapper>
+ {/if}
+ </p>
+</div>
+
+<style>
+ .banner {
+ background: rgba(var(--keyColor-rgb), 0.07);
+ padding: 8px 16px;
+ margin: 0 var(--bodyGutter);
+ text-align: center;
+ border-radius: var(--global-border-radius-small);
+ }
+
+ .banner :global(a) {
+ color: var(--keyColor);
+ text-decoration: none;
+ }
+
+ .banner :global(a:hover) {
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/components/jet/item/BrickItem.svelte b/src/components/jet/item/BrickItem.svelte
new file mode 100644
index 0000000..a9e6319
--- /dev/null
+++ b/src/components/jet/item/BrickItem.svelte
@@ -0,0 +1,300 @@
+<script lang="ts">
+ import type { Brick } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import {
+ colorAsString,
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+
+ export let item: Brick;
+ export let shouldOverlayDescription: boolean = false;
+
+ const rtlArtwork = item.artworks?.[1] || item.rtlArtwork;
+ const artwork = isRtl() && rtlArtwork ? rtlArtwork : item.artworks?.[0];
+ const { collectionIcons } = item;
+
+ const gradientColor: string = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : 'rgb(0 0 0 / 62%)';
+
+ let backgroundGradientCssVars: string | undefined = undefined;
+
+ if (collectionIcons && collectionIcons.length > 1) {
+ // If there are multiple app icons, we build a string of CSS variables from the icons
+ // background colors to fill as many of the lockups quadrants as possible.
+ backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
+ collectionIcons,
+ {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ shouldRemoveGreys: true,
+ },
+ );
+ }
+</script>
+
+<LinkWrapper
+ action={item.clickAction}
+ label={item.accessibilityLabel || item.clickAction?.title}
+>
+ <div class="container">
+ <HoverWrapper>
+ {#if artwork}
+ <Artwork
+ {artwork}
+ profile={shouldOverlayDescription ? 'small-brick' : 'brick'}
+ />
+ {:else if backgroundGradientCssVars}
+ <div
+ class="background-gradient"
+ style={backgroundGradientCssVars}
+ />
+ {/if}
+
+ {#if item.title}
+ <GradientOverlay --color={gradientColor} />
+ {/if}
+
+ <div class="text-container">
+ <div class="metadata-container">
+ {#if item.caption}
+ <LineClamp clamp={1}>
+ <h4>{item.caption}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={3}>
+ <h3 class="title">
+ {@html sanitizeHtml(item.title)}
+ </h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={2}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ {#if !artwork && collectionIcons}
+ <ul class="app-icons">
+ {#each collectionIcons?.slice(0, 8) as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon
+ icon={collectionIcon}
+ profile="brick-app-icon"
+ fixedWidth={false}
+ />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ </HoverWrapper>
+
+ {#if item.shortEditorialDescription}
+ <h3
+ class="editorial-description"
+ class:overlaid={shouldOverlayDescription}
+ >
+ {item.shortEditorialDescription}
+ </h3>
+ {/if}
+ </div>
+</LinkWrapper>
+
+<style>
+ .container {
+ position: relative;
+ container-type: inline-size;
+ container-name: container;
+ }
+
+ .metadata-container {
+ width: 100%;
+ align-self: end;
+ }
+
+ .text-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ display: flex;
+ align-items: flex-end;
+ width: 100%;
+ height: 100%;
+ padding: 20px;
+ color: var(--systemPrimary-onDark);
+ }
+
+ .app-icon-container {
+ position: relative;
+ flex-shrink: 0;
+ width: 60px;
+ margin-inline-end: 5%;
+ }
+
+ .title {
+ font: var(--title-1-emphasized);
+ text-wrap: pretty;
+ }
+
+ h4 {
+ margin-bottom: 3px;
+ font: var(--callout-emphasized);
+ }
+
+ p {
+ margin-top: 6px;
+ font: var(--body-emphasized);
+ }
+
+ .editorial-description {
+ margin-top: 8px;
+ font: var(--title-3);
+ }
+
+ .editorial-description.overlaid {
+ position: absolute;
+ z-index: 1;
+ bottom: 9px;
+ padding: 0 20px;
+ color: white;
+ font: var(--title-2-emphasized);
+ }
+
+ @property --top-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 20%;
+ }
+
+ @property --bottom-left-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 40%;
+ }
+
+ @property --top-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 55%;
+ }
+
+ @property --bottom-right-stop {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 50%;
+ }
+
+ .container .background-gradient {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) var(--top-left-stop),
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) var(--bottom-left-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 66% -175%,
+ var(--top-right, #000) var(--top-right-stop),
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) var(--bottom-right-stop),
+ transparent 100%
+ );
+ animation: gradient-hover 8s infinite alternate-reverse;
+ animation-play-state: paused;
+ }
+
+ @keyframes gradient-hover {
+ 0% {
+ --top-left-stop: 20%;
+ --bottom-left-stop: 40%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 50%;
+ background-size: 100% 100%;
+ }
+
+ 50% {
+ --top-left-stop: 25%;
+ --bottom-left-stop: 15%;
+ --top-right-stop: 70%;
+ --bottom-right-stop: 30%;
+ background-size: 130% 130%;
+ }
+
+ 100% {
+ --top-left-stop: 15%;
+ --bottom-left-stop: 20%;
+ --top-right-stop: 55%;
+ --bottom-right-stop: 20%;
+ background-size: 110% 110%;
+ }
+ }
+
+ .container:hover .background-gradient {
+ animation-play-state: running;
+ }
+
+ .app-icons {
+ display: grid;
+ align-self: center;
+ flex-direction: row;
+ width: 44%;
+ grid-template-rows: auto auto;
+ grid-auto-flow: column;
+ gap: 8px;
+ }
+
+ .app-icons li:nth-child(even) {
+ inset-inline-start: 40px;
+ }
+
+ @container container (max-width: 298px) {
+ .title {
+ font: var(--title-2-emphasized);
+ }
+
+ .text-container {
+ padding: 16px;
+ }
+
+ .editorial-description.overlaid {
+ bottom: 16px;
+ padding-inline: 16px;
+ }
+
+ .app-icons {
+ width: 36%;
+ }
+
+ .app-icon-container {
+ width: 50px;
+ }
+ }
+
+ @container container (min-width: 440px) {
+ .app-icon-container {
+ width: 83px;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/ContentModal.svelte b/src/components/jet/item/ContentModal.svelte
new file mode 100644
index 0000000..486937d
--- /dev/null
+++ b/src/components/jet/item/ContentModal.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ import ContentModal from '@amp/web-app-components/src/components/Modal/ContentModal.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { createEventDispatcher } from 'svelte';
+ import { getJet } from '~/jet';
+
+ export let title: string | null;
+ export let subtitle: string | null;
+ export let text: string | null = null;
+ export let dialogTitleId: string | null = null;
+ export let targetId: string = 'close';
+
+ const i18n = getI18n();
+ const jet = getJet();
+ const dispatch = createEventDispatcher();
+
+ const translateFn = (key: string) => $i18n.t(key);
+
+ const handleCloseModal = () => {
+ dispatch('close');
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ targetId,
+ targetType: 'button',
+ actionType: 'close',
+ });
+ };
+</script>
+
+<ContentModal
+ on:close={handleCloseModal}
+ {translateFn}
+ {title}
+ {subtitle}
+ text={text || undefined}
+ {dialogTitleId}
+>
+ <slot name="content" slot="content" />
+</ContentModal>
diff --git a/src/components/jet/item/EditorialCardItem.svelte b/src/components/jet/item/EditorialCardItem.svelte
new file mode 100644
index 0000000..2998b05
--- /dev/null
+++ b/src/components/jet/item/EditorialCardItem.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { EditorialCard } from '@jet-app/app-store/api/models';
+
+ import Hero from '~/components/hero/Hero.svelte';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+ import AppLockupDetail from '~/components/hero/AppLockupDetail.svelte';
+ import mediaQueries from '~/utils/media-queries';
+ import { isRtl } from '~/utils/locale';
+
+ export let item: EditorialCard;
+
+ $: isPortraitLayout = $mediaQueries === 'xsmall';
+</script>
+
+<Hero
+ action={item.clickAction}
+ artwork={item.artwork}
+ subtitle={item.subtitle}
+ title={item.title}
+ pinArtworkToHorizontalEnd={true}
+ backgroundColor={item.artwork?.backgroundColor}
+ isMediaDark={item.mediaOverlayStyle === 'dark'}
+ profileOverride={isPortraitLayout ? 'large-hero-portrait-iphone' : null}
+>
+ <svelte:fragment slot="eyebrow">
+ {#if item.appEventFormattedDates}
+ <AppEventDate formattedDates={item.appEventFormattedDates} />
+ {:else}
+ {item.caption}
+ {/if}
+ </svelte:fragment>
+
+ <svelte:fragment slot="details">
+ {#if item.lockup}
+ <AppLockupDetail
+ lockup={item.lockup}
+ isOnDarkBackground={item.mediaOverlayStyle === 'dark'}
+ />
+ {/if}
+ </svelte:fragment>
+</Hero>
diff --git a/src/components/jet/item/FooterLockupItem.svelte b/src/components/jet/item/FooterLockupItem.svelte
new file mode 100644
index 0000000..848885d
--- /dev/null
+++ b/src/components/jet/item/FooterLockupItem.svelte
@@ -0,0 +1,93 @@
+<script lang="ts">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Lockup;
+
+ const i18n = getI18n();
+</script>
+
+<div class="footer-lockup-item">
+ <LinkWrapper
+ action={item.clickAction}
+ label={`${$i18n.t('ASE.Web.AppStore.View')} ${
+ item.title ? item.title : null
+ }`}
+ >
+ {#if item.icon}
+ <AppIcon icon={item.icon} profile="app-icon-small" />
+ {/if}
+
+ <div>
+ {#if item.heading}
+ <LineClamp clamp={1}>
+ <h4 dir="auto">{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={1}>
+ <h3 dir="auto">{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p dir="auto">{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <span class="get-button blue" aria-hidden="true">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </LinkWrapper>
+</div>
+
+<style>
+ .footer-lockup-item > :global(a) {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ width: 100%;
+ padding: 32px;
+ gap: 16px;
+ text-align: center;
+ border-radius: var(--global-border-radius-small);
+ background-color: var(--systemQuinary);
+ transition: background-color 210ms ease-out;
+ }
+
+ .footer-lockup-item > :global(a:hover) {
+ --darken-amount: 2%;
+ background-color: color-mix(
+ in srgb,
+ var(--systemQuinary) calc(100% - var(--darken-amount)),
+ black
+ );
+
+ @media (prefers-color-scheme: dark) {
+ --darken-amount: 10%;
+ }
+ }
+
+ h3 {
+ margin-bottom: 4px;
+ font: var(--title-2-emphasized);
+ color: var(--title-color);
+ }
+
+ h4 {
+ text-transform: uppercase;
+ font: var(--subhead-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ p {
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/HeroCarouselItem.svelte b/src/components/jet/item/HeroCarouselItem.svelte
new file mode 100644
index 0000000..295aa8a
--- /dev/null
+++ b/src/components/jet/item/HeroCarouselItem.svelte
@@ -0,0 +1,60 @@
+<!--
+@component
+Component for rendering a `HeroCarouselItem` view-model from the App Store Client
+-->
+<script lang="ts">
+ import type { HeroCarouselItem } from '@jet-app/app-store/api/models';
+
+ import Hero from '~/components/hero/Hero.svelte';
+ import HeroAppLockup from '~/components/hero/AppLockupDetail.svelte';
+ import mediaQueries from '~/utils/media-queries';
+
+ export let item: HeroCarouselItem;
+
+ const {
+ titleText,
+ badgeText,
+ overlayType,
+ callToActionText,
+ lockup: overlayLockup,
+ clickAction,
+ descriptionText,
+ } = item.overlay || {};
+
+ $: artwork = item.artwork || item.video?.preview;
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: video = isXSmallViewport ? item.portraitVideo : item.video;
+</script>
+
+<Hero
+ {artwork}
+ {video}
+ title={titleText}
+ eyebrow={badgeText}
+ action={clickAction}
+ backgroundColor={item.backgroundColor}
+ subtitle={descriptionText}
+ isMediaDark={item.isMediaDark}
+ collectionIcons={item.collectionIcons}
+>
+ <svelte:fragment slot="details" let:isPortraitLayout>
+ {#if overlayLockup && overlayType === 'singleModule'}
+ <HeroAppLockup lockup={overlayLockup} />
+ {:else if callToActionText && !isPortraitLayout}
+ <div class="button-container">
+ <span class="get-button transparent">
+ {callToActionText}
+ </span>
+ </div>
+ {/if}
+ </svelte:fragment>
+</Hero>
+
+<style>
+ .button-container {
+ --get-button-font: var(--title-3-bold);
+ margin-top: 16px;
+ position: relative;
+ z-index: 1;
+ }
+</style>
diff --git a/src/components/jet/item/InAppPurchaseLockup.svelte b/src/components/jet/item/InAppPurchaseLockup.svelte
new file mode 100644
index 0000000..29b7196
--- /dev/null
+++ b/src/components/jet/item/InAppPurchaseLockup.svelte
@@ -0,0 +1,74 @@
+<script lang="ts">
+ import type { InAppPurchaseLockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import PlusIcon from '~/sf-symbols/plus.heavy.svg';
+
+ export let item: InAppPurchaseLockup;
+</script>
+
+<article>
+ <div class="artwork-container">
+ <PlusIcon class="plus-icon" aria-hidden="true" />
+ <Artwork artwork={item.icon} profile="in-app-purchase" />
+ </div>
+
+ <div class="metadata-container">
+ {#if item.title}
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.productDescription}
+ <LineClamp clamp={1}>
+ <p>{item.productDescription}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.offerDisplayProperties.titles}
+ <p>
+ {item.offerDisplayProperties.titles.discountUnownedParent ||
+ item.offerDisplayProperties.titles.standard}
+ </p>
+ {/if}
+ </div>
+</article>
+
+<style>
+ .artwork-container {
+ position: relative;
+ flex-shrink: 0;
+ width: 100%;
+ margin-bottom: 8px;
+ padding: 8%;
+ border-radius: var(--global-border-radius-small);
+ background: var(--systemQuinary);
+ }
+
+ .artwork-container :global(.plus-icon) {
+ position: absolute;
+ top: 6%;
+ width: 9%;
+ inset-inline-end: 5%;
+ }
+
+ .artwork-container :global(.artwork-component) {
+ border-radius: var(--global-border-radius-small) 43%
+ var(--global-border-radius-small) var(--global-border-radius-small);
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ font: var(--body-tall);
+ }
+
+ p {
+ font: var(--callout-tall);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/LargeBrickItem.svelte b/src/components/jet/item/LargeBrickItem.svelte
new file mode 100644
index 0000000..5ce9974
--- /dev/null
+++ b/src/components/jet/item/LargeBrickItem.svelte
@@ -0,0 +1,106 @@
+<script lang="ts">
+ import type { Brick } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ export let item: Brick;
+ const artwork =
+ isRtl() && item.rtlArtwork ? item.rtlArtwork : item.artworks?.[0];
+ const collectionIcon = item.collectionIcons?.[0];
+ let artworkFallbackColor: string | null = null;
+
+ const gradientOverlayColor: string = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : '#000';
+
+ if (!artwork) {
+ artworkFallbackColor = collectionIcon?.backgroundColor
+ ? colorAsString(collectionIcon.backgroundColor)
+ : '#000';
+ }
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ {#if artwork}
+ <div class="artwork-container">
+ <Artwork {artwork} profile="large-brick" />
+ </div>
+ {:else}
+ <div
+ class="gradient-container"
+ style={`--color: ${artworkFallbackColor};`}
+ />
+ {/if}
+
+ <div class="text-container">
+ <div class="metadata-container">
+ {#if item.caption}
+ <LineClamp clamp={1}>
+ <h4>{item.caption}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={2}>
+ <h3>{@html sanitizeHtml(item.title)}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={2}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+
+ <GradientOverlay --color={gradientOverlayColor} />
+ </HoverWrapper>
+</LinkWrapper>
+
+<style>
+ .artwork-container {
+ width: 100%;
+ }
+
+ .gradient-container {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background-color: var(--color);
+ }
+
+ .text-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ width: 66%;
+ padding-inline: 20px;
+ padding-bottom: 20px;
+ color: var(--systemPrimary-onDark);
+ }
+
+ h3 {
+ font: var(--title-1-emphasized);
+ text-wrap: balance;
+ }
+
+ h4 {
+ font: var(--callout-emphasized);
+ margin-bottom: 3px;
+ }
+
+ p {
+ font: var(--body-emphasized);
+ margin-top: 6px;
+ }
+</style>
diff --git a/src/components/jet/item/LargeHeroBreakoutItem.svelte b/src/components/jet/item/LargeHeroBreakoutItem.svelte
new file mode 100644
index 0000000..d07eec8
--- /dev/null
+++ b/src/components/jet/item/LargeHeroBreakoutItem.svelte
@@ -0,0 +1,268 @@
+<script lang="ts">
+ import {
+ type Artwork as JetArtworkType,
+ type LargeHeroBreakout,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import { isSome } from '@jet/environment/types/optional';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import mediaQueries from '~/utils/media-queries';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+ import { colorAsString, isRGBColor, isDark } from '~/utils/color';
+ import { isRtl } from '~/utils/locale';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ export let item: LargeHeroBreakout;
+
+ let profile: NamedProfile;
+ let artwork: JetArtworkType | undefined;
+ let gradientColor: string;
+
+ const {
+ collectionIcons = [],
+ editorialDisplayOptions,
+ rtlArtwork,
+ video,
+ details: { callToActionButtonAction: action },
+ } = item;
+ const canUseRTLArtwork = isRtl() && rtlArtwork;
+ const shouldShowCollectionIcons =
+ collectionIcons?.length > 1 && !editorialDisplayOptions.suppressLockup;
+
+ $: artwork =
+ (canUseRTLArtwork ? rtlArtwork : item.artwork) || video?.preview;
+ $: doesArtworkHaveDarkBackground =
+ artwork?.backgroundColor &&
+ isRGBColor(artwork.backgroundColor) &&
+ isDark(artwork.backgroundColor);
+ $: isBackgroundDark = item.isMediaDark ?? doesArtworkHaveDarkBackground;
+
+ $: profile =
+ $mediaQueries === 'xsmall'
+ ? 'large-hero-portrait-iphone'
+ : canUseRTLArtwork
+ ? 'large-hero-breakout-rtl'
+ : 'large-hero-breakout';
+
+ $: gradientColor = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper {action}>
+ <HoverWrapper>
+ <div class="artwork-container">
+ {#if video && $mediaQueries !== 'xsmall' && !canUseRTLArtwork}
+ <Video {video} {profile} autoplay loop useControls={false} />
+ {:else if artwork}
+ <Artwork {artwork} {profile} />
+ {/if}
+ </div>
+
+ <div class="gradient" style="--color: {gradientColor};" />
+
+ <div
+ class="text-container"
+ class:on-dark={isBackgroundDark}
+ class:on-light={!isBackgroundDark}
+ >
+ {#if item.details?.badge}
+ <LineClamp clamp={1}>
+ <h4>{item.details.badge}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.title}
+ <LineClamp clamp={2}>
+ <h3>{@html sanitizeHtml(item.details.title)}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.description}
+ <LineClamp clamp={3}>
+ <p>{@html sanitizeHtml(item.details.description)}</p>
+ </LineClamp>
+ {/if}
+
+ {#if isSome(action) && isFlowAction(action)}
+ <span class="link-container">
+ {action.title}
+ <span aria-hidden="true">
+ <SFSymbol name="chevron.forward" />
+ </span>
+ </span>
+ {/if}
+
+ {#if shouldShowCollectionIcons}
+ <ul class="collection-icons">
+ {#each collectionIcons.slice(0, 6) as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon icon={collectionIcon} />
+ </li>
+ {/each}
+ </ul>
+ {/if}
+ </div>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .artwork-container {
+ width: 100%;
+
+ @media (--range-small-up) {
+ aspect-ratio: 8 / 3;
+ }
+ }
+
+ .artwork-container :global(.video-container) {
+ display: flex;
+ }
+
+ .text-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ align-items: center;
+ width: 100%;
+ padding-inline: 20px;
+ padding-bottom: 20px;
+ text-wrap: pretty;
+
+ @media (--range-small-up) {
+ width: 50%;
+ }
+
+ @media (--range-large-up) {
+ width: 33%;
+ }
+ }
+
+ .text-container.on-dark {
+ color: var(--systemPrimary-onDark);
+
+ h4 {
+ color: var(--systemSecondary-onDark);
+ }
+
+ :global(svg) {
+ fill: var(--systemPrimary-onDark);
+ }
+ }
+
+ .text-container.on-light {
+ color: var(--systemPrimary-onLight);
+
+ h4 {
+ color: var(--systemSecondary-onLight);
+ }
+
+ :global(svg) {
+ fill: var(--systemPrimary-onLight);
+ }
+ }
+
+ .link-container {
+ margin-top: 8px;
+ display: flex;
+ gap: 4px;
+ font: var(--body-emphasized);
+
+ @media (--range-small-up) {
+ margin-top: 16px;
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .link-container :global(svg) {
+ width: 8px;
+ height: 8px;
+
+ @include rtl {
+ transform: rotate(180deg);
+ }
+
+ @media (--range-small-up) {
+ width: 10px;
+ height: 10px;
+ }
+ }
+
+ h3 {
+ text-wrap: balance;
+ font: var(--title-1-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--large-title-emphasized);
+ }
+ }
+
+ h4 {
+ font: var(--subhead-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--callout-emphasized);
+ }
+ }
+
+ p {
+ margin-top: 4px;
+ font: var(--body);
+
+ @media (--range-small-up) {
+ margin-top: 8px;
+ font: var(--title-3);
+ }
+ }
+
+ .collection-icons {
+ display: flex;
+ gap: 8px;
+ margin-top: 16px;
+ padding-top: 16px;
+ border-top: 2px solid var(--systemTertiary-onDark);
+ }
+
+ .app-icon-container {
+ aspect-ratio: 1/1;
+ }
+
+ .gradient {
+ --rotation: 35deg;
+ position: absolute;
+ z-index: 1;
+ width: 100%;
+ height: 100%;
+ filter: saturate(1.5) brightness(0.9);
+ background: linear-gradient(
+ var(--rotation),
+ var(--color) 20%,
+ transparent 50%
+ );
+
+ // In non-XS viewports with an RTL text direction, we flip the legibility gradient to
+ // accomodate the right-justified text.
+ @include rtl {
+ @media (--range-small-up) {
+ --rotation: -35deg;
+ }
+ }
+
+ // In XS viewports, this component is renderd in a 3/4 card layout, so we always want the
+ // gradient to be at 0deg rotation, as it goes from botttom to top.
+ @media (--range-xsmall-down) {
+ --rotation: 0deg;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/LargeImageLockupItem.svelte b/src/components/jet/item/LargeImageLockupItem.svelte
new file mode 100644
index 0000000..1df51c2
--- /dev/null
+++ b/src/components/jet/item/LargeImageLockupItem.svelte
@@ -0,0 +1,130 @@
+<script lang="ts">
+ import type { ImageLockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: ImageLockup;
+
+ const color: string = item.artwork.backgroundColor
+ ? colorAsString(item.artwork.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper action={item.lockup.clickAction}>
+ <HoverWrapper>
+ <div class="container">
+ <div class="artwork-container">
+ <Artwork artwork={item.artwork} profile="large-image-lockup" />
+ </div>
+
+ {#if item.lockup}
+ <div
+ class="lockup-container"
+ class:on-dark={item.isDark}
+ class:on-light={!item.isDark}
+ >
+ {#if item.lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={item.lockup.icon} />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if item.lockup.heading}
+ <LineClamp clamp={1}>
+ <p>{item.lockup.heading}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.title}
+ <LineClamp clamp={2}>
+ <h3>{item.lockup.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.subtitle}
+ <LineClamp clamp={1}>
+ <p>{item.lockup.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+ {/if}
+
+ <div class="gradient-container">
+ <GradientOverlay --color={color} --height="85%" />
+ </div>
+ </div>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style>
+ .artwork-container {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ }
+
+ .container {
+ width: 100%;
+ aspect-ratio: 16/9;
+ container-type: inline-size;
+ container-name: container;
+ }
+
+ .gradient-container {
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ }
+
+ .lockup-container {
+ display: flex;
+ align-items: flex-end;
+ width: 100%;
+ height: 100%;
+ padding: 0 20px 20px;
+ }
+
+ .lockup-container.on-dark {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .lockup-container.on-light {
+ color: var(--systemPrimary-onLight);
+ }
+
+ @container container (max-width: 260px) {
+ .lockup-container {
+ padding: 0 10px 10px;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 8px;
+ }
+
+ h3 {
+ margin: 2px 0;
+ font: var(--title-1-emphasized);
+ }
+
+ p {
+ font: var(--callout-emphasized);
+ }
+
+ .lockup-container.on-dark p {
+ mix-blend-mode: plus-lighter;
+ }
+</style>
diff --git a/src/components/jet/item/LargeLockupItem.svelte b/src/components/jet/item/LargeLockupItem.svelte
new file mode 100644
index 0000000..93adc6e
--- /dev/null
+++ b/src/components/jet/item/LargeLockupItem.svelte
@@ -0,0 +1,121 @@
+<script lang="ts">
+ import {
+ isFlowAction,
+ type FlowAction,
+ type Lockup,
+ } from '@jet-app/app-store/api/models';
+ import type { Opt } from '@jet/environment';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Lockup;
+ const i18n = getI18n();
+ const { clickAction } = item;
+ const destination: Opt<FlowAction> = isFlowAction(clickAction)
+ ? clickAction
+ : undefined;
+
+ $: secondaryLine = item.editorialTagline || item.subtitle;
+</script>
+
+<LinkWrapper action={destination}>
+ <article>
+ <div class="app-icon-container">
+ <AppIcon
+ fixedWidth={false}
+ icon={item.icon}
+ profile="app-icon-large"
+ />
+ </div>
+
+ <div class="metadata-container">
+ {#if item.heading}
+ <LineClamp clamp={2}>
+ <h4>{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={2}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if !item.heading && secondaryLine}
+ <LineClamp clamp={1}>
+ <p>{secondaryLine}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.tertiaryTitle}
+ <LineClamp clamp={1}>
+ <p class="tertiary-text">{item.tertiaryTitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ {#if destination}
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ {/if}
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ display: flex;
+ flex-direction: column;
+ min-height: 290px;
+ padding: 20px;
+ border-radius: var(--global-border-radius-large);
+ background: var(--systemPrimary-onDark);
+ box-shadow: var(--shadow-small);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ article {
+ background: var(--systemQuaternary);
+ }
+ }
+
+ .app-icon-container {
+ --artwork-override-height: 100px;
+ --artwork-override-width: auto;
+ display: flex;
+ margin-bottom: 10px;
+ }
+
+ .metadata-container {
+ flex-grow: 1;
+ }
+
+ h3 {
+ margin-bottom: 3px;
+ font: var(--title-2-emphasized);
+ }
+
+ h4 {
+ margin-bottom: 3px;
+ color: var(--systemSecondary);
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ }
+
+ p {
+ margin: 3px 0;
+ font: var(--body);
+ color: var(--systemSecondary);
+ text-wrap: pretty;
+ }
+
+ .tertiary-text {
+ font: var(--callout);
+ color: var(--systemTertiary);
+ }
+</style>
diff --git a/src/components/jet/item/LargeStoryCardItem.svelte b/src/components/jet/item/LargeStoryCardItem.svelte
new file mode 100644
index 0000000..66079c2
--- /dev/null
+++ b/src/components/jet/item/LargeStoryCardItem.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+ import type { TodayCard } from '@jet-app/app-store/api/models';
+
+ import Hero from '~/components/hero/Hero.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+ import mediaQueries from '~/utils/media-queries';
+ import { isRtl } from '~/utils/locale';
+
+ export let item: TodayCard;
+
+ let profile: NamedProfile;
+
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: artwork = item.heroMedia?.artworks[0];
+ $: video = isXSmallViewport ? null : item.heroMedia?.videos[0];
+ $: ({ backgroundColor, clickAction, heading, inlineDescription, title } =
+ item);
+ $: profile = isXSmallViewport
+ ? 'large-hero-story-card-portrait'
+ : isRtl()
+ ? 'large-hero-story-card-rtl'
+ : 'large-hero-story-card';
+</script>
+
+<Hero
+ {artwork}
+ {backgroundColor}
+ {title}
+ {video}
+ action={clickAction}
+ eyebrow={heading}
+ subtitle={inlineDescription}
+ pinArtworkToVerticalMiddle={true}
+ pinArtworkToHorizontalEnd={true}
+ pinTextToVerticalStart={isRtl()}
+ profileOverride={profile}
+ isMediaDark={item.style !== 'white'}
+/>
diff --git a/src/components/jet/item/LinkableTextItem.svelte b/src/components/jet/item/LinkableTextItem.svelte
new file mode 100644
index 0000000..a5a3e74
--- /dev/null
+++ b/src/components/jet/item/LinkableTextItem.svelte
@@ -0,0 +1,88 @@
+<script lang="ts">
+ import type { LinkableText, Action } from '@jet-app/app-store/api/models';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: LinkableText;
+
+ type Fragment = {
+ text: string;
+ action?: Action;
+ isTrailingPunctuation?: boolean;
+ };
+
+ const {
+ linkedSubstrings = {},
+ styledText: { rawText },
+ } = item;
+
+ // `LinkableText` items contain a `rawText` string, and an object of `linkedSubstrings`,
+ // where the key of the object is the substring to replace in the `rawText` and whose value
+ // is the `Action` that the link should trigger.
+ //
+ // That means we have to render replace the keys from `linkedSubstrings` in the `rawText`.
+ // To do this, we build a regex to match all the strings that are supposed to be linked,
+ // then build an array of objects representing the fully text, with the `Action` appended
+ // to the fragments that need to be linked.
+ const fragmentsToLink = Object.keys(linkedSubstrings);
+ let fragments: Fragment[];
+
+ if (fragmentsToLink.length === 0) {
+ fragments = [{ text: rawText }];
+ } else {
+ // Escapes regex-sensitive characters in the text, so characters like `.` or `+` don't act as regex operators
+ const cleanedFragmentsToLink = fragmentsToLink.map((fragment) =>
+ fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
+ );
+
+ const pattern = new RegExp(
+ `(${cleanedFragmentsToLink.join('|')})`,
+ 'g',
+ );
+
+ // After we split our text into an array representing the seqence of the raw text, with the
+ // linkable items as their own entries, we transform the array to contain include the linkable
+ // items actions, which we then use to determine if we want to render a `LinkWrapper` or plain-text.
+ fragments = rawText.split(pattern).map((fragment): Fragment => {
+ const action = linkedSubstrings[fragment];
+
+ if (action) {
+ return { action, text: fragment };
+ } else {
+ const isTrailingPunctuation = /^[.,;:!?)\]}"”»']+$/.test(
+ fragment.trim(),
+ );
+
+ return {
+ isTrailingPunctuation,
+ text: fragment,
+ };
+ }
+ });
+ }
+</script>
+
+{#each fragments as fragment}
+ {#if fragment.action}
+ <LinkWrapper
+ action={fragment.action}
+ includeExternalLinkArrowIcon={false}
+ >
+ {fragment.text}
+ </LinkWrapper>
+ {:else if fragment.isTrailingPunctuation}
+ <span class="trailing-punctuation">{fragment.text}</span>
+ {:else}
+ {@html sanitizeHtml(fragment.text)}
+ {/if}
+{/each}
+
+<style>
+ span :global(a:hover) {
+ text-decoration: underline;
+ }
+
+ .trailing-punctuation {
+ margin-inline-start: -0.45ch;
+ }
+</style>
diff --git a/src/components/jet/item/MediumImageLockupItem.svelte b/src/components/jet/item/MediumImageLockupItem.svelte
new file mode 100644
index 0000000..8b93453
--- /dev/null
+++ b/src/components/jet/item/MediumImageLockupItem.svelte
@@ -0,0 +1,118 @@
+<script lang="ts">
+ import type { ImageLockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: ImageLockup;
+
+ const color: string = item.artwork.backgroundColor
+ ? colorAsString(item.artwork.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper action={item.lockup.clickAction}>
+ <div class="container">
+ <HoverWrapper>
+ <div class="artwork-container">
+ <Artwork artwork={item.artwork} profile="brick" />
+ </div>
+
+ {#if item.lockup}
+ <div
+ class="lockup-container"
+ class:on-dark={item.isDark}
+ class:on-light={!item.isDark}
+ >
+ {#if item.lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={item.lockup.icon} />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if item.lockup.heading}
+ <LineClamp clamp={1}>
+ <p class="eyebrow">{item.lockup.heading}</p>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.title}
+ <LineClamp clamp={2}>
+ <h3>{item.lockup.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.lockup.subtitle}
+ <LineClamp clamp={1}>
+ <p class="subtitle">{item.lockup.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+ {/if}
+
+ <GradientOverlay --color={color} --height="90%" />
+ </HoverWrapper>
+ </div>
+</LinkWrapper>
+
+<style>
+ .artwork-container {
+ width: 100%;
+ }
+
+ .container {
+ container-type: inline-size;
+ container-name: container;
+ }
+
+ .lockup-container {
+ position: absolute;
+ z-index: 2;
+ bottom: 0;
+ display: flex;
+ align-items: center;
+ width: 100%;
+ padding: 0 20px 20px;
+ }
+
+ .lockup-container.on-dark {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .lockup-container.on-light {
+ color: var(--systemPrimary-onLight);
+ }
+
+ @container container (max-width: 260px) {
+ .lockup-container {
+ padding: 0 10px 10px;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 8px;
+ }
+
+ h3 {
+ font: var(--title-3-emphasized);
+ }
+
+ .eyebrow {
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: plus-lighter;
+ }
+
+ .subtitle {
+ font: var(--callout-emphasized);
+ }
+</style>
diff --git a/src/components/jet/item/MediumLockupItem.svelte b/src/components/jet/item/MediumLockupItem.svelte
new file mode 100644
index 0000000..be70acb
--- /dev/null
+++ b/src/components/jet/item/MediumLockupItem.svelte
@@ -0,0 +1,96 @@
+<script lang="ts">
+ import {
+ type FlowAction,
+ type Lockup,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import type { Opt } from '@jet/environment';
+
+ export let item: Lockup;
+
+ const i18n = getI18n();
+
+ const { clickAction } = item;
+ const destination: Opt<FlowAction> = isFlowAction(clickAction)
+ ? clickAction
+ : undefined;
+</script>
+
+<LinkWrapper action={destination}>
+ <article>
+ <div class="app-icon-container">
+ <AppIcon
+ icon={item.icon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+
+ <div class="metadata-container">
+ {#if item.heading}
+ <span class="heading">{item.heading}</span>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+
+ {#if destination}
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ {/if}
+ </div>
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ display: flex;
+ align-items: center;
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 85px;
+ margin-inline-end: 16px;
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ margin-bottom: 2px;
+ }
+
+ p {
+ font: var(--callout);
+ color: var(--systemSecondary);
+ }
+
+ .heading {
+ font: var(--callout-emphasized);
+ }
+
+ .button-container {
+ margin-inline-start: auto;
+ margin-top: 8px;
+ }
+</style>
diff --git a/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte
new file mode 100644
index 0000000..7b7807c
--- /dev/null
+++ b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte
@@ -0,0 +1,304 @@
+<script lang="ts">
+ import {
+ isFlowAction,
+ type EditorialStoryCard,
+ type FlowAction,
+ } from '@jet-app/app-store/api/models';
+ import type { Opt } from '@jet/environment';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+
+ export let item: EditorialStoryCard;
+
+ let {
+ clickAction,
+ collectionIcons,
+ title,
+ lockup: { title: lockupTitle, subtitle, heading: lockupHeading } = {},
+ } = item;
+ const i18n = getI18n();
+ const hasMultipleCollectionIcons = (collectionIcons?.length ?? 0) > 1;
+ const destination: Opt<FlowAction> =
+ clickAction && isFlowAction(clickAction) ? clickAction : undefined;
+</script>
+
+<LinkWrapper action={destination}>
+ <article>
+ {#if item.artwork}
+ <div class="artwork-container">
+ <HoverWrapper element="div">
+ <Artwork
+ artwork={item.artwork}
+ profile="editorial-story-card"
+ />
+ </HoverWrapper>
+ </div>
+ {/if}
+ <div class="details-container">
+ <div
+ class="title-container"
+ class:on-dark={item.isMediaDark}
+ class:on-light={!item.isMediaDark}
+ >
+ {#if item.badge}
+ <h4>{item.badge.title}</h4>
+ {/if}
+
+ {#if item.title}
+ <h3>{@html sanitizeHtml(item.title)}</h3>
+ {/if}
+
+ {#if item.description}
+ <p>{@html sanitizeHtml(item.description)}</p>
+ {/if}
+ </div>
+
+ {#if collectionIcons && !item.editorialDisplayOptions.suppressLockup}
+ <div class="lockup-container">
+ <ul class:with-multiple-icons={hasMultipleCollectionIcons}>
+ {#each collectionIcons as collectionIcon}
+ <li class="app-icon-container">
+ <AppIcon
+ icon={collectionIcon}
+ fixedWidth={false}
+ profile={hasMultipleCollectionIcons
+ ? 'app-icon-medium'
+ : 'app-icon'}
+ />
+ </li>
+ {/each}
+ </ul>
+
+ {#if !hasMultipleCollectionIcons}
+ <div class="metadata-container">
+ {#if lockupHeading}
+ <span class="lockup-eyebrow">
+ {lockupHeading}
+ </span>
+ {/if}
+
+ <!--
+ Some cards with the lockup UI don't have a `lockup` property,
+ so we use the title of the item as a fallback.
+ -->
+ {#if lockupTitle || title}
+ <LineClamp clamp={1}>
+ <h4 class="lockup-title">
+ {lockupTitle || title}
+ </h4>
+ </LineClamp>
+ {/if}
+
+ {#if subtitle}
+ <LineClamp clamp={1}>
+ <p class="lockup-subtitle">{subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ {#if destination}
+ <div class="button-container">
+ <span class="get-button transparent">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ {/if}
+ {/if}
+ </div>
+ {/if}
+ </div>
+ <div
+ class="blur-overlay"
+ style:--brightness={item.isMediaDark ? 0.75 : 1.25}
+ />
+ </article>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--global-border-radius-large);
+ box-shadow: var(--shadow-medium);
+ aspect-ratio: 3/4;
+ container-type: inline-size;
+ container-name: card;
+ }
+
+ .artwork-container {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ }
+
+ .details-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ height: 100%;
+ border-radius: var(--global-border-radius-large);
+ overflow: hidden;
+ z-index: 1;
+ }
+
+ .title-container {
+ padding: 20px;
+ z-index: 2;
+ }
+
+ .title-container h3 {
+ margin-bottom: 2px;
+ font: var(--title-1-emphasized);
+ text-wrap: pretty;
+ }
+
+ .title-container h4 {
+ font: var(--callout-emphasized);
+ }
+
+ .on-dark {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .on-light {
+ color: var(--systemPrimary-onLight);
+ }
+
+ .title-container.on-dark h4 {
+ color: var(--systemSecondary-onDark);
+ mix-blend-mode: plus-lighter;
+ }
+
+ .title-container.on-light h4 {
+ color: var(--systemSecondary-onLight);
+ }
+
+ .title-container.on-dark p {
+ font: var(--body);
+ color: var(--systemSecondary-onDark);
+ }
+
+ .title-container.on-light p {
+ font: var(--body);
+ color: var(--systemSecondary-onLight);
+ }
+
+ .lockup-container {
+ display: flex;
+ align-items: center;
+ min-height: 80px;
+ padding: 10px 20px;
+ color: var(--systemPrimary-onDark);
+ background-image: linear-gradient(rgba(0, 0, 0, 0.2) 0 0);
+ z-index: 2;
+ }
+
+ .metadata-container {
+ flex-grow: 1;
+ margin-inline-end: 16px;
+ }
+
+ .lockup-title {
+ font: var(--title-3-emphasized);
+ }
+
+ .lockup-eyebrow {
+ color: var(--systemSecondary-onDark);
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: plus-lighter;
+ }
+
+ .lockup-subtitle {
+ color: var(--systemSecondary-onDark);
+ font: var(--callout);
+ mix-blend-mode: plus-lighter;
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 16px;
+ }
+
+ article:hover .blur-overlay {
+ height: 52%;
+ backdrop-filter: blur(70px) saturate(1.5)
+ brightness(calc(var(--brightness) * 0.9));
+ }
+
+ .blur-overlay {
+ position: absolute;
+ z-index: 1;
+ top: unset;
+ bottom: 0;
+ width: 100%;
+ height: 50%;
+ border-radius: var(--global-border-radius-large);
+ mask-image: linear-gradient(
+ 180deg,
+ rgba(255, 255, 255, 0) 5%,
+ rgba(0, 0, 0, 1) 50%
+ );
+ backdrop-filter: blur(50px) saturate(1.5)
+ brightness((var(--brightness)));
+ transition-property: height, backdrop-filter;
+ transition-duration: 210ms;
+ transition-timing-function: ease-out;
+ }
+
+ ul.with-multiple-icons {
+ width: 100%;
+ display: grid;
+ gap: 12px;
+
+ .app-icon-container {
+ width: 100%;
+ margin-inline-end: unset;
+ }
+ }
+
+ // In the following container queries, we are specifying column counts and hiding icons past
+ // that number to ensure a reasonable number of icons are shown for different size cards.
+ @container card (max-width: 300px) {
+ ul.with-multiple-icons {
+ // Think of "4" as the number of columns to show
+ grid-template-columns: repeat(4, 1fr);
+ }
+
+ // And "5" as the number of columns to hide past
+ .app-icon-container:nth-child(n + 5) {
+ display: none;
+ }
+ }
+
+ @container card (min-width: 300px) and (max-width: 400px) {
+ ul.with-multiple-icons {
+ grid-template-columns: repeat(5, 1fr);
+ }
+
+ .app-icon-container:nth-child(n + 6) {
+ display: none;
+ }
+ }
+
+ @container card (min-width: 400px) {
+ ul.with-multiple-icons {
+ grid-template-columns: repeat(6, 1fr);
+ }
+
+ .app-icon-container:nth-child(n + 7) {
+ display: none;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/MediumStoryCardItem.svelte b/src/components/jet/item/MediumStoryCardItem.svelte
new file mode 100644
index 0000000..80ead7d
--- /dev/null
+++ b/src/components/jet/item/MediumStoryCardItem.svelte
@@ -0,0 +1,27 @@
+<script lang="ts" context="module">
+ import type {
+ EditorialStoryCard,
+ TodayCard,
+ } from '@jet-app/app-store/api/models';
+
+ export type Item = EditorialStoryCard | TodayCard;
+
+ function isEditorialStoryCard(item: Item): item is EditorialStoryCard {
+ return 'artwork' in item;
+ }
+</script>
+
+<script lang="ts">
+ import EditorialStoryCardItem from '~/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte';
+ import SmallStoryCardWithMediaItem, {
+ isSmallStoryCardWithMediaItem,
+ } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte';
+
+ export let item: Item;
+</script>
+
+{#if isEditorialStoryCard(item)}
+ <EditorialStoryCardItem {item} />
+{:else if isSmallStoryCardWithMediaItem(item)}
+ <SmallStoryCardWithMediaItem {item} />
+{/if}
diff --git a/src/components/jet/item/MixedMediaLockupItem.svelte b/src/components/jet/item/MixedMediaLockupItem.svelte
new file mode 100644
index 0000000..4874419
--- /dev/null
+++ b/src/components/jet/item/MixedMediaLockupItem.svelte
@@ -0,0 +1,39 @@
+<script lang="ts">
+ import type { MixedMediaLockup } from '@jet-app/app-store/api/models';
+
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: MixedMediaLockup;
+
+ let video = item.trailers?.[0]?.videos[0];
+</script>
+
+<div class="mixed-media-lockup-item">
+ <div class="video-wrapper">
+ {#if video}
+ <Video {video} profile="brick" shouldSuperimposePosterImage />
+ {/if}
+ </div>
+ <SmallLockupItem {item} />
+</div>
+
+<style>
+ .mixed-media-lockup-item {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+
+ .video-wrapper {
+ --mixed-media-lockup-video-aspect-ratio: 16/9;
+ aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio);
+ overflow: hidden;
+ border-radius: 7px;
+ }
+
+ .video-wrapper :global(video) {
+ aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio);
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/item/ParagraphShelfItem.svelte b/src/components/jet/item/ParagraphShelfItem.svelte
new file mode 100644
index 0000000..9adf09c
--- /dev/null
+++ b/src/components/jet/item/ParagraphShelfItem.svelte
@@ -0,0 +1,21 @@
+<script lang="ts">
+ import type { Paragraph } from '@jet-app/app-store/api/models';
+ import he from 'he';
+
+ export let item: Paragraph;
+</script>
+
+<p>
+ {@html he.decode(item.text)}
+</p>
+
+<style>
+ p {
+ font: var(--title-2-medium);
+ color: var(--systemSecondary);
+ }
+
+ p :global(b) {
+ color: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/item/PosterLockupItem.svelte b/src/components/jet/item/PosterLockupItem.svelte
new file mode 100644
index 0000000..08b34e2
--- /dev/null
+++ b/src/components/jet/item/PosterLockupItem.svelte
@@ -0,0 +1,121 @@
+<script lang="ts">
+ import type { PosterLockup } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+
+ export let item: PosterLockup;
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ <article>
+ <div class="background">
+ {#if item.epicHeading}
+ <div class="title-container">
+ <Artwork
+ hasTransparentBackground
+ artwork={item.epicHeading}
+ alt={item.heading}
+ profile="poster-title"
+ />
+ </div>
+ {/if}
+
+ {#if item.posterVideo}
+ <div class="video-container">
+ <Video
+ autoplay
+ loop
+ video={item.posterVideo}
+ useControls={false}
+ profile="poster-lockup"
+ />
+ </div>
+ {:else if item.posterArtwork}
+ <div class="artwork-container">
+ <Artwork
+ artwork={item.posterArtwork}
+ profile="poster-lockup"
+ />
+ </div>
+ {/if}
+ </div>
+
+ <div class="content">
+ <div class="logo-container">
+ <AppleArcadeLogo aria-label={item.heading} />
+ </div>
+
+ <span>
+ {item.footerText}
+ {#if item.tertiaryTitle}
+ | {item.tertiaryTitle}
+ {/if}
+ </span>
+ </div>
+ </article>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style>
+ article {
+ position: relative;
+ width: 100%;
+ aspect-ratio: 16/9;
+ overflow: hidden;
+ color: var(--systemPrimary-onDark);
+ border-radius: var(--global-border-radius-large);
+ container-type: inline-size;
+ container-name: poster-lockup-item;
+ }
+
+ .title-container {
+ position: absolute;
+ z-index: 2;
+ width: 100%;
+ }
+
+ .background {
+ position: absolute;
+ z-index: -1;
+ width: 100%;
+ line-height: 0;
+ }
+
+ .content {
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: space-between;
+ height: 100%;
+ padding: 12px 0;
+ font: var(--body);
+ background: linear-gradient(
+ 0deg,
+ rgba(0, 0, 0, 0.5) 0%,
+ rgba(255, 255, 255, 0) 25%,
+ rgba(255, 255, 255, 0) 50%,
+ rgba(255, 255, 255, 0) 80%,
+ rgba(0, 0, 0, 0.4) 100%
+ );
+ }
+
+ .logo-container {
+ width: 62px;
+ margin-bottom: 10px;
+ line-height: 0;
+ }
+
+ @container poster-lockup-item (min-width: 550px) {
+ .logo-container {
+ width: 78px;
+ }
+ }
+
+ .logo-container :global(path) {
+ color: var(--systemPrimary-onDark);
+ }
+</style>
diff --git a/src/components/jet/item/PrivacyHeaderItem.svelte b/src/components/jet/item/PrivacyHeaderItem.svelte
new file mode 100644
index 0000000..f9611a6
--- /dev/null
+++ b/src/components/jet/item/PrivacyHeaderItem.svelte
@@ -0,0 +1,41 @@
+<script lang="ts">
+ import type { PrivacyHeader } from '@jet-app/app-store/api/models';
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ export let item: PrivacyHeader;
+</script>
+
+<div>
+ <p>
+ <LinkableTextItem item={item.bodyText} />
+ </p>
+
+ {#if item.supplementaryItems.length}
+ <div class="supplementary-items-container">
+ {#each item.supplementaryItems as supItem}
+ <p>
+ <LinkableTextItem item={supItem.bodyText} />
+ </p>
+ {/each}
+ </div>
+ {/if}
+</div>
+
+<style>
+ p {
+ font: var(--body-tall);
+ }
+
+ p :global(a) {
+ color: var(--keyColor);
+ }
+
+ .supplementary-items-container {
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 20px 0 0;
+ margin-top: 20px;
+ border-top: 1px solid var(--systemGray4);
+ }
+</style>
diff --git a/src/components/jet/item/PrivacyTypeItem.svelte b/src/components/jet/item/PrivacyTypeItem.svelte
new file mode 100644
index 0000000..5e63966
--- /dev/null
+++ b/src/components/jet/item/PrivacyTypeItem.svelte
@@ -0,0 +1,193 @@
+<script lang="ts">
+ import type { PrivacyType } from '@jet-app/app-store/api/models';
+
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let item: PrivacyType;
+ export let isDetailView: boolean = false;
+</script>
+
+<article class:is-detail-view={isDetailView}>
+ {#if item.artwork && isSystemImageArtwork(item.artwork)}
+ <span class="icon-container" aria-hidden="true">
+ <SystemImage artwork={item.artwork} />
+ </span>
+ {/if}
+
+ <h2>{item.title}</h2>
+ <p>{item.detail}</p>
+
+ <ul class:grid={item.categories.length > 1 && !isDetailView}>
+ {#each item.categories as category}
+ <li>
+ {#if isSystemImageArtwork(category.artwork)}
+ <span aria-hidden="true" class="category-icon-container">
+ <SystemImage artwork={category.artwork} />
+ </span>
+ {/if}
+ {category.title}
+ </li>
+ {/each}
+ </ul>
+
+ {#each item.purposes as purpose}
+ <section class="purpose-section">
+ <h3>{purpose.title}</h3>
+
+ {#each purpose.categories as category}
+ <li class="purpose-category">
+ {#if isSystemImageArtwork(category.artwork)}
+ <span
+ aria-hidden="true"
+ class="category-icon-container"
+ >
+ <SystemImage artwork={category.artwork} />
+ </span>
+ {/if}
+
+ <span class="category-title">{category.title}</span>
+
+ <ul class="privacy-data-types">
+ {#each category.dataTypes as type}
+ <li>{type}</li>
+ {/each}
+ </ul>
+ </li>
+ {/each}
+ </section>
+ {/each}
+</article>
+
+<style lang="scss">
+ @use 'amp/stylekit/core/border-radiuses' as *;
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ article {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ padding: 30px;
+ gap: 8px;
+ text-align: center;
+ font: var(--body-tall);
+ border-radius: $global-border-radius-rounded-large;
+ background-color: var(--systemQuinary);
+
+ &.is-detail-view {
+ padding: 20px 0 0;
+ margin-top: 20px;
+ text-align: left;
+ border-radius: 0;
+ background-color: transparent;
+ border-top: 1px solid var(--defaultLine);
+ }
+ }
+
+ .icon-container {
+ width: 30px;
+ margin: 0 auto;
+
+ .is-detail-view & {
+ display: block;
+ width: 32px;
+ margin: 0;
+ }
+ }
+
+ .icon-container :global(svg) {
+ width: 100%;
+ fill: var(--keyColor);
+ }
+
+ h2 {
+ font: var(--title-3-emphasized);
+
+ .is-detail-view & {
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ p {
+ text-wrap: pretty;
+ font: var(--body-tall);
+ color: var(--systemSecondary);
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 10px;
+ }
+
+ li {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: start;
+ padding: 4px 0;
+ gap: 8px;
+
+ .is-detail-view & {
+ justify-content: start;
+ }
+ }
+
+ .category-title {
+ font: var(--title-3);
+ }
+
+ .grid li {
+ justify-content: start;
+ }
+
+ .category-icon-container {
+ display: inline-flex;
+
+ @media (prefers-color-scheme: dark) {
+ filter: invert(1);
+ }
+
+ .is-detail-view & {
+ display: flex;
+ align-items: center;
+ }
+ }
+
+ .category-icon-container :global(svg) {
+ width: 20px;
+
+ .is-detail-view & {
+ width: 20px;
+ height: 18px;
+ }
+ }
+
+ .purpose-section {
+ border-top: 1px solid var(--defaultLine);
+ padding-top: 16px;
+ }
+
+ .purpose-section + .purpose-section {
+ margin-top: 4px;
+ }
+
+ .purpose-section h3 {
+ margin-bottom: 8px;
+ }
+
+ .purpose-category {
+ display: grid;
+ grid-template-areas:
+ 'icon title'
+ '. types';
+ align-items: center;
+ }
+
+ .privacy-data-types {
+ grid-area: types;
+ color: var(--systemSecondary);
+ font: var(--body);
+ }
+</style>
diff --git a/src/components/jet/item/ProductBadgeItem.svelte b/src/components/jet/item/ProductBadgeItem.svelte
new file mode 100644
index 0000000..fa32e6f
--- /dev/null
+++ b/src/components/jet/item/ProductBadgeItem.svelte
@@ -0,0 +1,188 @@
+<script lang="ts">
+ import type { Badge } from '@jet-app/app-store/api/models';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import StarRating from '~/components/StarRating.svelte';
+ import GameController from '~/sf-symbols/gamecontroller.fill.svg';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import ContentRatingBadge, {
+ isContentRatingBadge,
+ } from '../badge/ContentRatingBadge.svelte';
+
+ export let item: Badge;
+
+ const { artwork, content, type } = item;
+
+ $: isParagraph = type === 'paragraph';
+ $: isRating = type === 'rating';
+ $: isEditorsChoice = type === 'editorsChoice';
+ $: isController = type === 'controller';
+ $: hasImageArtwork = artwork && !isSystemImageArtwork(artwork);
+</script>
+
+<LinkWrapper withoutLabel action={item.clickAction}>
+ <div class="badge-container">
+ <div class="badge">
+ <div class="badge-dt" role="term">
+ <LineClamp clamp={1}>
+ {item.heading}
+ </LineClamp>
+ </div>
+
+ <div class="badge-dd" role="definition">
+ {#if isContentRatingBadge(item)}
+ <ContentRatingBadge badge={item} />
+ {:else if isParagraph}
+ <span class="text-container">{content.paragraphText}</span>
+ {:else if isRating && !content.rating}
+ <span class="text-container">
+ {content.ratingFormatted}
+ </span>
+ {:else if isEditorsChoice}
+ <span class="editors-choice">
+ <SFSymbol name="laurel.leading" ariaHidden={true} />
+
+ <span>
+ <LineClamp clamp={2}>
+ {item.accessibilityTitle}
+ </LineClamp>
+ </span>
+
+ <SFSymbol name="laurel.trailing" ariaHidden={true} />
+ </span>
+ {:else if artwork && hasImageArtwork}
+ <div class="artwork-container" aria-hidden="true">
+ <Artwork
+ {artwork}
+ profile="app-icon"
+ hasTransparentBackground
+ />
+ </div>
+ {:else if artwork && isSystemImageArtwork(artwork)}
+ <div class="icon-container color" aria-hidden="true">
+ <SystemImage {artwork} />
+ </div>
+ {:else if isController}
+ <div class="icon-container" aria-hidden="true">
+ <GameController />
+ </div>
+ {/if}
+
+ {#if isRating && content.rating}
+ <span class="text-container" aria-hidden="true">
+ {content.ratingFormatted}
+ </span>
+ <StarRating rating={content.rating} />
+ {:else}
+ <LineClamp clamp={1}>{item.caption}</LineClamp>
+ {/if}
+ </div>
+ </div>
+ </div>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .badge-container {
+ --color: var(--systemGray3-onDark);
+ --accent-color: var(--systemSecondary);
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ transition: filter 210ms ease-in;
+
+ @media (prefers-color-scheme: dark) {
+ --color: var(--systemGray3-onLight);
+ }
+ }
+
+ .badge {
+ text-align: center;
+ }
+
+ .artwork-container {
+ height: 25px;
+ aspect-ratio: 1/1;
+ margin: 4px 0 2px;
+ opacity: 0.7;
+
+ @media (prefers-color-scheme: dark) {
+ filter: invert(1);
+ }
+ }
+
+ .icon-container {
+ display: flex;
+ width: 35px;
+ height: 25px;
+ margin: 4px 0 2px;
+ line-height: 0;
+ }
+
+ .icon-container.color {
+ filter: brightness(1);
+ }
+
+ .badge-dt {
+ text-transform: uppercase;
+ font: var(--subhead-emphasized);
+ color: var(--accent-color);
+ margin-bottom: 4px;
+ }
+
+ .text-container {
+ height: 25px;
+ margin: 4px 0 2px;
+ font: var(--title-1-emphasized);
+ color: var(--color);
+ }
+
+ .editors-choice {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 30px;
+
+ :global(svg) {
+ height: 20px;
+ flex-shrink: 0;
+
+ @include rtl {
+ transform: rotateY(180deg);
+ }
+ }
+
+ @media (--range-medium-only) {
+ gap: 2px;
+ }
+
+ :global(svg path:not([fill='none'])) {
+ fill: var(--color);
+ }
+ }
+
+ .editors-choice span {
+ width: 50%;
+ font: var(--subhead-medium);
+
+ @media (--range-medium-only) {
+ width: 55%;
+ }
+ }
+
+ .badge-dd {
+ --fill-color: var(--color);
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ font: var(--subhead-tall);
+ color: var(--color);
+ gap: 4px;
+ }
+</style>
diff --git a/src/components/jet/item/ProductCapabilityItem.svelte b/src/components/jet/item/ProductCapabilityItem.svelte
new file mode 100644
index 0000000..21e97cd
--- /dev/null
+++ b/src/components/jet/item/ProductCapabilityItem.svelte
@@ -0,0 +1,84 @@
+<script lang="ts">
+ import {
+ type ProductCapability,
+ type ProductCapabilityType,
+ } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ type CapabilityIcons = Record<ProductCapabilityType, string | undefined>;
+
+ const capabilityIcons: CapabilityIcons = {
+ gameCenter: '/assets/images/supports/supports-GameCenter@2x.png',
+ siri: '/assets/images/supports/supports-Siri@2x.png',
+ wallet: '/assets/images/supports/supports-Wallet@2x.png',
+ controllers: '/assets/images/supports/supports-GameController@2x.png',
+ familySharing: '/assets/images/supports/supports-FamilySharing@2x.png',
+ sharePlay: '/assets/images/supports/supports-Shareplay@2x.png',
+ spatialControllers:
+ '/assets/images/supports/supports-SpatialController@2x.png',
+ safariExtensions: '/assets/images/supports/supports-Safari@2x.png',
+ };
+
+ export let item: ProductCapability;
+</script>
+
+<article>
+ <div class="capability-icon-container">
+ <img
+ src={capabilityIcons[item.type]}
+ class="capability-icon"
+ alt=""
+ aria-hidden="true"
+ />
+ </div>
+
+ <div class="metadata-container">
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+
+ <p>
+ <LinkableTextItem item={item.caption} />
+ </p>
+ </div>
+</article>
+
+<style>
+ article {
+ display: flex;
+ align-items: center;
+ }
+
+ .capability-icon-container {
+ flex-shrink: 0;
+ width: 48px;
+ margin-inline-end: 16px;
+ }
+
+ .capability-icon {
+ margin-top: 2px;
+ min-width: 46px;
+ height: 46px;
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ .metadata-container :global(a) {
+ color: var(--keyColor);
+ }
+
+ h3 {
+ color: var(--systemPrimary);
+ font-size: 1em;
+ margin-bottom: 1px;
+ }
+
+ p {
+ color: var(--systemSecondary);
+ font: var(--body-tall);
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte
new file mode 100644
index 0000000..516ed32
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte
@@ -0,0 +1,31 @@
+<script lang="ts">
+ import type { ProductMediaItem } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+</script>
+
+{#if item.screenshot}
+ <article>
+ <Artwork artwork={item.screenshot} profile="screenshot-mac" />
+ </article>
+{:else if item.video}
+ <article>
+ <Video autoplay video={item.video} profile="screenshot-mac" />
+ </article>
+{/if}
+
+<style>
+ article {
+ overflow: hidden;
+ }
+
+ article :global(.video) {
+ aspect-ratio: 16/10;
+ }
+
+ article :global(video) {
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte
new file mode 100644
index 0000000..6b9886c
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte
@@ -0,0 +1,89 @@
+<script lang="ts">
+ import type {
+ ProductMediaItem,
+ MediaType,
+ } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+ export let hasPortraitMedia: boolean;
+ export let mediaType: MediaType | undefined;
+</script>
+
+{#if item.screenshot || item.video}
+ <article>
+ <div
+ class="artwork-container"
+ class:ipad-pro-2018={mediaType === 'ipadPro_2018'}
+ class:ipad-11={mediaType === 'ipad_11'}
+ class:portrait={hasPortraitMedia}
+ >
+ {#if item.screenshot}
+ <Artwork
+ artwork={item.screenshot}
+ profile={hasPortraitMedia
+ ? 'screenshot-pad-portrait'
+ : 'screenshot-pad'}
+ />
+ {:else if item.video}
+ <Video
+ autoplay
+ video={item.video}
+ profile={hasPortraitMedia
+ ? 'screenshot-pad-portrait'
+ : 'screenshot-pad'}
+ />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container,
+ .artwork-container :global(video) {
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: contain;
+ border-radius: 1.3% / 1.9%;
+ overflow: hidden;
+
+ /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */
+ transform: translateZ(0);
+ }
+
+ .artwork-container.portrait {
+ aspect-ratio: 3/4;
+ background: var(--systemQuaternary);
+ }
+
+ .artwork-container.portrait,
+ .artwork-container.portrait :global(video) {
+ border-radius: 1.9% / 1.3%;
+ }
+
+ .ipad-pro-2018,
+ .ipad-pro-2018 :global(video) {
+ mask-image: url('/assets/images/masks/ipad-pro-2018-mask-landscape.svg');
+ }
+
+ .ipad-pro-2018.portrait,
+ .ipad-pro-2018.portrait :global(video) {
+ mask-image: url('/assets/images/masks/ipad-pro-2018-mask.svg');
+ }
+
+ .ipad-11,
+ .ipad-11 :global(video) {
+ mask-image: url('/assets/images/masks/ipad-11-mask-landscape.svg');
+ }
+
+ .ipad-11.portrait,
+ .ipad-11.portrait :global(video) {
+ mask-image: url('/assets/images/masks/ipad-11-mask.svg');
+ }
+
+ .artwork-container :global(video):fullscreen {
+ mask-image: none;
+ border-radius: 0;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte
new file mode 100644
index 0000000..255b663
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte
@@ -0,0 +1,142 @@
+<script lang="ts">
+ import type {
+ ProductMediaItem,
+ MediaType,
+ } from '@jet-app/app-store/api/models';
+ import { getAspectRatio } from '@amp/web-app-components/src/components/Artwork/utils/artProfile';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import type { NamedProfile } from '~/config/components/artwork';
+
+ export let item: ProductMediaItem;
+ export let hasPortraitMedia: boolean;
+ export let mediaType: MediaType | undefined;
+
+ const getArtworkProfile = (
+ mediaType: MediaType | undefined,
+ hasPortraitMedia: boolean,
+ ): NamedProfile => {
+ const suffix = hasPortraitMedia ? '_portrait' : '';
+
+ // Map specific media types to their artwork profile names
+ const mediaTypeProfiles: Record<string, string> = {
+ iphone_6_5: 'screenshot-iphone_6_5',
+ iphone_5_8: 'screenshot-iphone_5_8',
+ iphone_d74: 'screenshot-iphone_d74',
+ };
+
+ const baseProfile =
+ mediaType && mediaTypeProfiles[mediaType]
+ ? mediaTypeProfiles[mediaType]
+ : 'screenshot-phone';
+
+ return `${baseProfile}${suffix}` as NamedProfile;
+ };
+
+ $: isLandscapeScreenshot =
+ item.screenshot && item.screenshot.width > item.screenshot.height;
+ $: profile = getArtworkProfile(mediaType, !isLandscapeScreenshot);
+ $: restOfShelfAspectRatio = getAspectRatio(
+ getArtworkProfile(mediaType, hasPortraitMedia),
+ );
+</script>
+
+{#if item.screenshot || item.video}
+ <article
+ class:with-rotated-artwork={isLandscapeScreenshot && hasPortraitMedia}
+ style:--aspect-ratio={`${restOfShelfAspectRatio}`}
+ >
+ <div
+ class="artwork-container"
+ class:iphone-6-5={mediaType === 'iphone_6_5'}
+ class:iphone-5-8={mediaType === 'iphone_5_8'}
+ class:iphone-d74={mediaType === 'iphone_d74'}
+ class:portrait={hasPortraitMedia}
+ >
+ {#if item.screenshot}
+ <Artwork
+ {profile}
+ artwork={item.screenshot}
+ disableAutoCenter={true}
+ withoutBorder={true}
+ />
+ {:else if item.video}
+ <Video autoplay video={item.video} {profile} />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ article.with-rotated-artwork {
+ position: relative;
+ aspect-ratio: var(--aspect-ratio);
+ }
+
+ /*
+ * For iPhone screenshots that are landscape, but in a shelf/list with portrait screenshots,
+ * as denoted by `hasPortraitMedia`, we rotate the landscape screenshot to be in the portrait
+ * orientation, and scale it up so it fills the container.
+ */
+ article.with-rotated-artwork .artwork-container {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ height: auto;
+ width: calc((1 / var(--aspect-ratio)) * 100%);
+ transform: translate(-50%, -50%) rotate(-90deg);
+ transform-origin: center;
+ }
+
+ .artwork-container,
+ .artwork-container :global(video) {
+ mask-position: center;
+ mask-repeat: no-repeat;
+ mask-size: 100%;
+ border-radius: 20px;
+ overflow: hidden;
+
+ /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */
+ transform: translateZ(0);
+ }
+
+ .iphone-5-8,
+ .iphone-5-8 :global(video) {
+ /* need to confirm with design for correct value */
+ border-radius: 23px;
+ mask-image: url('/assets/images/masks/iphone-5-8-mask-landscape.svg');
+ }
+
+ .iphone-5-8.portrait,
+ .iphone-5-8.portrait :global(video) {
+ mask-image: url('/assets/images/masks/iphone-5-8-mask.svg');
+ }
+
+ .iphone-6-5,
+ .iphone-6-5 :global(video) {
+ /* need to confirm with design for correct value */
+ border-radius: 21px;
+ mask-image: url('/assets/images/masks/iphone-6-5-mask-landscape.svg');
+ }
+
+ .iphone-6-5.portrait,
+ .iphone-6-5.portrait :global(video) {
+ mask-image: url('/assets/images/masks/iphone-6-5-mask.svg');
+ }
+
+ .iphone-d74,
+ .iphone-d74 :global(video) {
+ border-radius: 5.7% / 12.8%;
+ }
+
+ .iphone-d74.portrait,
+ .iphone-d74.portrait :global(video) {
+ border-radius: 12.8% / 5.7%;
+ }
+
+ .artwork-container :global(video):fullscreen {
+ mask-image: none;
+ border-radius: 0;
+ object-fit: contain;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte
new file mode 100644
index 0000000..7f7fd7a
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte
@@ -0,0 +1,34 @@
+<script lang="ts">
+ import type { ProductMediaItem } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+</script>
+
+{#if item.screenshot || item.video}
+ <article>
+ <div class="artwork-container">
+ {#if item.screenshot}
+ <Artwork artwork={item.screenshot} profile="screenshot-tv" />
+ {:else if item.video}
+ <Video autoplay video={item.video} profile="screenshot-tv" />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container,
+ .artwork-container :global(video) {
+ border-radius: 1.3% / 1.9%;
+ overflow: hidden;
+
+ /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */
+ transform: translateZ(0);
+ }
+
+ .artwork-container :global(video):fullscreen {
+ border-radius: 0;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte
new file mode 100644
index 0000000..e893dd6
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte
@@ -0,0 +1,38 @@
+<script lang="ts">
+ import type { ProductMediaItem } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: ProductMediaItem;
+</script>
+
+{#if item.screenshot || item.video}
+ <article>
+ <div class="artwork-container">
+ {#if item.screenshot}
+ <Artwork
+ artwork={item.screenshot}
+ profile="screenshot-vision"
+ />
+ {:else if item.video}
+ <Video
+ autoplay
+ video={item.video}
+ profile="screenshot-vision"
+ />
+ {/if}
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container,
+ .artwork-container :global(video) {
+ border-radius: 20px;
+ overflow: hidden;
+ }
+
+ .artwork-container :global(video):fullscreen {
+ border-radius: 0;
+ }
+</style>
diff --git a/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte
new file mode 100644
index 0000000..0a4b50e
--- /dev/null
+++ b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte
@@ -0,0 +1,50 @@
+<script lang="ts">
+ import type {
+ ProductMediaItem,
+ MediaType,
+ } from '@jet-app/app-store/api/models';
+ import Artwork from '~/components/Artwork.svelte';
+
+ export let item: ProductMediaItem;
+ export let mediaType: MediaType | undefined;
+</script>
+
+{#if item.screenshot}
+ <article>
+ <div
+ class="artwork-container"
+ class:apple-watch-2018={mediaType === 'appleWatch_2018'}
+ class:apple-watch-2021={mediaType === 'appleWatch_2021'}
+ class:apple-watch-2022={mediaType === 'appleWatch_2022'}
+ class:apple-watch-2024={mediaType === 'appleWatch_2024'}
+ >
+ <Artwork artwork={item.screenshot} profile="screenshot-watch" />
+ </div>
+ </article>
+{/if}
+
+<style>
+ .artwork-container {
+ mask-position: center;
+ mask-size: contain;
+ mask-repeat: no-repeat;
+ border-radius: 12px;
+ overflow: hidden;
+ }
+
+ .apple-watch-2018 {
+ mask-image: url('/assets/images/masks/apple-watch-2018-mask.svg');
+ }
+
+ .apple-watch-2021 {
+ mask-image: url('/assets/images/masks/apple-watch-2021-mask.svg');
+ }
+
+ .apple-watch-2022 {
+ mask-image: url('/assets/images/masks/apple-watch-2022-mask.svg');
+ }
+
+ .apple-watch-2024 {
+ mask-image: url('/assets/images/masks/apple-watch-2024-mask.svg');
+ }
+</style>
diff --git a/src/components/jet/item/ProductPageLinkItem.svelte b/src/components/jet/item/ProductPageLinkItem.svelte
new file mode 100644
index 0000000..be4bb16
--- /dev/null
+++ b/src/components/jet/item/ProductPageLinkItem.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import {
+ type ProductPageLink,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import { isExternalUrlAction } from '~/jet/models/';
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import ExternalURLAction from '~/components/jet/action/ExternalUrlAction.svelte';
+
+ export let item: ProductPageLink;
+
+ const clickAction = item.clickAction;
+
+ $: canRenderContainer =
+ isFlowAction(clickAction) || isExternalUrlAction(clickAction);
+</script>
+
+{#if canRenderContainer}
+ <div class="product-link-container">
+ {#if isFlowAction(clickAction)}
+ <FlowAction destination={clickAction}>
+ {item.text}
+ </FlowAction>
+ {:else if isExternalUrlAction(clickAction)}
+ <ExternalURLAction destination={clickAction}>
+ {item.text}
+ </ExternalURLAction>
+ {/if}
+ </div>
+{/if}
+
+<style>
+ .product-link-container {
+ @media (--range-xsmall-down) {
+ padding: 10px 0;
+ }
+ }
+
+ .product-link-container :global(a) {
+ display: inline-flex;
+ align-items: center;
+ color: var(--keyColor);
+ text-decoration: none;
+ gap: 6px;
+
+ &:hover {
+ text-decoration: underline;
+ }
+
+ @media (--range-xsmall-down) {
+ font-size: 18px;
+ gap: 8px;
+ }
+ }
+
+ .product-link-container :global(a) :global(.external-link-arrow) {
+ width: 7px;
+ height: 7px;
+ fill: var(--keyColor);
+ margin-top: 3px;
+
+ @media (--range-xsmall-down) {
+ width: 10px;
+ height: 10px;
+ margin-top: 2px;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/ProductRatingsItem.svelte b/src/components/jet/item/ProductRatingsItem.svelte
new file mode 100644
index 0000000..0345993
--- /dev/null
+++ b/src/components/jet/item/ProductRatingsItem.svelte
@@ -0,0 +1,37 @@
+<script lang="ts">
+ import type { Ratings } from '@jet-app/app-store/api/models';
+
+ import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte';
+ import { getJet } from '~/jet/svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Ratings;
+
+ const i18n = getI18n();
+ const jet = getJet();
+ const numberOfRatings = jet.localization.formattedCount(
+ item.totalNumberOfRatings,
+ );
+</script>
+
+<article>
+ {#if item.totalNumberOfRatings === 0}
+ {item.status}
+ {:else}
+ <RatingComponent
+ averageRating={jet.localization.decimal(item.ratingAverage, 1)}
+ ratingCount={item.totalNumberOfRatings}
+ ratingCountText={$i18n.t('ASE.Web.AppStore.Ratings.CountText', {
+ numberOfRatings: numberOfRatings,
+ })}
+ totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')}
+ ratingCountsList={item.ratingCounts}
+ />
+ {/if}
+</article>
+
+<style>
+ article {
+ --ratingBarColor: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte
new file mode 100644
index 0000000..2bb6a06
--- /dev/null
+++ b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte
@@ -0,0 +1,99 @@
+<script lang="ts" context="module">
+ import type {
+ EditorsChoice,
+ ProductReview,
+ } from '@jet-app/app-store/api/models';
+
+ interface EditorsChoiceReview extends ProductReview {
+ sourceType: 'editorsChoice';
+ review: EditorsChoice;
+ }
+
+ export function isEditorsChoiceReviewItem(
+ productReview: ProductReview,
+ ): productReview is EditorsChoiceReview {
+ return productReview.sourceType === 'editorsChoice';
+ }
+</script>
+
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte';
+ import EditorsChoiceBadge from '~/components/EditorsChoiceBadge.svelte';
+ import { getJet } from '~/jet';
+ import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics';
+
+ export let item: EditorsChoiceReview;
+ export let isDetailView: boolean = false;
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const translateFn = (key: string) => $i18n.t(key);
+ const i18n = getI18n();
+ const jet = getJet();
+
+ const handleCloseModal = () => modalComponent?.close();
+ const handleOpenModal = () => {
+ modalComponent?.showModal();
+ jet.recordCustomMetricsEvent({
+ eventType: 'dialog',
+ dialogId: 'more',
+ targetId: CUSTOMER_REVIEW_MODAL_ID,
+ dialogType: 'button',
+ });
+ };
+</script>
+
+<article class:is-detail-view={isDetailView}>
+ <EditorsChoiceBadge
+ --font={isDetailView
+ ? 'var(--large-title-emphasized)'
+ : 'var(--title-1-emphasized)'}
+ />
+
+ {#if isDetailView}
+ <p>{item.review.notes}</p>
+ {:else}
+ <Truncate
+ {translateFn}
+ lines={4}
+ text={item.review.notes}
+ title={$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
+ isPortalModal={true}
+ on:openModal={handleOpenModal}
+ />
+ {/if}
+</article>
+
+{#if !isDetailView}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleCloseModal}
+ title={null}
+ subtitle={null}
+ targetId={CUSTOMER_REVIEW_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <svelte:self {item} isDetailView={true} />
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+{/if}
+
+<style>
+ article:not(.is-detail-view) {
+ height: 186px;
+ padding: 20px;
+ background-color: var(--systemQuinary);
+ border-radius: var(--global-border-radius-xlarge);
+ }
+
+ article :global(.more) {
+ --moreTextColorOverride: var(--keyColor);
+ --moreFontOverride: var(--body);
+ text-transform: lowercase;
+ }
+</style>
diff --git a/src/components/jet/item/ProductReview/UserReviewItem.svelte b/src/components/jet/item/ProductReview/UserReviewItem.svelte
new file mode 100644
index 0000000..472dd1f
--- /dev/null
+++ b/src/components/jet/item/ProductReview/UserReviewItem.svelte
@@ -0,0 +1,25 @@
+<script lang="ts" context="module">
+ import {
+ type Review as ReviewModel,
+ ProductReview,
+ } from '@jet-app/app-store/api/models';
+
+ interface UserReview extends ProductReview {
+ sourceType: 'user';
+ review: ReviewModel;
+ }
+
+ export function isUserReviewItem(
+ productReview: ProductReview,
+ ): productReview is UserReview {
+ return productReview.sourceType === 'user';
+ }
+</script>
+
+<script lang="ts">
+ import ReviewItem from '~/components/jet/item/ReviewItem.svelte';
+
+ export let item: UserReview;
+</script>
+
+<ReviewItem item={item.review} />
diff --git a/src/components/jet/item/ReviewItem.svelte b/src/components/jet/item/ReviewItem.svelte
new file mode 100644
index 0000000..7f406c8
--- /dev/null
+++ b/src/components/jet/item/ReviewItem.svelte
@@ -0,0 +1,237 @@
+<script lang="ts">
+ import type { Review as ReviewModel } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte';
+ import StarRating from '~/components/StarRating.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import { getI18n } from '~/stores/i18n';
+ import { getJet } from '~/jet/svelte';
+ import {
+ escapeHtml,
+ stripUnicodeWhitespace,
+ } from '~/utils/string-formatting';
+ import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics';
+
+ export let item: ReviewModel;
+ export let isDetailView: boolean = false;
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const jet = getJet();
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+
+ const handleCloseModal = () => modalComponent?.close();
+ const handleOpenModal = () => {
+ modalComponent?.showModal();
+ jet.recordCustomMetricsEvent({
+ eventType: 'dialog',
+ dialogId: 'more',
+ targetId: CUSTOMER_REVIEW_MODAL_ID,
+ dialogType: 'button',
+ });
+ };
+
+ $: ({ id, reviewerName, rating, contents, title, date, response } = item);
+ $: dateForDisplay = jet.localization.timeAgo(new Date(date));
+ $: dateForAttribute = new Date(date).toISOString();
+ $: titleId = `review-${id}-title`;
+ $: maximumLinesForReview = response ? 3 : 5;
+ $: responseDateForDisplay =
+ response && jet.localization.timeAgo(new Date(response.date));
+ $: responseDateForAttribute =
+ response && new Date(response.date).toISOString();
+ $: reviewContents = stripUnicodeWhitespace(escapeHtml(contents));
+ $: responseContents =
+ response && stripUnicodeWhitespace(escapeHtml(response.contents));
+</script>
+
+<article class:is-detail-view={isDetailView} aria-labelledby={titleId}>
+ <div class="header">
+ <div class="title-and-rating-container">
+ {#if !isDetailView}
+ <h3 id={titleId} class="title">
+ <LineClamp clamp={1}>
+ {title}
+ </LineClamp>
+ </h3>
+ {/if}
+
+ <StarRating
+ {rating}
+ --fill-color="var(--systemOrange)"
+ --star-size={isDetailView ? '24px' : '12px'}
+ />
+ </div>
+
+ <div class="review-header">
+ <time class="date" datetime={dateForAttribute}>
+ {dateForDisplay}
+ </time>
+
+ <LineClamp clamp={1}>
+ <p class="author">
+ {reviewerName}
+ </p>
+ </LineClamp>
+ </div>
+ </div>
+
+ {#if isDetailView}
+ <p>
+ {@html sanitizeHtml(reviewContents, {
+ allowedTags: [''],
+ keepChildrenWhenRemovingParent: true,
+ })}
+
+ {#if response}
+ <div class="developer-response-container">
+ <div class="developer-response-header">
+ <span class="developer-response-heading">
+ {$i18n.t(
+ 'ASE.Web.AppStore.Review.DeveloperResponse',
+ )}
+ </span>
+
+ <time class="date" datetime={responseDateForAttribute}>
+ {responseDateForDisplay}
+ </time>
+ </div>
+
+ {@html sanitizeHtml(responseContents, {
+ allowedTags: [''],
+ keepChildrenWhenRemovingParent: true,
+ })}
+ </div>
+ {/if}
+ </p>
+ {:else}
+ <div class="content">
+ <Truncate
+ on:openModal={handleOpenModal}
+ {title}
+ lines={maximumLinesForReview}
+ {translateFn}
+ text={reviewContents}
+ isPortalModal={true}
+ />
+
+ {#if item.response}
+ <div class="developer-response-container">
+ <span class="developer-response-heading">
+ {$i18n.t('ASE.Web.AppStore.Review.DeveloperResponse')}
+ </span>
+ <Truncate
+ on:openModal={handleOpenModal}
+ {title}
+ {translateFn}
+ lines={1}
+ text={responseContents}
+ isPortalModal={true}
+ />
+ </div>
+ {/if}
+ </div>
+ {/if}
+</article>
+
+{#if !isDetailView}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleCloseModal}
+ {title}
+ subtitle={null}
+ targetId={CUSTOMER_REVIEW_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <svelte:self {item} isDetailView={true} />
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+{/if}
+
+<style lang="scss">
+ article:not(.is-detail-view) {
+ height: 186px;
+ padding: 20px 16px;
+ background-color: var(--systemQuinary);
+ border-radius: var(--global-border-radius-xlarge);
+
+ @media (--small) {
+ padding: 20px;
+ }
+ }
+
+ .header {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 18px;
+ align-items: center;
+ justify-content: space-between;
+
+ .is-detail-view & {
+ margin-bottom: 0;
+ }
+ }
+
+ .title-and-rating-container {
+ .is-detail-view & {
+ display: flex;
+ }
+ }
+
+ .title {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ margin-bottom: 4px;
+ }
+
+ .date,
+ .author {
+ color: var(--systemSecondary);
+ font: var(--callout);
+ word-break: normal;
+ }
+
+ .content {
+ position: relative;
+ word-wrap: break-word; /* Break to fit the review block, even when people leave a review with long text without spaces */
+ text-align: start;
+ font: var(--body);
+ }
+
+ .review-header {
+ text-align: end;
+ }
+
+ .developer-response-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 5px;
+ margin-top: 20px;
+ }
+
+ .developer-response-heading {
+ font: var(--body-emphasized);
+
+ .is-detail-view & {
+ display: block;
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ .developer-response-container {
+ margin-top: 10px;
+ }
+
+ article :global(.more) {
+ --moreTextColorOverride: var(--keyColor);
+ --moreFontOverride: var(--body);
+ text-transform: lowercase;
+ }
+</style>
diff --git a/src/components/jet/item/SearchLinkItem.svelte b/src/components/jet/item/SearchLinkItem.svelte
new file mode 100644
index 0000000..cd60512
--- /dev/null
+++ b/src/components/jet/item/SearchLinkItem.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import {
+ isFlowAction,
+ type SearchLink,
+ } from '@jet-app/app-store/api/models';
+
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import MagnifyingGlass from '~/sf-symbols/magnifyingglass.svg';
+
+ export let item: SearchLink;
+</script>
+
+{#if isFlowAction(item.clickAction)}
+ <div class="link-container">
+ <FlowAction destination={item.clickAction}>
+ <MagnifyingGlass class="icon" />
+ {item.title}
+ </FlowAction>
+ </div>
+{/if}
+
+<style>
+ .link-container {
+ display: contents;
+ }
+
+ .link-container :global(a) {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ padding: 12px;
+ font: var(--title-2);
+ border-radius: var(--global-border-radius-large);
+ background: var(--systemQuinary);
+ }
+
+ .link-container :global(a:hover) {
+ text-decoration: none;
+ }
+
+ .link-container :global(a) :global(.icon) {
+ overflow: visible;
+ width: 20px;
+ fill: currentColor;
+ }
+</style>
diff --git a/src/components/jet/item/SearchResult/AppSearchResultItem.svelte b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte
new file mode 100644
index 0000000..c36e5fc
--- /dev/null
+++ b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte
@@ -0,0 +1,392 @@
+<script lang="ts" context="module">
+ import type {
+ AppSearchResult,
+ AppEventSearchResult,
+ SearchResult,
+ Trailers,
+ Screenshots,
+ FlowAction,
+ Artwork as ArtworkType,
+ Video as VideoType,
+ } from '@jet-app/app-store/api/models';
+
+ export function isAppSearchResult(
+ result: SearchResult,
+ ): result is AppSearchResult {
+ return result.resultType === 'content';
+ }
+
+ export function isAppEventSearchResult(
+ result: SearchResult,
+ ): result is AppEventSearchResult {
+ return result.resultType === 'appEvent';
+ }
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import type {
+ ImageSizes,
+ Profile,
+ } from '@amp/web-app-components/src/components/Artwork/types';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
+
+ import type { NamedProfile } from '~/config/components/artwork';
+ import { getI18n } from '~/stores/i18n';
+ import AppIcon, {
+ doesAppIconNeedBorder,
+ } from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import StarRating from '~/components/StarRating.svelte';
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { isNamedColor } from '~/utils/color';
+ import mediaQueries from '~/utils/media-queries';
+ import VideoPlayer from '~/components/VideoPlayer.svelte';
+
+ const i18n = getI18n();
+
+ export let item: AppSearchResult;
+
+ $: ({
+ clickAction,
+ heading,
+ isEditorsChoice,
+ rating,
+ ratingCount,
+ screenshots,
+ subtitle,
+ title,
+ trailers,
+ } = item.lockup);
+ let video: VideoType | undefined;
+ let media: (ArtworkType | VideoType)[];
+ let mediaAspectRatio: number;
+ let numberOfMediaToShow: number;
+ let profile: NamedProfile | Profile;
+ let mediaSizes: ImageSizes;
+ let videoPlayerInstance: InstanceType<typeof VideoPlayer> | null = null;
+ let shouldAutoplayVideo: boolean = false;
+
+ const currentPlatform =
+ (item.lockup.clickAction as FlowAction).destination?.platform ?? '';
+
+ function isForCurrentPlatform(media: Trailers | Screenshots) {
+ return media.mediaPlatform.appPlatform === currentPlatform;
+ }
+
+ $: {
+ const selectedTrailer =
+ trailers?.find(isForCurrentPlatform) ?? trailers?.[0];
+ video = selectedTrailer?.videos?.[0];
+
+ const selectedScreenshot =
+ screenshots.find(isForCurrentPlatform) ?? screenshots[0];
+
+ const firstMedia = video
+ ? video.preview
+ : selectedScreenshot.artwork[0];
+ const hasPortraitMedia = firstMedia.width < firstMedia.height;
+ const isMobile = $mediaQueries === 'xsmall' && $sidebarIsHidden;
+
+ mediaAspectRatio = firstMedia.width / firstMedia.height;
+
+ if (!hasPortraitMedia) {
+ numberOfMediaToShow = 1;
+ mediaSizes = isMobile ? [308] : [648, 417, 417, 656];
+ } else if (currentPlatform !== 'iphone') {
+ numberOfMediaToShow = 2;
+ mediaSizes = isMobile ? [150] : [238, 203, 203, 320];
+ } else {
+ numberOfMediaToShow = 3;
+ mediaSizes = isMobile ? [98] : [156, 133, 133, 210];
+ }
+
+ profile = getNaturalProfile(firstMedia, mediaSizes);
+ media = [video, ...selectedScreenshot.artwork]
+ .filter(Boolean)
+ .slice(0, numberOfMediaToShow) as (ArtworkType | VideoType)[];
+ }
+
+ function handleMouseEnter() {
+ videoPlayerInstance?.play();
+ }
+
+ function handleMouseLeave() {
+ videoPlayerInstance?.pause();
+ }
+
+ onMount(() => {
+ shouldAutoplayVideo = navigator.maxTouchPoints > 0;
+ });
+</script>
+
+<LinkWrapper
+ action={clickAction}
+ label={`${$i18n.t('ASE.Web.AppStore.View')} ${clickAction.title}`}
+>
+ <article on:mouseenter={handleMouseEnter} on:mouseleave={handleMouseLeave}>
+ <div class="top-container">
+ {#if item.lockup.icon}
+ <div class="app-icon-container">
+ <AppIcon
+ icon={item.lockup.icon}
+ profile="app-icon"
+ withBorder={doesAppIconNeedBorder(item.lockup.icon)}
+ />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if heading}
+ <LineClamp clamp={1}>
+ <h4>{heading}</h4>
+ </LineClamp>
+ {/if}
+
+ <LineClamp clamp={1}>
+ <h3>{title}</h3>
+ </LineClamp>
+
+ <LineClamp clamp={1}>
+ <p>{subtitle}</p>
+ </LineClamp>
+
+ {#if isEditorsChoice}
+ <div class="editors-choice-badge-container">
+ <SFSymbol name="laurel.leading" ariaHidden={true} />
+
+ {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
+
+ <SFSymbol name="laurel.trailing" ariaHidden={true} />
+ </div>
+ {:else if ratingCount}
+ <span class="rating-container">
+ <StarRating
+ {rating}
+ --fill-color="var(--systemGray2-onDark_IC)"
+ />
+ {ratingCount}
+ </span>
+ {/if}
+ </div>
+
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ </div>
+
+ <div
+ class="artwork-container {currentPlatform}"
+ style:--media-aspect-ratio={mediaAspectRatio}
+ >
+ {#each media as mediaItem}
+ {#if 'videoUrl' in mediaItem}
+ <div class="video-wrapper">
+ <Video
+ {profile}
+ loop
+ video={mediaItem}
+ autoplay={shouldAutoplayVideo}
+ useControls={false}
+ autoplayVisibilityThreshold={0.75}
+ bind:videoPlayerRef={videoPlayerInstance}
+ />
+ </div>
+ {:else}
+ <Artwork
+ {profile}
+ artwork={mediaItem}
+ disableAutoCenter={true}
+ useCropCodeFromArtwork={false}
+ />
+ {/if}
+ {/each}
+ </div>
+ </article>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ display: flex;
+ align-items: stretch;
+ flex-direction: column;
+ padding: 16px;
+ border-radius: 28px;
+ box-shadow: var(--shadow-medium);
+ background: #fff;
+ transition: box-shadow 210ms ease-out;
+ width: 100%;
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemQuaternary);
+ }
+ }
+
+ article:hover {
+ box-shadow: 0 5px 28px rgba(0, 0, 0, 0.12);
+ }
+
+ .top-container {
+ align-items: center;
+ width: 100%;
+ padding-bottom: 16px;
+ gap: 8px;
+ }
+
+ .top-container,
+ .metadata-container {
+ display: flex;
+ }
+
+ .metadata-container {
+ flex-direction: column;
+ flex-grow: 1;
+ }
+
+ .rating-container {
+ display: flex;
+ align-items: center;
+ font: var(--subhead-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ .rating-container :global(svg) {
+ @media (prefers-contrast: more) and (prefers-color-scheme: dark) {
+ --fill-color: #fff;
+ }
+ }
+
+ .editors-choice-badge-container {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ font: var(--caption-1-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ .editors-choice-badge-container :global(svg) {
+ height: 14px;
+ overflow: visible;
+
+ @include rtl {
+ transform: rotateY(180deg);
+ }
+ }
+
+ .editors-choice-badge-container :global(svg path) {
+ fill: var(--systemSecondary);
+ }
+
+ h3 {
+ font: var(--headline);
+ }
+
+ h4 {
+ color: var(--systemSecondary);
+ font: var(--footnote-emphasized);
+ text-transform: uppercase;
+ }
+
+ p {
+ font: var(--callout);
+ color: var(--systemSecondary);
+ }
+
+ .artwork-container {
+ --container-aspect-ratio: 1.333;
+ --artwork-override-object-fit: contain;
+ --artwork-override-height: auto;
+ --artwork-override-width: 100%;
+ --artwork-override-max-height: 100%;
+ --artwork-override-max-width: 100%;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+ height: calc(100% * var(--container-aspect-ratio));
+ aspect-ratio: var(--container-aspect-ratio);
+ border-radius: var(--global-border-radius-medium);
+
+ &.iphone {
+ --container-aspect-ratio: 1.444;
+ }
+
+ &.ipad {
+ --container-aspect-ratio: 1.54;
+ }
+
+ &.mac {
+ --container-aspect-ratio: 1.6;
+ }
+
+ &.watch {
+ --container-aspect-ratio: 1.636;
+ }
+
+ &.tv,
+ &.vision {
+ --container-aspect-ratio: 1.77;
+ }
+ }
+
+ // Centers a single item in the grid
+ .artwork-container :global(> :only-child) {
+ justify-self: center;
+ }
+
+ // Aligns the first of two items to the center edge
+ .artwork-container :global(> :nth-child(1):nth-last-child(2)) {
+ justify-self: flex-end;
+ }
+
+ // Aligns the second of two items to the center edge
+ .artwork-container :global(> :nth-child(2):nth-last-child(1)) {
+ justify-self: flex-start;
+ }
+
+ .video-wrapper {
+ display: flex;
+ overflow: hidden;
+ max-height: 100%;
+ width: auto;
+ aspect-ratio: var(--media-aspect-ratio, 16/9);
+ border: 1px solid var(--systemQuaternary);
+ border-radius: 16px;
+ }
+
+ .artwork-container :global(.artwork-component) {
+ display: flex;
+ aspect-ratio: var(--media-aspect-ratio);
+ border-radius: 16px;
+ justify-content: center;
+ align-items: center;
+ width: auto;
+ height: auto;
+ max-width: 100%;
+ max-height: 100%;
+ }
+
+ .artwork-container :global(.artwork-component img) {
+ height: 100%;
+ }
+
+ .artwork-container :global(.video-container) {
+ container-type: normal;
+ }
+
+ .artwork-container :global(video) {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/item/SmallBreakoutItem.svelte b/src/components/jet/item/SmallBreakoutItem.svelte
new file mode 100644
index 0000000..311fbef
--- /dev/null
+++ b/src/components/jet/item/SmallBreakoutItem.svelte
@@ -0,0 +1,187 @@
+<script lang="ts">
+ import {
+ type Artwork as JetArtworkType,
+ type SmallBreakout,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+ import { isSome } from '@jet/environment/types/optional';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: SmallBreakout;
+
+ $: ({ backgroundColor, iconArtwork, clickAction: action = null } = item);
+
+ $: backgroundColorForCss = backgroundColor
+ ? colorAsString(backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper {action}>
+ <HoverWrapper>
+ <div class="container" style:--background-color={backgroundColorForCss}>
+ {#if iconArtwork}
+ <div class="artwork-container">
+ <AppIcon
+ icon={iconArtwork}
+ profile="app-icon-xlarge"
+ fixedWidth={false}
+ />
+ </div>
+ {/if}
+
+ <div
+ class="text-container"
+ class:with-dark-background={item.details.backgroundStyle ===
+ 'dark'}
+ >
+ {#if item.details?.badge}
+ <LineClamp clamp={1}>
+ <h4>{item.details.badge}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.title}
+ <LineClamp clamp={2}>
+ <h3>{item.details.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.details.description}
+ <LineClamp clamp={3}>
+ <p>{item.details.description}</p>
+ </LineClamp>
+ {/if}
+
+ {#if isSome(action) && isFlowAction(action)}
+ <span class="link-container">
+ {action.title}
+ <span aria-hidden="true">
+ <SFSymbol name="chevron.forward" />
+ </span>
+ </span>
+ {/if}
+ </div>
+ </div>
+ </HoverWrapper>
+</LinkWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .container {
+ width: 100%;
+ max-height: 460px;
+ aspect-ratio: 16/9;
+ background-color: var(--background-color);
+ container-type: inline-size;
+ container-name: container;
+
+ @media (--range-small-up) {
+ aspect-ratio: 13/5;
+ }
+ }
+
+ .artwork-container {
+ --rotation: -30deg;
+ position: absolute;
+ width: 33%;
+ max-width: 430px;
+ inset-inline-end: -10%;
+ transform: translateY(-8%) rotate(var(--rotation));
+
+ @include rtl {
+ --rotation: 30deg;
+ }
+ }
+
+ @container container (min-width: 1150px) {
+ .artwork-container {
+ transform: translateY(-11%) rotate(var(--rotation));
+ }
+ }
+
+ .artwork-container :global(.artwork-component) {
+ --angle: -7px;
+ box-shadow: var(--angle) 5px 12px 0 rgba(0, 0, 0, 0.15);
+
+ @include rtl {
+ --angle: 7px;
+ }
+ }
+
+ .text-container {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ width: 66%;
+ height: 100%;
+ padding: 0 20px;
+ text-wrap: pretty;
+
+ @media (--range-small-up) {
+ width: 33%;
+ }
+
+ @media (--range-large-up) {
+ width: 33%;
+ }
+ }
+
+ .text-container.with-dark-background {
+ color: var(--systemPrimary-onDark);
+ }
+
+ .link-container {
+ display: flex;
+ gap: 4px;
+ margin-top: 16px;
+ font: var(--title-3-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .link-container :global(svg) {
+ width: 10px;
+ height: 10px;
+ fill: currentColor;
+
+ @include rtl {
+ transform: rotate(180deg);
+ }
+ }
+
+ h3 {
+ text-wrap: balance;
+ font: var(--title-1-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--large-title-emphasized);
+ }
+ }
+
+ h4 {
+ font: var(--subhead-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--headline);
+ }
+ }
+
+ p {
+ margin-top: 8px;
+
+ @media (--range-small-up) {
+ font: var(--title-3);
+ }
+ }
+</style>
diff --git a/src/components/jet/item/SmallLockupItem.svelte b/src/components/jet/item/SmallLockupItem.svelte
new file mode 100644
index 0000000..b235652
--- /dev/null
+++ b/src/components/jet/item/SmallLockupItem.svelte
@@ -0,0 +1,110 @@
+<script lang="ts">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: Lockup;
+
+ /**
+ * Controls the `get-button` variant class that is applied to the "View" button
+ *
+ * @default "gray"
+ */
+ export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'gray';
+ export let shouldShowLaunchNativeButton: boolean = false;
+ export let titleLineCount: number = 2;
+ export let appIconProfile: AppIconProfile = 'app-icon-small';
+
+ const i18n = getI18n();
+</script>
+
+<div class="small-lockup-item">
+ <LinkWrapper
+ action={item.clickAction}
+ label={`${$i18n.t('ASE.Web.AppStore.View')} ${
+ item.title ? item.title : null
+ }`}
+ >
+ {#if item.icon}
+ <div class="app-icon-container">
+ <AppIcon icon={item.icon} profile={appIconProfile} />
+ </div>
+ {/if}
+
+ <div class="metadata-container">
+ {#if item.heading}
+ <LineClamp clamp={1}>
+ <h4 dir="auto">{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={titleLineCount}>
+ <h3 dir="auto">{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p dir="auto">{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <div class="button-container" aria-hidden="true">
+ {#if shouldShowLaunchNativeButton && $$slots['launch-native-button']}
+ <slot name="launch-native-button" />
+ {:else}
+ <span class="get-button {buttonVariant}">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ {/if}
+ </div>
+ </LinkWrapper>
+</div>
+
+<style>
+ .small-lockup-item,
+ .small-lockup-item :global(a) {
+ display: flex;
+ align-items: center;
+ width: 100%;
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ margin-inline-end: 16px;
+ }
+
+ .metadata-container {
+ margin-inline-end: 16px;
+ }
+
+ h3 {
+ color: var(--title-color);
+ font: var(--title-3-emphasized);
+ }
+
+ h4 {
+ color: var(--eyebrow-color, var(--systemSecondary));
+ font: var(--subhead-emphasized);
+ text-transform: uppercase;
+ mix-blend-mode: var(--eyebrow-blend-mode);
+ }
+
+ p {
+ font: var(--callout);
+ color: var(--subtitle-color, var(--systemSecondary));
+ mix-blend-mode: var(--subtitle-blend-mode);
+ }
+
+ .button-container {
+ margin-inline-start: auto;
+ margin-inline-end: var(--margin-inline-end, 0);
+ mix-blend-mode: var(--button-blend-mode);
+ flex-shrink: 0;
+ }
+</style>
diff --git a/src/components/jet/item/SmallLockupWithOrdinalItem.svelte b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte
new file mode 100644
index 0000000..9fb796c
--- /dev/null
+++ b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte
@@ -0,0 +1,176 @@
+<script lang="ts" context="module">
+ import type { Lockup } from '@jet-app/app-store/api/models';
+
+ interface SmallLockupWithOrdinalItem extends Lockup {
+ ordinal: string;
+ }
+
+ export function isSmallLockupWithOrdinalItem(
+ item: Lockup,
+ ): item is SmallLockupWithOrdinalItem {
+ return !!item?.ordinal;
+ }
+</script>
+
+<script lang="ts">
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import mediaQueries from '~/utils/media-queries';
+
+ export let item: Lockup;
+
+ $: titleLineCount = item.heading || $mediaQueries === 'xsmall' ? 1 : 2;
+
+ const i18n = getI18n();
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <article>
+ {#if item.ordinal}
+ <div class="ordinal">
+ {item.ordinal}
+ </div>
+ {/if}
+
+ {#if item.icon}
+ <div
+ class="app-icon-container"
+ style:--icon-aspect-ratio={item.icon.width / item.icon.height}
+ >
+ <AppIcon
+ icon={item.icon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+ {/if}
+ <div class="metadata-container">
+ {#if item.heading}
+ <LineClamp clamp={1}>
+ <h4>{item.heading}</h4>
+ </LineClamp>
+ {/if}
+
+ {#if item.title}
+ <LineClamp clamp={titleLineCount}>
+ <h3 title={item.title}>{item.title}</h3>
+ </LineClamp>
+ {/if}
+
+ {#if item.subtitle}
+ <LineClamp clamp={1}>
+ <p>{item.subtitle}</p>
+ </LineClamp>
+ {/if}
+ </div>
+
+ <div class="button-container">
+ <span class="get-button gray">
+ {$i18n.t('ASE.Web.AppStore.View')}
+ </span>
+ </div>
+ </article>
+</LinkWrapper>
+
+<style>
+ article {
+ position: relative;
+ aspect-ratio: 0.9;
+ height: 100%;
+ padding: 16px;
+ gap: 10px;
+ display: flex;
+ align-items: center;
+ flex-direction: column;
+ justify-content: center;
+ text-align: center;
+ border-radius: var(--global-border-radius-xlarge);
+ background: var(--systemPrimary-onDark);
+ box-shadow: var(--shadow-small);
+ container-type: inline-size;
+ container-name: container;
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemQuaternary);
+ }
+
+ @media (--sidebar-visible) and (--range-xsmall-only) {
+ aspect-ratio: 1;
+ }
+
+ @media (--range-medium-up) {
+ aspect-ratio: 1;
+ }
+ }
+
+ .app-icon-container {
+ flex-shrink: 0;
+ margin-top: 4px;
+ aspect-ratio: var(--icon-aspect-ratio);
+ height: clamp(40px, 40cqi, 100px);
+ width: auto;
+ }
+
+ .metadata-container {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ h3 {
+ text-wrap: balance;
+ font: var(--body-emphasized);
+ line-height: 1.1;
+ color: var(--title-color);
+ }
+
+ h4 {
+ text-transform: uppercase;
+ font: var(--subhead-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ p {
+ font: var(--subhead);
+ color: var(--systemSecondary);
+ }
+
+ .button-container {
+ --get-button-font: var(--subhead-bold);
+ align-content: end;
+ flex-grow: 1;
+ }
+
+ .ordinal {
+ position: absolute;
+ top: 12px;
+ inset-inline-start: 12px;
+ font: var(--title-1-semibold);
+ color: var(--systemTertiary);
+ }
+
+ @container container (width >= 180px) {
+ h3 {
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ @container container (width >= 250px) {
+ h3 {
+ font: var(--title-2-emphasized);
+ margin-bottom: 4px;
+ }
+
+ p {
+ font: var(--body);
+ }
+ }
+
+ @container container (width >= 200px) {
+ .button-container {
+ --get-button-font: unset;
+ }
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte
new file mode 100644
index 0000000..ce7784b
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte
@@ -0,0 +1,69 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCard,
+ TodayCardMediaBrandedSingleApp,
+ } from '@jet-app/app-store/api/models';
+
+ export interface SmallStoryCardMediaBrandedSingleApp extends TodayCard {
+ media: TodayCardMediaBrandedSingleApp;
+ }
+
+ export function isSmallStoryCardMediaBrandedSingleApp(
+ item: TodayCard,
+ ): item is SmallStoryCardMediaBrandedSingleApp {
+ return !!item.media && item.media.kind === 'brandedSingleApp';
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: SmallStoryCardMediaBrandedSingleApp;
+
+ $: artwork = item.media.artworks?.[0] || item.media.icon;
+</script>
+
+<article>
+ <LinkWrapper action={item.clickAction}>
+ <HoverWrapper element="div">
+ <Artwork {artwork} profile="brick" useCropCodeFromArtwork={false} />
+ </HoverWrapper>
+
+ <div class="text-container">
+ <h4>{item.heading}</h4>
+ <h3>{item.title}</h3>
+ <p>{item.inlineDescription}</p>
+ </div>
+ </LinkWrapper>
+</article>
+
+<style>
+ article {
+ aspect-ratio: 16/9;
+ }
+
+ .text-container {
+ gap: 4px;
+ display: flex;
+ flex-direction: column;
+ margin-top: 8px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ margin-bottom: 2px;
+ font: var(--callout-emphasized);
+ color: var(--systemSecondary);
+ }
+
+ p {
+ font: var(--body-tall);
+ color: var(--systemSecondary);
+ text-wrap: pretty;
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte
new file mode 100644
index 0000000..bcd7333
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte
@@ -0,0 +1,87 @@
+<script lang="ts" context="module">
+ import type {
+ Artwork as ArtworkModel,
+ TodayCard,
+ } from '@jet-app/app-store/api/models';
+
+ export interface SmallStoryCardWithArtwork extends TodayCard {
+ artwork: ArtworkModel;
+ badge: any;
+ }
+
+ export function isSmallStoryCardWithArtworkItem(
+ item: TodayCard,
+ ): item is SmallStoryCardWithArtwork {
+ return !('media' in item) && 'artwork' in item;
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import { colorAsString } from '~/utils/color';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+
+ export let item: SmallStoryCardWithArtwork;
+
+ $: artwork = item.heroMedia?.artworks?.[0] || item.artwork;
+
+ $: gradientColor = artwork.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : 'rgb(0 0 0 / 62%)';
+</script>
+
+<article>
+ <LinkWrapper action={item.clickAction}>
+ <HoverWrapper element="div">
+ <Artwork {artwork} profile="small-story-card-portrait" />
+
+ <GradientOverlay --color={gradientColor} />
+
+ <div class="text-container">
+ {#if item.badge?.title}
+ <h4>{item.badge.title}</h4>
+ {/if}
+
+ {#if item.title}
+ <h3>{@html sanitizeHtml(item.title)}</h3>
+ {/if}
+ </div>
+ </HoverWrapper>
+ </LinkWrapper>
+</article>
+
+<style>
+ article {
+ aspect-ratio: 3/4;
+ }
+
+ .text-container {
+ position: absolute;
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ height: 100%;
+ margin-top: 8px;
+ padding: 16px;
+ color: var(--systemPrimary);
+ }
+
+ h3 {
+ z-index: 1;
+ text-wrap: pretty;
+ font: var(--body-bold);
+ color: var(--systemPrimary-onDark);
+ }
+
+ h4 {
+ position: relative;
+ z-index: 1;
+ margin-bottom: 2px;
+ font: var(--caption-2-emphasized);
+ color: var(--systemSecondary-onDark);
+ mix-blend-mode: plus-lighter;
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte
new file mode 100644
index 0000000..5b20e1c
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte
@@ -0,0 +1,156 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCard,
+ TodayCardMediaAppIcon,
+ } from '@jet-app/app-store/api/models';
+
+ export interface TodayCardWithMediAppIcon extends TodayCard {
+ media: TodayCardMediaAppIcon;
+ }
+
+ export function isSmallStoryCardWithMediaAppIcon(
+ item: TodayCard,
+ ): item is TodayCardWithMediAppIcon {
+ return !!item.media && item.media.kind === 'appIcon';
+ }
+</script>
+
+<script lang="ts">
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import AppIcon from '~/components/AppIcon.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let item: TodayCardWithMediAppIcon;
+
+ $: artwork = item.heroMedia?.artworks[0];
+ $: appIcon = item.media.icon;
+ $: backgroundImage = appIcon
+ ? buildSrc(
+ appIcon.template,
+ {
+ crop: 'bb',
+ width: 160,
+ height: 160,
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+ $: backgroundColor = appIcon.backgroundColor
+ ? colorAsString(appIcon.backgroundColor)
+ : '#000';
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ <div
+ class="container"
+ style:--background-color={backgroundColor}
+ style:--background-image={`url(${backgroundImage})`}
+ >
+ <div class="protection" />
+
+ {#if artwork}
+ <Artwork {artwork} profile="brick" />
+ {:else}
+ <div class="app-icon-container">
+ <div class="app-icon-normal">
+ <AppIcon
+ icon={appIcon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+
+ <div class="app-icon-glow">
+ <AppIcon
+ icon={appIcon}
+ profile="app-icon-medium"
+ fixedWidth={false}
+ />
+ </div>
+ </div>
+ {/if}
+ </div>
+ </HoverWrapper>
+
+ <div class="text-container">
+ <h4>{item.heading}</h4>
+ <h3>{item.title}</h3>
+ </div>
+</LinkWrapper>
+
+<style lang="scss">
+ @use 'amp/stylekit/core/mixins/browser-targets' as *;
+
+ .container {
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background: linear-gradient(
+ to bottom,
+ transparent 20%,
+ rgba(0, 0, 0, 0.33) 100%
+ ),
+ var(--background-image), var(--background-color, #000);
+ background-size: cover;
+ background-position: center;
+
+ // Safari has issues rendering the overlaid `backdrop-filter` from `.proection` atop the
+ // background image of `.container`, so in Safari only we are forgoing the use of
+ // `var(--background-image)` and just using colors.
+ @include target-safari {
+ background: linear-gradient(
+ to bottom,
+ transparent 20%,
+ rgba(0, 0, 0, 0.33) 100%
+ ),
+ var(--background-color, #000);
+ }
+ }
+
+ .protection {
+ position: absolute;
+ width: 100%;
+ height: 100%;
+ backdrop-filter: blur(80px) saturate(1.5);
+ }
+
+ .app-icon-container {
+ position: relative;
+ width: 80px;
+ }
+
+ .app-icon-normal {
+ position: relative;
+ z-index: 1;
+ filter: drop-shadow(0 0 13px rgba(0, 0, 0, 0.15));
+ }
+
+ .app-icon-glow {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ transform: scale(1.4);
+ filter: blur(25px);
+ }
+
+ .text-container {
+ margin-top: 8px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ margin-bottom: 2px;
+ font: var(--callout-emphasized);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithMediaItem.svelte b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte
new file mode 100644
index 0000000..4901744
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte
@@ -0,0 +1,104 @@
+<script lang="ts" context="module">
+ import { isSome } from '@jet/environment/types/optional';
+ import type {
+ TodayCard,
+ TodayCardMediaWithArtwork,
+ } from '@jet-app/app-store/api/models';
+
+ import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+
+ export interface SmallStoryCardWithMedia extends TodayCard {
+ media: TodayCardMediaWithArtwork;
+ heroMedia: TodayCardMediaWithArtwork;
+ }
+
+ export function isSmallStoryCardWithMediaItem(
+ item: TodayCard,
+ ): item is SmallStoryCardWithMedia {
+ return isSome(item.media);
+ }
+</script>
+
+<script lang="ts">
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import Artwork from '~/components/Artwork.svelte';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let item: SmallStoryCardWithMedia;
+
+ $: artwork = (() => {
+ if (item.heroMedia) {
+ return item.heroMedia?.artworks?.[0];
+ }
+
+ if (isTodayCardMediaWithArtwork(item.media)) {
+ return item.media.artworks?.[0];
+ }
+
+ return null;
+ })();
+</script>
+
+<article>
+ <LinkWrapper action={item.clickAction}>
+ <HoverWrapper element="div">
+ {#if artwork}
+ <div class="artwork-container">
+ <Artwork
+ {artwork}
+ profile={item.heroMedia
+ ? 'small-story-card'
+ : 'small-story-card-legacy'}
+ useCropCodeFromArtwork={!item.heroMedia}
+ />
+ </div>
+ {/if}
+ </HoverWrapper>
+
+ <div class="text-container">
+ <h4>{item.heading}</h4>
+ <LineClamp clamp={1}>
+ <h3>{item.title}</h3>
+ </LineClamp>
+
+ {#if item.inlineDescription}
+ <LineClamp clamp={1}>
+ <p>{item.inlineDescription}</p>
+ </LineClamp>
+ {/if}
+ </div>
+ </LinkWrapper>
+</article>
+
+<style>
+ .artwork-container {
+ width: 100%;
+ aspect-ratio: 16 / 9;
+ background-color: var(--color);
+ border-radius: 8px;
+ }
+
+ .text-container {
+ display: flex;
+ margin-top: 8px;
+ gap: 4px;
+ color: var(--systemPrimary);
+ flex-direction: column;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ font: var(--callout-emphasized);
+ color: var(--systemTertiary);
+ }
+
+ p {
+ font: var(--body-tall);
+ color: var(--systemSecondary);
+ text-wrap: pretty;
+ }
+</style>
diff --git a/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte
new file mode 100644
index 0000000..038f504
--- /dev/null
+++ b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte
@@ -0,0 +1,118 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCard,
+ TodayCardMediaRiver,
+ } from '@jet-app/app-store/api/models';
+
+ export interface TodayCardWithMediaRiver extends TodayCard {
+ media: TodayCardMediaRiver;
+ }
+
+ export function isSmallStoryCardWithMediaRiver(
+ item: TodayCard,
+ ): item is TodayCardWithMediaRiver {
+ return !!item.media && item.media.kind === 'river';
+ }
+</script>
+
+<script lang="ts">
+ import type { Opt } from '@jet/environment/types/optional';
+ import HoverWrapper from '~/components/HoverWrapper.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import AppIconRiver from '~/components/AppIconRiver.svelte';
+ import {
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+
+ export let item: TodayCardWithMediaRiver;
+
+ $: icons = item.media.lockups.map((lockup) => lockup.icon);
+ $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
+ icons,
+ {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ },
+ );
+
+ let title: Opt<string>;
+ let eyebrow: Opt<string>;
+ $: {
+ eyebrow = item.heading;
+ title = item.title;
+
+ if (item.inlineDescription) {
+ eyebrow = item.title;
+ title = item.inlineDescription;
+ }
+ }
+</script>
+
+<LinkWrapper action={item.clickAction}>
+ <HoverWrapper>
+ <div class="river-container" style={backgroundGradientCssVars}>
+ <AppIconRiver {icons} profile="app-icon" />
+ </div>
+ </HoverWrapper>
+
+ <div class="text-container">
+ {#if eyebrow}
+ <h4>{eyebrow}</h4>
+ {/if}
+
+ {#if title}
+ <h3>{title}</h3>
+ {/if}
+ </div>
+</LinkWrapper>
+
+<style>
+ .river-container {
+ --app-icon-river-icon-width: 48px;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ border-radius: 8px;
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) 20%,
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) 40%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 140% -50%,
+ var(--top-right, #000) 60%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) 50%,
+ transparent 100%
+ );
+ }
+
+ .river-container :global(.app-icons:last-of-type) {
+ margin-bottom: 0;
+ }
+
+ .text-container {
+ margin-top: 8px;
+ }
+
+ h3 {
+ font: var(--title-3);
+ }
+
+ h4 {
+ margin-bottom: 2px;
+ font: var(--callout-emphasized);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/item/TitledParagraphItem.svelte b/src/components/jet/item/TitledParagraphItem.svelte
new file mode 100644
index 0000000..ad8e4bc
--- /dev/null
+++ b/src/components/jet/item/TitledParagraphItem.svelte
@@ -0,0 +1,175 @@
+<script lang="ts" context="module">
+ import type {
+ ShelfModel,
+ TitledParagraph,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTitledParagraphItem(
+ item: ShelfModel | string,
+ ): item is TitledParagraph {
+ return typeof item !== 'string' && 'text' in item;
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { getNumericDateFromDateString } from '@amp/web-app-components/src/utils/date';
+ import { getJet } from '~/jet/svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let item: TitledParagraph;
+
+ const i18n = getI18n();
+ const jet = getJet();
+ const isDetailView = item.style === 'detail';
+ const dateForDisplay = jet.localization.timeAgo(
+ new Date(item.secondarySubtitle),
+ );
+ const dateForAttribute = getNumericDateFromDateString(
+ item.secondarySubtitle,
+ );
+
+ let isTruncated = true;
+</script>
+
+<article class:detail={isDetailView} class:overview={!isDetailView}>
+ <div class="container">
+ <p>
+ {#if item.text}
+ {#if !isTruncated || isDetailView}
+ {item.text}
+ {:else}
+ <LineClamp
+ clamp={5}
+ observe
+ on:resize={({ detail }) =>
+ (isTruncated = detail.truncated)}
+ >
+ {@html sanitizeHtml(item.text)}
+ </LineClamp>
+
+ {#if isTruncated}
+ <button on:click={() => (isTruncated = false)}>
+ {$i18n.t('ASE.Web.AppStore.More')}
+ </button>
+ {/if}
+ {/if}
+ {/if}
+ </p>
+
+ <div class="metadata">
+ <h4>{item.primarySubtitle}</h4>
+ <time datetime={dateForAttribute}>{dateForDisplay}</time>
+ </div>
+ </div>
+</article>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ display: flex;
+ flex-direction: column-reverse;
+ font: var(--body-tall);
+ color: var(--systemPrimary);
+ margin: 0 var(--bodyGutter);
+
+ @media (--range-small-up) {
+ flex-direction: row;
+ }
+ }
+
+ .container {
+ display: flex;
+ width: 100%;
+ }
+
+ p {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ white-space: break-spaces;
+ font: var(--body-tall);
+ }
+
+ .metadata {
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ margin: 0 0 8px 8px;
+ text-align: end;
+ color: var(--systemSecondary);
+ }
+
+ h4 {
+ font: var(--body-tall);
+ }
+
+ button {
+ --gradient-direction: 270deg;
+ position: absolute;
+ bottom: 0;
+ display: flex;
+ justify-content: end;
+ color: var(--keyColor);
+ inset-inline-end: 0;
+ padding-inline-start: 20px;
+ background: linear-gradient(
+ var(--gradient-direction),
+ var(--pageBg) 72%,
+ transparent 100%
+ );
+
+ @include rtl {
+ --gradient-direction: 90deg;
+ }
+ }
+
+ time {
+ color: var(--systemSecondary);
+ white-space: nowrap;
+ }
+
+ .detail {
+ flex-direction: column-reverse;
+ margin: 0;
+ padding: 16px 0 0;
+ border-top: 1px solid var(--systemGray4);
+ }
+
+ .detail .metadata {
+ gap: 2px;
+ }
+
+ .detail h4 {
+ font: var(--body-emphasized-tall);
+ color: var(--systemPrimary);
+ }
+
+ .overview .container {
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ .overview .metadata {
+ flex-grow: 1;
+ gap: 4px;
+ }
+
+ .overview p {
+ @media (--range-small-up) {
+ width: 66%;
+ }
+
+ @media (--range-large-up) {
+ width: 50%;
+ }
+ }
+
+ .detail .container {
+ justify-content: space-between;
+ }
+</style>
diff --git a/src/components/jet/item/TrailersLockupItem.svelte b/src/components/jet/item/TrailersLockupItem.svelte
new file mode 100644
index 0000000..6b2ee42
--- /dev/null
+++ b/src/components/jet/item/TrailersLockupItem.svelte
@@ -0,0 +1,51 @@
+<script lang="ts">
+ import type { TrailersLockup } from '@jet-app/app-store/api/models';
+ import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let item: TrailersLockup;
+
+ $: video = item.trailers.videos[0];
+</script>
+
+<article>
+ {#if video}
+ <div class="video-container">
+ <Video
+ {video}
+ shouldSuperimposePosterImage
+ loop={true}
+ useControls={true}
+ profile="app-trailer-lockup-video"
+ />
+ </div>
+ {/if}
+
+ <SmallLockup {item} />
+</article>
+
+<style>
+ /*
+ The video container is explicitly not 16/9 aspect ratio, because a lot trailers have
+ pillarboxing (black bars on the sides), so expand the height of their container which
+ causes those black bars to overflow outside the container, thus cropping them.
+ This follows the iOS pattern.
+ */
+ .video-container {
+ --app-trailer-lockup-video-aspect-ratio: 16/10;
+ aspect-ratio: var(--app-trailer-lockup--video-aspect-ratio);
+ margin-bottom: 16px;
+ overflow: hidden;
+ border-radius: var(--global-border-radius-large);
+ }
+
+ /*
+ Not all trailers are in a landscape aspect ratio (many iPhone trailers are portrait),
+ so for those cases we force them to fit inside a landscape container, centered vertically,
+ by using `object-fit: cover;`.
+ */
+ .video-container :global(video) {
+ aspect-ratio: var(--app-trailer-lockup-video-aspect-ratio);
+ object-fit: cover;
+ }
+</style>
diff --git a/src/components/jet/marker-shelf/ProductTopLockup.svelte b/src/components/jet/marker-shelf/ProductTopLockup.svelte
new file mode 100644
index 0000000..e56e5b0
--- /dev/null
+++ b/src/components/jet/marker-shelf/ProductTopLockup.svelte
@@ -0,0 +1,463 @@
+<script lang="ts" context="module">
+ import type {
+ AppPlatform,
+ ShelfBasedProductPage,
+ } from '@jet-app/app-store/api/models';
+
+ /**
+ * The parts of {@linkcode ShelfBasedProductPage} that are required to render
+ * the `MarkerShelf` component
+ */
+ export type MarkerShelfPageRequirements = Pick<
+ ShelfBasedProductPage,
+ | 'badges'
+ | 'banner'
+ | 'developerAction'
+ | 'lockup'
+ | 'shelfMapping'
+ | 'titleOfferDisplayProperties'
+ | 'canonicalURL'
+ | 'appPlatforms'
+ >;
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import { platform } from '@amp/web-apps-utils';
+ import AppIcon, {
+ doesAppIconNeedBorder,
+ } from '~/components/AppIcon.svelte';
+ import Banner from '~/components/jet/item/BannerItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ProductPageArcadeBanner from '~/components/ProductPageArcadeBanner.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { colorAsString, isNamedColor, isRGBColor } from '~/utils/color';
+ import { concatWithMiddot, isString } from '~/utils/string-formatting';
+ import {
+ isPlatformExclusivelySupported,
+ isPlatformSupported,
+ PlatformToExclusivityText,
+ } from '~/utils/app-platforms';
+ import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
+ import ShareArrowButton, {
+ isShareSupported,
+ } from '~/components/ShareArrowButton.svelte';
+ import LaunchNativeButton from '~/components/LaunchNativeButton.svelte';
+
+ export let page: MarkerShelfPageRequirements;
+
+ $: banner = page.banner;
+ $: lockup = page.lockup;
+ $: appPlatforms = page.appPlatforms;
+ $: offerDisplayProperties = lockup.offerDisplayProperties || {};
+ $: ({ expectedReleaseDate } = offerDisplayProperties?.subtitles || {});
+
+ const i18n = getI18n();
+
+ // TODO: replace with `supportsArcade` from Jet
+ // rdar://143706610 (Support `supportsArcade` attribute)
+ $: supportsArcade = offerDisplayProperties.offerType === 'arcadeApp';
+
+ $: backgroundColor = isRGBColor(lockup.icon?.backgroundColor)
+ ? colorAsString(lockup.icon.backgroundColor)
+ : '#fff';
+
+ $: backgroundImage = lockup.icon
+ ? buildSrc(
+ lockup.icon.template,
+ {
+ crop: 'bb',
+ width: 400,
+ height: 400,
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+
+ $: attributes = concatWithMiddot(
+ [
+ expectedReleaseDate && $i18n.t('ASE.Web.AppStore.App.ComingSoon'),
+ expectedReleaseDate && expectedReleaseDate,
+ // Attributes that are not relevant for Arcade Apps:
+ ...(!supportsArcade
+ ? [
+ page.titleOfferDisplayProperties?.isFree &&
+ $i18n.t('ASE.Web.AppStore.Free'),
+ offerDisplayProperties.priceFormatted,
+ offerDisplayProperties.subtitles?.standard,
+ lockup.tertiaryTitle,
+ ]
+ : []),
+ ].filter(isString),
+ $i18n,
+ );
+
+ $: exclusivePlatform = (
+ Object.keys(PlatformToExclusivityText) as AppPlatform[]
+ ).find((platform: AppPlatform) =>
+ isPlatformExclusivelySupported(platform, appPlatforms),
+ );
+ $: exclusivityText = exclusivePlatform
+ ? PlatformToExclusivityText[exclusivePlatform]
+ : null;
+
+ $: shouldShowLaunchNativeButton =
+ platform.ismacOS() &&
+ (lockup.isIOSBinaryMacOSCompatible ||
+ isPlatformSupported('mac', appPlatforms));
+
+ let shouldShowShareButton: boolean = true;
+
+ onMount(() => {
+ shouldShowShareButton = isShareSupported();
+ });
+</script>
+
+<ShelfWrapper withBottomPadding={false} withPaddingTop={false}>
+ <div
+ class="container"
+ style:--background-color={backgroundColor}
+ style:--background-image={`url(${backgroundImage})`}
+ >
+ <div class="rotate" />
+ <div class="blur" />
+
+ <div class="content-container">
+ {#if lockup.icon}
+ <div
+ class="app-icon-contianer"
+ class:without-border={!doesAppIconNeedBorder(lockup.icon)}
+ aria-hidden="true"
+ >
+ <AppIcon
+ icon={lockup.icon}
+ profile="app-icon-large"
+ fixedWidth={false}
+ />
+
+ <div class="glow">
+ <AppIcon
+ icon={lockup.icon}
+ profile="app-icon-large"
+ fixedWidth={false}
+ />
+ </div>
+ </div>
+ {/if}
+
+ <section>
+ {#if supportsArcade}
+ <span
+ class="arcade-logo"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.ArcadeLogo.AccessibilityValue',
+ )}
+ >
+ <AppleArcadeLogo />
+ </span>
+ {:else if lockup.editorialTagline}
+ <h3>{lockup.editorialTagline}</h3>
+ {/if}
+
+ <h1>
+ {lockup.title}
+ </h1>
+
+ <h2 class="subtitle">
+ {lockup.subtitle}
+ </h2>
+
+ {#if exclusivityText}
+ <p class="attributes">
+ {$i18n.t(exclusivityText)}
+ </p>
+ {/if}
+
+ {#if attributes.length > 0}
+ <p class="attributes">
+ {attributes}
+ </p>
+ {/if}
+
+ {#if page.canonicalURL && (shouldShowLaunchNativeButton || shouldShowShareButton)}
+ <div class="buttons-container">
+ {#if shouldShowLaunchNativeButton}
+ <span class="launch-native-button-container">
+ <LaunchNativeButton url={page.canonicalURL} />
+ </span>
+ {/if}
+
+ {#if shouldShowShareButton}
+ <!--
+ If there is no launch native button, then we show a label for
+ the share button, which helps to visually fill out the space.
+ -->
+ <ShareArrowButton
+ url={page.canonicalURL}
+ withLabel={!shouldShowLaunchNativeButton}
+ />
+ {/if}
+ </div>
+ {/if}
+ </section>
+ </div>
+ </div>
+</ShelfWrapper>
+
+{#if banner}
+ <ShelfWrapper withBottomPadding={false} withTopMargin={false}>
+ <Banner item={banner} />
+ </ShelfWrapper>
+{/if}
+
+{#if supportsArcade}
+ <ShelfWrapper
+ withBottomPadding={false}
+ withTopMargin={true}
+ centered={false}
+ >
+ <ProductPageArcadeBanner />
+ </ShelfWrapper>
+{/if}
+
+<style>
+ .container {
+ --blend-mode: plus-lighter;
+ position: relative;
+ display: flex;
+ overflow: hidden;
+ align-items: center;
+ height: 200px;
+ color: var(--systemPrimary-onDark);
+ border-bottom: 1px solid var(--systemQuaternary-vibrant);
+ border-bottom-right-radius: 2px;
+ border-bottom-left-radius: 2px;
+ background: linear-gradient(
+ to bottom,
+ transparent 20%,
+ rgba(0, 0, 0, 0.8) 100%
+ ),
+ var(--background-image), var(--background-color, #000);
+ background-size: cover;
+ background-position: center;
+ transition: border-bottom-left-radius 210ms ease-out,
+ border-bottom-right-radius 210ms ease-out;
+ transform: translate(0);
+
+ @media (--range-small-up) {
+ height: 286px;
+ }
+
+ @media (--range-xlarge-up) {
+ border: 1px solid var(--systemQuaternary-vibrant);
+ border-top: none;
+ border-bottom-right-radius: 30px;
+ border-bottom-left-radius: 30px;
+ }
+ }
+
+ .glow {
+ position: absolute;
+ z-index: -1;
+ top: 0;
+ width: 100%;
+ transform: scale(1.5);
+ filter: blur(60px);
+ }
+
+ .blur {
+ position: absolute;
+ z-index: 1;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ backdrop-filter: blur(100px) saturate(1.5);
+ }
+
+ .rotate {
+ position: absolute;
+ z-index: 2;
+ top: 0;
+ left: 0;
+ width: 100%;
+ filter: brightness(1.3) saturate(0) blur(50px);
+ mix-blend-mode: overlay;
+ height: 500%;
+ background-image: var(--background-image);
+ background-repeat: repeat;
+ opacity: 0;
+ transform-origin: top center;
+ animation: shift-background 60s infinite linear 10s;
+ }
+
+ .content-container {
+ display: flex;
+ flex-direction: row;
+ max-width: 840px;
+ gap: 1em;
+ margin: 0 var(--bodyGutter);
+
+ @media (--range-small-up) {
+ gap: 1.5em;
+ }
+
+ @media (--range-medium-up) {
+ gap: 2em;
+ }
+ }
+
+ .app-icon-contianer {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ width: 128px;
+ flex-shrink: 0;
+
+ @media (--range-small-up) {
+ width: 194px;
+ }
+ }
+
+ .app-icon-contianer:not(.without-border) :global(> .app-icon) {
+ box-shadow: 0 0 30px rgba(0, 0, 0, 0.33);
+ border: 2px solid var(--systemQuaternary-onDark);
+ }
+
+ section {
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ .subtitle,
+ .attributes {
+ position: relative;
+ z-index: 2;
+ margin-bottom: 4px;
+ font: var(--body);
+ color: rgba(245.973, 245.973, 245.973, 0.6);
+ text-wrap: pretty;
+ mix-blend-mode: var(--blend-mode);
+
+ @media (--range-small-up) {
+ margin-bottom: 8px;
+ font: var(--title-2-emphasized);
+ }
+ }
+
+ .attributes {
+ margin-bottom: 0;
+ font: var(--body);
+ }
+
+ .buttons-container {
+ --share-arrow-size: 27px;
+ --launch-native-button-arrow-size: 7px;
+ --get-button-font: var(--footnote-bold);
+ margin-top: 10px;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+
+ @media (--range-small-up) {
+ --share-arrow-size: unset;
+ --launch-native-button-arrow-size: unset;
+ --get-button-font: unset;
+ }
+ }
+
+ h1 {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ align-items: center;
+ margin-bottom: 2px;
+ font: var(--title-2-emphasized);
+ color: white;
+ text-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
+ text-wrap: pretty;
+
+ @media (--sidebar-visible) {
+ font: var(--title-1-emphasized);
+ }
+
+ @media (--range-small-up) {
+ font: var(--header-emphasized);
+ letter-spacing: -0.02em;
+ }
+ }
+
+ h3 {
+ margin-bottom: 0;
+ position: relative;
+ z-index: 2;
+ mix-blend-mode: plus-lighter;
+ font: var(--body-emphasized);
+
+ @media (--range-small-up) {
+ font: var(--title-3-emphasized);
+ }
+ }
+
+ .arcade-logo {
+ display: flex;
+ height: 10px;
+ margin-bottom: 4px;
+ position: relative;
+ z-index: 2;
+ mix-blend-mode: plus-lighter;
+
+ @media (--range-small-up) {
+ height: 14px;
+ }
+ }
+
+ .launch-native-button-container {
+ position: relative;
+ z-index: 2;
+ }
+
+ @keyframes shift-background {
+ 0% {
+ background-position: 50% 50%;
+ background-size: 100%;
+ transform: rotate(0deg);
+ opacity: 0;
+ }
+
+ 10% {
+ opacity: 0.5;
+ }
+
+ 20% {
+ background-position: 65% 25%;
+ background-size: 160%;
+ transform: rotate(45deg);
+ }
+
+ 45% {
+ background-position: 90% 60%;
+ background-size: 250%;
+ transform: rotate(160deg);
+ opacity: 0.5;
+ }
+
+ 70% {
+ background-position: 70% 40%;
+ background-size: 200%;
+ transform: rotate(250deg);
+ opacity: 0.5;
+ }
+
+ 100% {
+ background-position: 50% 50%;
+ background-size: 100%;
+ transform: rotate(360deg);
+ opacity: 0;
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte
new file mode 100644
index 0000000..c1e7b2e
--- /dev/null
+++ b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte
@@ -0,0 +1,36 @@
+<script lang="ts" context="module">
+ import type {
+ AccessibilityParagraph,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface AccessibilityDeveloperLinkShelf extends Shelf {
+ items: [AccessibilityParagraph];
+ }
+
+ export function isAccessibilityDeveloperLinkShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityDeveloperLinkShelf {
+ let { contentType, items, title } = shelf;
+
+ return (
+ contentType === 'accessibilityParagraph' &&
+ !title &&
+ Array.isArray(items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import AccessibilityParagraphItem from '../item/AccessibilityParagraphItem.svelte';
+ import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
+
+ export let shelf: AccessibilityDeveloperLinkShelf;
+
+ $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
+</script>
+
+<ShelfWrapper {shelf} centered {withBottomPadding}>
+ <AccessibilityParagraphItem item={shelf.items[0]} />
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte
new file mode 100644
index 0000000..cb2fed8
--- /dev/null
+++ b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte
@@ -0,0 +1,35 @@
+<script lang="ts" context="module">
+ import type {
+ AccessibilityFeatures,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ export interface AccessibilityFeaturesShelf extends Shelf {
+ items: AccessibilityFeatures[];
+ }
+
+ export function isAccessibilityFeaturesShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityFeaturesShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'accessibilityFeatures' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte';
+ import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
+
+ export let shelf: AccessibilityFeaturesShelf;
+
+ $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
+</script>
+
+<ShelfWrapper {shelf} {withBottomPadding}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <AccessibilityFeaturesItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AccessibilityHeaderShelf.svelte b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte
new file mode 100644
index 0000000..990c507
--- /dev/null
+++ b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte
@@ -0,0 +1,182 @@
+<script lang="ts" context="module">
+ import {
+ type Action,
+ type FlowAction,
+ type GenericPage,
+ type AccessibilityParagraph,
+ type Shelf,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ import {
+ isAccessibilityFeaturesShelf,
+ type AccessibilityFeaturesShelf,
+ } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte';
+
+ interface AccessibilityParagraphShelf extends Shelf {
+ items: [AccessibilityParagraph];
+ }
+
+ interface AccessibilityHeaderShelf extends AccessibilityParagraphShelf {
+ items: [AccessibilityParagraph];
+ }
+
+ interface AccessibilityDetailPage extends GenericPage {
+ shelves: (AccessibilityFeaturesShelf | AccessibilityParagraphShelf)[];
+ }
+
+ interface AccessibilityDetailPageFlowAction extends FlowAction {
+ page: 'accessibilityDetails';
+ pageData: AccessibilityDetailPage;
+ }
+
+ export function isAccessibilityHeaderShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityHeaderShelf {
+ let { contentType, items, title } = shelf;
+
+ return (
+ contentType === 'accessibilityParagraph' &&
+ !!title &&
+ Array.isArray(items)
+ );
+ }
+
+ function isAccessibilityParagraphShelf(
+ shelf: Shelf,
+ ): shelf is AccessibilityParagraphShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'accessibilityParagraph' && Array.isArray(items);
+ }
+
+ function isAccessibilityDetailFlowAction(
+ action: Action,
+ ): action is AccessibilityDetailPageFlowAction {
+ return isFlowAction(action) && action.page === 'accessibilityDetails';
+ }
+</script>
+
+<script lang="ts">
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import AccessibilityParagraphItem from '~/components/jet/item/AccessibilityParagraphItem.svelte';
+ import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
+
+ export let shelf: AccessibilityHeaderShelf;
+
+ $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const { seeAllAction } = shelf;
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+ const handleModalClose = () => modalComponent?.close();
+ const handleOpenModalClick = (e: Event) => {
+ modalTriggerElement = e.target as HTMLElement;
+ modalComponent?.showModal();
+ };
+
+ const destination =
+ seeAllAction && isAccessibilityDetailFlowAction(seeAllAction)
+ ? seeAllAction
+ : undefined;
+ const pageData = destination?.pageData;
+</script>
+
+<ShelfWrapper {shelf} {withBottomPadding}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ {#if destination}
+ <button on:click={handleOpenModalClick}>
+ <ShelfTitle
+ title={shelf.title}
+ seeAllAction={destination}
+ />
+ </button>
+ {:else}
+ <ShelfTitle title={shelf.title} />
+ {/if}
+ {/if}
+
+ {#if pageData}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleModalClose}
+ title={pageData.title || null}
+ subtitle={null}
+ >
+ <svelte:fragment slot="content">
+ <div class="modal-content-container">
+ {#each pageData.shelves as shelf}
+ <div class="content-section">
+ {#if isAccessibilityParagraphShelf(shelf)}
+ {#each shelf.items as item}
+ <AccessibilityParagraphItem
+ {item}
+ />
+ {/each}
+ {/if}
+
+ {#if isAccessibilityFeaturesShelf(shelf)}
+ {#each shelf.items as item}
+ <AccessibilityFeaturesItem
+ {item}
+ isDetailView={true}
+ />
+ {/each}
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+ {/if}
+ </div>
+
+ <div class="header-container">
+ <div>
+ <AccessibilityParagraphItem item={shelf.items[0]} />
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style>
+ .title-container {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 16px;
+ padding-inline-end: var(--bodyGutter);
+ }
+
+ .header-container {
+ margin: 0 var(--bodyGutter);
+ }
+
+ .header-container div {
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ .modal-content-container {
+ font: var(--body-tall);
+ white-space: normal;
+ }
+
+ .modal-content-container .content-section {
+ padding-top: 20px;
+ border-top: 1px solid var(--defaultLine);
+ }
+
+ .modal-content-container .content-section:not(:first-child) {
+ margin-top: 20px;
+ }
+</style>
diff --git a/src/components/jet/shelf/ActionShelf.svelte b/src/components/jet/shelf/ActionShelf.svelte
new file mode 100644
index 0000000..847438f
--- /dev/null
+++ b/src/components/jet/shelf/ActionShelf.svelte
@@ -0,0 +1,80 @@
+<script lang="ts" context="module">
+ import type { Shelf, Action } from '@jet-app/app-store/api/models';
+
+ interface ActionShelf extends Shelf {
+ items: Action[];
+ }
+
+ export function isActionShelf(shelf: Shelf): shelf is ActionShelf {
+ return shelf.contentType === 'action' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ActionShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="F" let:item>
+ {@const action = item}
+ {@const artwork = item.artwork}
+ {@const title = item.title}
+
+ <div class="container">
+ <LinkWrapper {action}>
+ {#if artwork}
+ <div class="artwork-container" aria-hidden="true">
+ <Artwork
+ {artwork}
+ profile={getNaturalProfile(artwork, [24])}
+ hasTransparentBackground
+ />
+ </div>
+ {/if}
+ {title}
+ </LinkWrapper>
+ </div>
+ </ShelfItemLayout>
+</ShelfWrapper>
+
+<style>
+ .container :global(a) {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+ background: var(--pageBG);
+ border-radius: var(--global-border-radius-medium);
+ box-shadow: var(--shadow-small);
+ padding: 16px 10px;
+ width: 100%;
+ font: var(--title-3-medium);
+ transition: background-color 210ms ease-out;
+ }
+
+ .container :global(a:hover) {
+ /* stylelint-disable color-function-notation */
+ background-color: rgb(from var(--pageBG) r g b/0.1);
+ /* stylelint-enable color-function-notation */
+
+ @media (prefers-color-scheme: dark) {
+ /* stylelint-disable color-function-notation */
+ background-color: rgb(from var(--pageBG) r g b/0.85);
+ /* stylelint-enable color-function-notation */
+ }
+ }
+
+ .artwork-container {
+ width: 24px;
+ height: 24px;
+ }
+
+ .container :global(.external-link-arrow) {
+ height: 10px;
+ }
+</style>
diff --git a/src/components/jet/shelf/AnnotationShelf.svelte b/src/components/jet/shelf/AnnotationShelf.svelte
new file mode 100644
index 0000000..e11de72
--- /dev/null
+++ b/src/components/jet/shelf/AnnotationShelf.svelte
@@ -0,0 +1,49 @@
+<script lang="ts" context="module">
+ import type { Shelf, Annotation } from '@jet-app/app-store/api/models';
+
+ interface AnnotationShelf extends Shelf {
+ items: Annotation[];
+ }
+
+ export function isAnnotationShelf(shelf: Shelf): shelf is AnnotationShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'annotation' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import Grid from '~/components/Grid.svelte';
+ import CollapsableContent from '~/components/CollapsableContent.svelte';
+ import AnnotationItem from '~/components/jet/item/Annotation/AnnotationItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: AnnotationShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <dl>
+ <Grid items={shelf.items} gridType="F" let:item>
+ <dt>{item.title}</dt>
+
+ {#if item.summary}
+ <CollapsableContent>
+ <svelte:fragment slot="summary">
+ {item.summary}
+ </svelte:fragment>
+
+ <AnnotationItem {item} />
+ </CollapsableContent>
+ {:else}
+ <AnnotationItem {item} />
+ {/if}
+ </Grid>
+ </dl>
+</ShelfWrapper>
+
+<style>
+ dt {
+ color: var(--systemSecondary);
+ margin-bottom: 4px;
+ }
+</style>
diff --git a/src/components/jet/shelf/AppEventDetailShelf.svelte b/src/components/jet/shelf/AppEventDetailShelf.svelte
new file mode 100644
index 0000000..2ae84eb
--- /dev/null
+++ b/src/components/jet/shelf/AppEventDetailShelf.svelte
@@ -0,0 +1,290 @@
+<script lang="ts" context="module">
+ import {
+ type AppEventDetailShelf,
+ isAppEventDetailShelf,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ export { isAppEventDetailShelf };
+</script>
+
+<script lang="ts">
+ import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import mediaQueries from '~/utils/media-queries';
+ import Artwork from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+ import GradientOverlay from '~/components/GradientOverlay.svelte';
+ import { colorAsString } from '~/utils/color';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+ import { platform } from '@amp/web-apps-utils';
+ import LaunchNativeButton from '~/components/LaunchNativeButton.svelte';
+
+ export let shelf: AppEventDetailShelf;
+
+ $: item = shelf.items[0];
+ $: ({ appEvent, artwork: productArtwork, video } = item);
+ $: ({ requirements, lockup } = appEvent);
+ $: artwork = video ? video.preview : productArtwork;
+
+ $: backgroundImageUrl = artwork
+ ? buildSrc(
+ artwork.template,
+ {
+ crop: artwork.crop as CropCode,
+ width: 200,
+ height: Math.floor(200 / (artwork.width / artwork.height)),
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+
+ $: backgroundColor = artwork?.backgroundColor
+ ? colorAsString(artwork.backgroundColor)
+ : '#000';
+ $: hasLightArtwork = appEvent.mediaOverlayStyle === 'light';
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: clickAction = lockup?.clickAction;
+ $: urlToLaunchNatively =
+ clickAction && isFlowAction(clickAction) ? clickAction.pageUrl : null;
+ $: shouldShowLaunchNativeButton =
+ platform.ismacOS() &&
+ lockup?.isIOSBinaryMacOSCompatible &&
+ !!urlToLaunchNatively;
+
+ function makeCSSURL(url: string | null | undefined): string {
+ return url ? `url(${url})` : '';
+ }
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false} centered={false}>
+ <div
+ class="event-detail"
+ style:--background-image-url={makeCSSURL(backgroundImageUrl)}
+ style:--background-color={backgroundColor}
+ >
+ {#if video}
+ <div class="video-container">
+ <Video
+ {video}
+ autoplay
+ loop
+ useControls={false}
+ profile="app-event-detail"
+ />
+ </div>
+ {:else if artwork}
+ <div class="artwork-container">
+ <Artwork {artwork} profile="app-event-detail" />
+ </div>
+ {/if}
+
+ {#if isXSmallViewport}
+ <div class="gradient-container">
+ <GradientOverlay
+ --color={backgroundColor}
+ --height="70%"
+ shouldDarken={!hasLightArtwork}
+ />
+ </div>
+ {:else}
+ <div class="tint-container" />
+ {/if}
+
+ <div class="time-container">
+ <AppEventDate {appEvent} />
+ </div>
+
+ <div
+ class="text-container"
+ class:dark={hasLightArtwork && isXSmallViewport}
+ >
+ <div class="event-details-container">
+ <p class="app-event-kind">{appEvent.kind}</p>
+ <h1 class="app-event-title">{appEvent.title}</h1>
+ <p class="app-event-subtitle">
+ {appEvent.detail}
+ </p>
+ {#if requirements}
+ <span class="requirements">{requirements}</span>
+ {/if}
+ </div>
+
+ {#if lockup}
+ <div class="lockup-container">
+ <SmallLockupItem
+ {shouldShowLaunchNativeButton}
+ item={lockup}
+ buttonVariant="transparent"
+ appIconProfile="app-icon"
+ >
+ <svelte:fragment slot="launch-native-button">
+ {#if urlToLaunchNatively}
+ <LaunchNativeButton url={urlToLaunchNatively} />
+ {/if}
+ </svelte:fragment>
+ </SmallLockupItem>
+ </div>
+ {/if}
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .event-detail {
+ --event-image-desktop-width: 31.64%;
+ --border-radius: 16px;
+ --event-gutter: 16px;
+ border-radius: var(--border-radius);
+ display: grid;
+ grid-template-areas:
+ 'time'
+ 'text';
+ grid-template-rows: 1fr auto;
+ aspect-ratio: 9/16;
+ max-height: 90vh;
+ overflow: hidden;
+ position: relative;
+
+ @media (--range-small-up) {
+ --event-gutter: 20px;
+ aspect-ratio: 16/9;
+ background-image: var(--background-image-url);
+ background-position-x: 50%;
+ background-position-y: 50%;
+ background-size: cover;
+ grid-template-areas:
+ 'image time'
+ 'image text';
+ grid-template-columns: var(--event-image-desktop-width) auto;
+ grid-template-rows: auto 1fr;
+ }
+ }
+
+ .artwork-container,
+ .video-container {
+ z-index: 1;
+
+ /* On "mobile" the artwork should be behind both the time and text */
+ grid-row-start: time;
+ grid-row-end: text;
+ grid-column: 1;
+
+ @media (--range-small-up) {
+ /* On large screens, it should be to the right of the text */
+ grid-area: image;
+ }
+ }
+
+ .video-container {
+ background: var(--background-color);
+ color: transparent;
+ }
+
+ .video-container :global(video) {
+ width: unset;
+ position: absolute;
+ }
+
+ .tint-container {
+ background: var(--systemTertiary-onLight_IC);
+ backdrop-filter: saturate(120%) blur(24px);
+ z-index: 1;
+
+ /* One smaller screens, extend behind just the text */
+ grid-area: text;
+
+ /* On larger screens, extend behind time and text */
+ grid-row-start: time;
+ grid-row-end: text;
+ }
+
+ .time-container {
+ grid-area: time;
+ margin-top: var(--event-gutter);
+ margin-inline-start: var(--event-gutter);
+ }
+
+ .time-container :global(time) {
+ color: var(--systemPrimary-onLight);
+ font: var(--callout-emphasized);
+ padding: 3px 10px;
+ background-color: var(--systemSecondary-onDark);
+ border-radius: var(--global-border-radius-medium);
+ position: relative;
+ z-index: 3;
+
+ @media (--range-small-up) {
+ position: relative;
+ z-index: 3;
+ mix-blend-mode: plus-lighter;
+ }
+ }
+
+ .text-container {
+ --blend-mode: plus-lighter;
+ --text-color: var(--systemPrimary-onDark);
+ padding: var(--event-gutter);
+
+ /* Placement within parent */
+ grid-area: text;
+
+ /* Layout of child elements */
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ justify-content: space-between;
+ color: var(--text-color);
+ }
+
+ .text-container.dark {
+ --blend-mode: normal;
+ --text-color: var(--systemPrimary-onLight);
+ }
+
+ .event-details-container {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .app-event-kind {
+ font: var(--callout-emphasized);
+ mix-blend-mode: var(--blend-mode);
+ z-index: 1;
+ }
+
+ .app-event-title {
+ font: var(--large-title-emphasized);
+ text-wrap: pretty;
+ z-index: 1;
+ }
+
+ .app-event-subtitle {
+ font: var(--title-3);
+ z-index: 1;
+ }
+
+ .requirements {
+ position: relative;
+ z-index: 1;
+ font: var(--body-emphasized);
+ }
+
+ .lockup-container {
+ --title-color: var(--text-color);
+ --subtitle-color: var(--text-color);
+ --eyebrow-color: var(--text-color);
+ --linkColor: var(--text-color);
+ --button-blend-mode: var(--blend-mode);
+ border-top: 1px solid var(--systemQuaternary-onDark);
+ padding-top: 16px;
+ z-index: 1;
+ }
+</style>
diff --git a/src/components/jet/shelf/AppPromotionShelf.svelte b/src/components/jet/shelf/AppPromotionShelf.svelte
new file mode 100644
index 0000000..48590cb
--- /dev/null
+++ b/src/components/jet/shelf/AppPromotionShelf.svelte
@@ -0,0 +1,47 @@
+<script lang="ts" context="module">
+ import type {
+ AppPromotion,
+ AppEvent,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface AppPromotionShelf extends Shelf {
+ items: AppPromotion[];
+ }
+
+ export function isAppPromotionShelf(
+ shelf: Shelf,
+ ): shelf is AppPromotionShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'appPromotion' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import AppEventItem from '~/components/jet/item/AppEventItem.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import mediaQueries from '~/utils/media-queries';
+
+ export let shelf: AppPromotionShelf;
+
+ $: appEventItems = shelf.items.filter(
+ (item): item is AppEvent => item.promotionType === 'appEvent',
+ );
+ $: isArticleContext = shelf.presentationHints?.isArticleContext;
+ $: gridType =
+ isArticleContext && $mediaQueries !== 'small' ? 'Spotlight' : 'B';
+</script>
+
+<ShelfWrapper {shelf} withTopMargin={isArticleContext}>
+ <ShelfItemLayout
+ shelf={{
+ ...shelf,
+ items: appEventItems,
+ }}
+ {gridType}
+ let:item
+ >
+ <AppEventItem {item} {isArticleContext} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AppShowcaseShelf.svelte b/src/components/jet/shelf/AppShowcaseShelf.svelte
new file mode 100644
index 0000000..095acf2
--- /dev/null
+++ b/src/components/jet/shelf/AppShowcaseShelf.svelte
@@ -0,0 +1,29 @@
+<script lang="ts" context="module">
+ import type { AppShowcase, Shelf } from '@jet-app/app-store/api/models';
+
+ interface AppShowcaseShelf extends Shelf {
+ contentType: 'appShowcase';
+ items: [AppShowcase];
+ }
+
+ export function isAppShowcaseShelf(
+ shelf: Shelf,
+ ): shelf is AppShowcaseShelf {
+ return (
+ shelf.contentType === 'appShowcase' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte';
+
+ export let shelf: AppShowcaseShelf;
+
+ $: item = shelf.items[0];
+</script>
+
+<ShelfWrapper {shelf} withTopMargin centered>
+ <SmallLockup item={item.lockup} />
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/AppTrailerLockupShelf.svelte b/src/components/jet/shelf/AppTrailerLockupShelf.svelte
new file mode 100644
index 0000000..f516074
--- /dev/null
+++ b/src/components/jet/shelf/AppTrailerLockupShelf.svelte
@@ -0,0 +1,48 @@
+<script lang="ts" context="module">
+ import type {
+ TrailersLockup,
+ MixedMediaLockup,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ type AppTrailerLockupItem = TrailersLockup | MixedMediaLockup;
+
+ interface AppTrailerLockupShelf extends Shelf {
+ contentType: 'appTrailerLockup';
+ items: AppTrailerLockupItem[];
+ }
+
+ export function isAppTrailerLockupShelf(
+ shelf: Shelf,
+ ): shelf is AppTrailerLockupShelf {
+ return (
+ shelf.contentType === 'appTrailerLockup' &&
+ Array.isArray(shelf.items)
+ );
+ }
+
+ function isMixedMediaLockup(
+ item: AppTrailerLockupItem,
+ ): item is MixedMediaLockup {
+ return Array.isArray(item.trailers);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import MixedMediaLockupItem from '~/components/jet/item/MixedMediaLockupItem.svelte';
+ import TrailersLockupItem from '~/components/jet/item/TrailersLockupItem.svelte';
+
+ export let shelf: AppTrailerLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ {#if isMixedMediaLockup(item)}
+ <MixedMediaLockupItem {item} />
+ {:else}
+ <TrailersLockupItem {item} />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ArcadeFooterShelf.svelte b/src/components/jet/shelf/ArcadeFooterShelf.svelte
new file mode 100644
index 0000000..dc46740
--- /dev/null
+++ b/src/components/jet/shelf/ArcadeFooterShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type { Shelf, ArcadeFooter } from '@jet-app/app-store/api/models';
+
+ interface ArcadeFooterShelf extends Shelf {
+ items: [ArcadeFooter];
+ }
+
+ export function isArcadeFooterShelf(
+ shelf: Shelf,
+ ): shelf is ArcadeFooterShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'arcadeFooter' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ArcadeFooterItem from '~/components/jet/item/ArcadeFooterItem.svelte';
+ import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ArcadeFooterShelf;
+
+ $: gridRows = shelf.rowsPerColumn ?? undefined;
+ $: items = shelf.items;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <HorizontalShelf {gridRows} gridType="Spotlight" {items} let:item>
+ <ArcadeFooterItem {item} />
+ </HorizontalShelf>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/BannerShelf.svelte b/src/components/jet/shelf/BannerShelf.svelte
new file mode 100644
index 0000000..84289c9
--- /dev/null
+++ b/src/components/jet/shelf/BannerShelf.svelte
@@ -0,0 +1,35 @@
+<script lang="ts" context="module">
+ import type { Shelf, Banner } from '@jet-app/app-store/api/models';
+
+ interface BannerShelf extends Shelf {
+ contentType: 'banner';
+ items: Banner[];
+ }
+
+ export function isBannerShelf(shelf: Shelf): shelf is BannerShelf {
+ return shelf.contentType === 'banner' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import BannerItem from '~/components/jet/item/BannerItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: BannerShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <div class="banner-items-container">
+ {#each shelf.items as item}
+ <BannerItem {item} />
+ {/each}
+ </div>
+</ShelfWrapper>
+
+<style>
+ .banner-items-container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ }
+</style>
diff --git a/src/components/jet/shelf/BrickShelf.svelte b/src/components/jet/shelf/BrickShelf.svelte
new file mode 100644
index 0000000..4bd55e5
--- /dev/null
+++ b/src/components/jet/shelf/BrickShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface BrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isBrickShelf(shelf: Shelf): shelf is BrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'brick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import BrickItem from '~/components/jet/item/BrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: BrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout
+ {shelf}
+ gridTypeForShelf="Brick"
+ gridTypeForGrid="F"
+ let:item
+ >
+ <BrickItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/CategoryBrickShelf.svelte b/src/components/jet/shelf/CategoryBrickShelf.svelte
new file mode 100644
index 0000000..22ca86b
--- /dev/null
+++ b/src/components/jet/shelf/CategoryBrickShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface CategoryBrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isCategoryBrickShelf(
+ shelf: Shelf,
+ ): shelf is CategoryBrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'categoryBrick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import BrickItem from '~/components/jet/item/BrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: CategoryBrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="C" let:item>
+ <BrickItem {item} shouldOverlayDescription />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/EditorialCardShelf.svelte b/src/components/jet/shelf/EditorialCardShelf.svelte
new file mode 100644
index 0000000..efbd71d
--- /dev/null
+++ b/src/components/jet/shelf/EditorialCardShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type { Shelf, EditorialCard } from '@jet-app/app-store/api/models';
+
+ interface EditorialCardShelf extends Shelf {
+ items: EditorialCard[];
+ }
+
+ export function isEditorialCardShelf(
+ shelf: Shelf,
+ ): shelf is EditorialCardShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'editorialCard' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import HeroCarousel from '~/components/hero/Carousel.svelte';
+ import EditorialCardItem from '~/components/jet/item/EditorialCardItem.svelte';
+
+ export let shelf: EditorialCardShelf;
+
+ $: items = shelf.items;
+
+ function deriveBackgroundArtworkFromItem(item: EditorialCard) {
+ return item.artwork;
+ }
+</script>
+
+<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item>
+ <EditorialCardItem {item} />
+</HeroCarousel>
diff --git a/src/components/jet/shelf/EditorialLinkShelf.svelte b/src/components/jet/shelf/EditorialLinkShelf.svelte
new file mode 100644
index 0000000..0946462
--- /dev/null
+++ b/src/components/jet/shelf/EditorialLinkShelf.svelte
@@ -0,0 +1,122 @@
+<script lang="ts" context="module">
+ import type { Shelf, EditorialLink } from '@jet-app/app-store/api/models';
+
+ interface EditorialLinkShelf extends Shelf {
+ contentType: 'smallStoryCard';
+ items: [EditorialLink];
+ }
+
+ export function isEditorialLinkShelf(
+ shelf: Shelf,
+ ): shelf is EditorialLinkShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'editorialLink' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ChevronRightIcon from '~/sf-symbols/chevron.right.svg';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let shelf: EditorialLinkShelf;
+ $: item = shelf.items[0];
+ $: ({ clickAction, descriptionText, summaryText } = item);
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <article>
+ <LinkWrapper
+ action={clickAction}
+ includeExternalLinkArrowIcon={false}
+ label={descriptionText}
+ >
+ <svelte:fragment>
+ <div>
+ <span class="title">{descriptionText}</span>
+ <span class="subtitle">{summaryText}</span>
+ </div>
+
+ <span class="icon-container" aria-hidden="true">
+ <ChevronRightIcon />
+ </span>
+ </svelte:fragment>
+ </LinkWrapper>
+ </article>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ article {
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ justify-content: space-between;
+ padding: 16px;
+ margin: 0 var(--bodyGutter);
+ border-radius: var(--global-border-radius-medium);
+ background-color: var(--systemQuinary);
+ transition: background-color 210ms ease-out;
+ }
+
+ article:hover {
+ cursor: pointer;
+ // a fallback for browsers that don't support relative colors (e.g. the `from` syntax)
+ background-color: var(--systemQuinary);
+ // stylelint-disable-next-line color-function-notation
+ background-color: rgb(
+ from var(--systemQuinary) r g b / calc(alpha + 0.02)
+ );
+ }
+
+ article:hover .icon-container {
+ transform: translateX(2px);
+
+ @include rtl {
+ transform: translateX(-2px) rotate(-180deg);
+ }
+ }
+
+ div {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .title {
+ font: var(--body-emphasized);
+ }
+
+ .subtitle {
+ color: var(--systemSecondary);
+ }
+
+ .icon-container {
+ position: relative;
+ height: 10px;
+ aspect-ratio: 0.9;
+ transition: transform 210ms ease-out;
+
+ @include rtl {
+ transform: rotate(-180deg);
+ }
+ }
+
+ .icon-container :global(path:not([fill='none'])) {
+ fill: var(--systemPrimary);
+ }
+
+ article :global(a) {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ width: 100%;
+
+ &:hover {
+ text-decoration: none;
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/FallbackShelf.svelte b/src/components/jet/shelf/FallbackShelf.svelte
new file mode 100644
index 0000000..c7e4200
--- /dev/null
+++ b/src/components/jet/shelf/FallbackShelf.svelte
@@ -0,0 +1,39 @@
+<script lang="ts" context="module">
+ import type { Shelf, ShelfModel } from '@jet-app/app-store/api/models';
+
+ interface FallbackShelf extends Shelf {
+ items: ShelfModel[];
+ }
+
+ export function isFallbackShelf(shelf: Shelf): shelf is FallbackShelf {
+ return Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: FallbackShelf;
+
+ const isPlaceholder = shelf.contentType === 'placeholder';
+</script>
+
+<ShelfWrapper withTopBorder>
+ <ShelfItemLayout {shelf} gridType="C">
+ <div class="wip">
+ {isPlaceholder
+ ? `🔄 Placeholder for ${shelf.placeholderContentType}`
+ : `🚧 ${shelf.contentType}`}
+ </div>
+ </ShelfItemLayout>
+</ShelfWrapper>
+
+<style>
+ .wip {
+ background: #f8f8f8;
+ padding: 16px;
+ border-radius: 8px;
+ border: 1px solid #ccc;
+ }
+</style>
diff --git a/src/components/jet/shelf/FramedArtworkShelf.svelte b/src/components/jet/shelf/FramedArtworkShelf.svelte
new file mode 100644
index 0000000..16f7c48
--- /dev/null
+++ b/src/components/jet/shelf/FramedArtworkShelf.svelte
@@ -0,0 +1,98 @@
+<script lang="ts" context="module">
+ import type { FramedArtwork, Shelf } from '@jet-app/app-store/api/models';
+
+ interface FramedArtworkShelf extends Shelf {
+ contentType: 'framedArtwork';
+ items: [FramedArtwork];
+ }
+
+ export function isFramedArtworkShelf(
+ shelf: Shelf,
+ ): shelf is FramedArtworkShelf {
+ return (
+ shelf.contentType === 'framedArtwork' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: FramedArtworkShelf;
+
+ $: item = shelf.items[0];
+ $: ({ artwork, caption, hasRoundedCorners } = item);
+ $: profile = getNaturalProfile(artwork, [1275, 1185, 825, 500, 690]);
+ $: aspectRatio = artwork.width / artwork.height;
+ $: isPortrait = aspectRatio < 1;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <figure
+ class="framed-artwork-item"
+ class:has-rounded-corners={hasRoundedCorners}
+ class:is-portrait={isPortrait}
+ >
+ <div
+ class="artwork-container"
+ style:--aspect-ratio={artwork.width / artwork.height}
+ >
+ <Artwork {artwork} {profile} forceFullWidth={!isPortrait} />
+ </div>
+
+ {#if caption}
+ <figcaption class="caption">
+ {@html sanitizeHtml(caption)}
+ </figcaption>
+ {/if}
+ </figure>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ .framed-artwork-item {
+ border-radius: var(--framed-artwork-border-radius);
+ padding: 0 20px;
+ overflow: hidden;
+
+ @media (--sidebar-visible) {
+ padding: 0 20px;
+ }
+
+ @media (--range-small-only) {
+ padding: 0 var(--bodyGutter);
+ }
+ }
+
+ .framed-artwork-item.has-rounded-corners {
+ --framed-artwork-border-radius: var(--global-border-radius-medium);
+ }
+
+ .artwork-container {
+ border-radius: inherit;
+ }
+
+ .caption {
+ border-bottom-left-radius: var(--framed-artwork-border-radius);
+ border-bottom-right-radius: var(--framed-artwork-border-radius);
+ color: var(--systemSecondary);
+ padding: 8px var(--article-page-padding) 0;
+ }
+
+ .framed-artwork-item.is-portrait {
+ --artwork-override-max-height: 560px;
+ --artwork-override-max-width: 100%;
+ --artwork-override-width: auto;
+ }
+
+ .framed-artwork-item.framed-artwork-item.is-portrait .artwork-container {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+ max-height: var(--artwork-override-max-height);
+ aspect-ratio: var(--aspect-ratio);
+ }
+</style>
diff --git a/src/components/jet/shelf/FramedVideoShelf.svelte b/src/components/jet/shelf/FramedVideoShelf.svelte
new file mode 100644
index 0000000..a685d39
--- /dev/null
+++ b/src/components/jet/shelf/FramedVideoShelf.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type { FramedVideo, Shelf } from '@jet-app/app-store/api/models';
+
+ interface FramedVideoShelf extends Shelf {
+ contentType: 'framedArtwork';
+ items: [FramedVideo];
+ }
+
+ export function isFramedVideoShelf(
+ shelf: Shelf,
+ ): shelf is FramedVideoShelf {
+ return (
+ shelf.contentType === 'framedVideo' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { getNaturalProfile } from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+
+ export let shelf: FramedVideoShelf;
+
+ $: ({ caption, video } = shelf.items[0]);
+ $: aspectRatio = video.preview.width / video.preview.height;
+ $: profile = getNaturalProfile(video.preview, [608, 528, 608, 928, 298]);
+ $: isPortrait = aspectRatio < 1;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <figure class="framed-artwork-item" class:is-portrait={isPortrait}>
+ <div class="artwork-container" style:--aspect-ratio={aspectRatio}>
+ <Video {video} {profile} autoplay />
+ </div>
+
+ {#if caption}
+ <figcaption class="caption">
+ {@html sanitizeHtml(caption)}
+ </figcaption>
+ {/if}
+ </figure>
+</ShelfWrapper>
+
+<style>
+ .framed-artwork-item {
+ border-radius: var(--global-border-radius-medium);
+ padding: 0 20px;
+ overflow: hidden;
+
+ @media (--sidebar-visible) {
+ padding: 0 20px;
+ }
+
+ @media (--range-small-only) {
+ padding: 0 var(--bodyGutter);
+ }
+ }
+
+ .artwork-container {
+ aspect-ratio: var(--aspect-ratio);
+ overflow: hidden;
+ line-height: 0;
+ border-radius: var(--global-border-radius-medium);
+ background-color: var(--systemQuaternary);
+ max-height: 560px;
+ max-width: 100%;
+ margin: 0 auto;
+ }
+
+ .caption {
+ border-bottom-left-radius: var(--global-border-radius-medium);
+ border-bottom-right-radius: var(--global-border-radius-medium);
+ color: var(--systemSecondary);
+ padding: 8px var(--article-page-padding) 0;
+ }
+</style>
diff --git a/src/components/jet/shelf/HeroCarouselShelf.svelte b/src/components/jet/shelf/HeroCarouselShelf.svelte
new file mode 100644
index 0000000..31a0287
--- /dev/null
+++ b/src/components/jet/shelf/HeroCarouselShelf.svelte
@@ -0,0 +1,38 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ HeroCarousel as HeroCarouselModel,
+ HeroCarouselItem as HeroCarouselItemModel,
+ } from '@jet-app/app-store/api/models';
+
+ interface HeroCarouselShelf extends Shelf {
+ items: [HeroCarouselModel];
+ }
+
+ export function isHeroCarouselShelf(
+ shelf: Shelf,
+ ): shelf is HeroCarouselShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'heroCarousel' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import HeroCarousel from '~/components/hero/Carousel.svelte';
+ import HeroCarouselItem from '~/components/jet/item/HeroCarouselItem.svelte';
+ import { isRtl } from '~/utils/locale';
+
+ export let shelf: HeroCarouselShelf;
+
+ $: ({ items: ltrItems, rtlItems } = shelf.items[0]);
+ $: items = isRtl() && rtlItems.length ? rtlItems : ltrItems;
+
+ function deriveBackgroundArtworkFromItem(item: HeroCarouselItemModel) {
+ return item.artwork || item.video?.preview;
+ }
+</script>
+
+<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item>
+ <HeroCarouselItem {item} />
+</HeroCarousel>
diff --git a/src/components/jet/shelf/HorizontalRuleShelf.svelte b/src/components/jet/shelf/HorizontalRuleShelf.svelte
new file mode 100644
index 0000000..3313ff2
--- /dev/null
+++ b/src/components/jet/shelf/HorizontalRuleShelf.svelte
@@ -0,0 +1,54 @@
+<script lang="ts" context="module">
+ import type {
+ HorizontalRule,
+ HorizontalRuleStyle,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ export interface HorizontalRuleShelf extends Shelf {
+ contentType: 'horizontalRule';
+ items: [HorizontalRule];
+ }
+
+ export function isHorizontalRuleShelf(
+ shelf: Shelf,
+ ): shelf is HorizontalRuleShelf {
+ return (
+ shelf.contentType === 'horizontalRule' && Array.isArray(shelf.items)
+ );
+ }
+
+ function horizontalRuleStyleToBorderStyle(
+ style: HorizontalRuleStyle,
+ ): string {
+ return style.toLowerCase();
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let shelf: HorizontalRuleShelf;
+
+ $: item = shelf.items[0];
+ $: borderStyle = horizontalRuleStyleToBorderStyle(item.style);
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <hr
+ style:color={colorAsString(item.color)}
+ style:border-style={borderStyle}
+ />
+</ShelfWrapper>
+
+<style>
+ hr {
+ display: block;
+ height: 1px;
+ border-width: 1px 0 0;
+ border-color: currentColor;
+ margin: 1em var(--bodyGutter);
+ padding: 0;
+ }
+</style>
diff --git a/src/components/jet/shelf/HorizontalShelf.svelte b/src/components/jet/shelf/HorizontalShelf.svelte
new file mode 100644
index 0000000..1addb31
--- /dev/null
+++ b/src/components/jet/shelf/HorizontalShelf.svelte
@@ -0,0 +1,53 @@
+<script lang="ts">
+ import type { Opt } from '@jet/environment';
+ import Shelf from '@amp/web-app-components/src/components/Shelf/Shelf.svelte';
+ import type {
+ ArrowOffset,
+ GridType,
+ } from '@amp/web-app-components/src/components/Shelf/types';
+ import { getI18n } from '~/stores/i18n';
+
+ type T = $$Generic;
+
+ export let items: T[];
+ export let gridType: GridType;
+ export let gridRows: number = 1;
+ export let arrowOffset: Opt<ArrowOffset> = null;
+
+ const i18n = getI18n();
+ // This makes the let:item of type T, because it doesn't know type when it comes back from the Shelf component.
+ function castGenericItem(x: T): T {
+ return x;
+ }
+</script>
+
+<div class="horizontal-shelf" data-test-id="horizontal-shelf">
+ <Shelf translateFn={$i18n.t} {items} {gridType} {gridRows} {arrowOffset}>
+ <svelte:fragment slot="item" let:item let:index let:numberOfItems>
+ <slot item={castGenericItem(item)} {index} {numberOfItems} />
+ </svelte:fragment>
+ </Shelf>
+</div>
+
+<style>
+ .horizontal-shelf :global(.shelf-grid) {
+ --shelfGridPaddingInline: var(--bodyGutter);
+ --shelfGridGutterWidth: var(--bodyGutter);
+ }
+
+ .horizontal-shelf :global(.shelf-grid__list) {
+ @media (--range-xsmall-only) {
+ scroll-padding-inline-start: var(
+ --shelfScrollPaddingInline,
+ var(--bodyGutter)
+ );
+ }
+ }
+
+ .horizontal-shelf
+ :global(.shelf-grid__list--grid-type-Spotlight .shelf-grid__list-item) {
+ @media (--range-xsmall-only) {
+ --standard-lockup-shadow-offset: var(--shelfScrollPaddingInline, 0);
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte
new file mode 100644
index 0000000..bf2e75e
--- /dev/null
+++ b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type {
+ InAppPurchaseLockup,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface InAppPurchaseLockupShelf extends Shelf {
+ items: InAppPurchaseLockup[];
+ }
+
+ export function isInAppPurchaseLockupShelf(
+ shelf: Shelf,
+ ): shelf is InAppPurchaseLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'inAppPurchaseLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import InAppPurchaseLockupComponent from '~/components/jet/item/InAppPurchaseLockup.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: InAppPurchaseLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="InAppPurchaseLockup" let:item>
+ <InAppPurchaseLockupComponent {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeBrickShelf.svelte b/src/components/jet/shelf/LargeBrickShelf.svelte
new file mode 100644
index 0000000..eea1044
--- /dev/null
+++ b/src/components/jet/shelf/LargeBrickShelf.svelte
@@ -0,0 +1,26 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface LargeBrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isLargeBrickShelf(shelf: Shelf): shelf is LargeBrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'largeBrick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import LargeBrickItem from '~/components/jet/item/LargeBrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeBrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="LargeBrick" let:item>
+ <LargeBrickItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte
new file mode 100644
index 0000000..a0dfe9c
--- /dev/null
+++ b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type {
+ LargeHeroBreakout,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+
+ interface LargeHeroBreakoutShelf extends Shelf {
+ items: LargeHeroBreakout[];
+ }
+
+ export function isLargeHeroBreakoutShelf(
+ shelf: Shelf,
+ ): shelf is LargeHeroBreakoutShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'largeHeroBreakout' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import LargeHeroBreakoutItem from '~/components/jet/item/LargeHeroBreakoutItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeHeroBreakoutShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="Spotlight" let:item>
+ <LargeHeroBreakoutItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeImageLockupShelf.svelte b/src/components/jet/shelf/LargeImageLockupShelf.svelte
new file mode 100644
index 0000000..fd192fb
--- /dev/null
+++ b/src/components/jet/shelf/LargeImageLockupShelf.svelte
@@ -0,0 +1,30 @@
+<script lang="ts" context="module">
+ import type { Shelf, ImageLockup } from '@jet-app/app-store/api/models';
+
+ interface LargeImageLockupShelf extends Shelf {
+ items: ImageLockup[];
+ }
+
+ export function isLargeImageLockupShelf(
+ shelf: Shelf,
+ ): shelf is LargeImageLockupShelf {
+ return (
+ shelf.contentType === 'largeImageLockup' &&
+ Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import LargeImageLockupItem from '~/components/jet/item/LargeImageLockupItem.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeImageLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <LargeImageLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeLockupShelf.svelte b/src/components/jet/shelf/LargeLockupShelf.svelte
new file mode 100644
index 0000000..dedd1fe
--- /dev/null
+++ b/src/components/jet/shelf/LargeLockupShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type { Lockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface LargeLockupShelf extends Shelf {
+ items: Lockup[];
+ }
+
+ export function isLargeLockupShelf(
+ shelf: Shelf,
+ ): shelf is LargeLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'largeLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import LargeLockupItem from '~/components/jet/item/LargeLockupItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: LargeLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="LargeLockup" let:item>
+ <LargeLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/LargeStoryCardShelf.svelte b/src/components/jet/shelf/LargeStoryCardShelf.svelte
new file mode 100644
index 0000000..c1a1e57
--- /dev/null
+++ b/src/components/jet/shelf/LargeStoryCardShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type { Shelf, TodayCard } from '@jet-app/app-store/api/models';
+
+ interface LargeStoryCardShelf extends Shelf {
+ items: TodayCard[];
+ }
+
+ export function isLargeStoryCardShelf(
+ shelf: Shelf,
+ ): shelf is LargeStoryCardShelf {
+ return (
+ shelf.contentType === 'largeStoryCard' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import HeroCarousel from '~/components/hero/Carousel.svelte';
+ import LargeStoryCardItem from '~/components/jet/item/LargeStoryCardItem.svelte';
+
+ export let shelf: LargeStoryCardShelf;
+
+ $: items = shelf.items;
+
+ function deriveBackgroundArtworkFromItem(item: TodayCard) {
+ return item.heroMedia?.artworks[0];
+ }
+</script>
+
+<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item>
+ <LargeStoryCardItem {item} />
+</HeroCarousel>
diff --git a/src/components/jet/shelf/LinkableTextShelf.svelte b/src/components/jet/shelf/LinkableTextShelf.svelte
new file mode 100644
index 0000000..dcfde36
--- /dev/null
+++ b/src/components/jet/shelf/LinkableTextShelf.svelte
@@ -0,0 +1,43 @@
+<script lang="ts" context="module">
+ import type { Shelf, LinkableText } from '@jet-app/app-store/api/models';
+
+ interface LinkableTextShelf extends Shelf {
+ contentType: 'linkableText';
+ items: [LinkableText];
+ }
+
+ export function isLinkableTextShelf(
+ shelf: Shelf,
+ ): shelf is LinkableTextShelf {
+ return (
+ shelf.contentType === 'linkableText' && Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+
+ export let shelf: LinkableTextShelf;
+</script>
+
+<ShelfWrapper centered withPaddingTop={true}>
+ <div class="banner">
+ <LinkableTextItem item={shelf.items[0]} />
+ </div>
+</ShelfWrapper>
+
+<style>
+ .banner {
+ background: rgba(var(--keyColor-rgb), 0.07);
+ padding: 8px 16px;
+ text-align: center;
+ border-radius: var(--global-border-radius-small);
+ }
+
+ .banner :global(a) {
+ color: var(--keyColor);
+ }
+</style>
diff --git a/src/components/jet/shelf/MarkerShelf.svelte b/src/components/jet/shelf/MarkerShelf.svelte
new file mode 100644
index 0000000..c719235
--- /dev/null
+++ b/src/components/jet/shelf/MarkerShelf.svelte
@@ -0,0 +1,36 @@
+<script lang="ts" context="module">
+ import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models';
+ import type {
+ Lockup,
+ Shelf,
+ ShelfMarker,
+ } from '@jet-app/app-store/api/models';
+
+ export interface MarkerShelf extends Shelf {
+ contentType: 'marker';
+ marker: ShelfMarker;
+ items: Lockup[];
+ }
+
+ export function isMarkerShelf(shelf: Shelf): shelf is MarkerShelf {
+ const { contentType, marker, items } = shelf;
+
+ return (
+ contentType === 'marker' &&
+ typeof marker === 'string' &&
+ Array.isArray(items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import ProductTopLockup from '~/components/jet/marker-shelf/ProductTopLockup.svelte';
+
+ export let shelf: MarkerShelf;
+
+ export let page: ShelfBasedProductPage;
+</script>
+
+{#if shelf.marker === 'productTopLockup'}
+ <ProductTopLockup {page} />
+{/if}
diff --git a/src/components/jet/shelf/MediumImageLockupShelf.svelte b/src/components/jet/shelf/MediumImageLockupShelf.svelte
new file mode 100644
index 0000000..f7b1316
--- /dev/null
+++ b/src/components/jet/shelf/MediumImageLockupShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type { ImageLockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface MediumImageLockupShelf extends Shelf {
+ items: ImageLockup[];
+ }
+
+ export function isMediumImageLockupShelf(
+ shelf: Shelf,
+ ): shelf is MediumImageLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'mediumImageLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import MediumImageLockupItem from '~/components/jet/item/MediumImageLockupItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: MediumImageLockupShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <MediumImageLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/MediumLockupShelf.svelte b/src/components/jet/shelf/MediumLockupShelf.svelte
new file mode 100644
index 0000000..186acb2
--- /dev/null
+++ b/src/components/jet/shelf/MediumLockupShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Lockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface MediumLockupShelf extends Shelf {
+ items: Lockup[];
+ }
+
+ export function isMediumLockupShelf(
+ shelf: Shelf,
+ ): shelf is MediumLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'mediumLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import MediumLockupItem from '~/components/jet/item/MediumLockupItem.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: MediumLockupShelf;
+
+ $: isArticleContext = shelf.presentationHints?.isArticleContext;
+ $: gridType = isArticleContext ? 'Spotlight' : 'MediumLockup';
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} {gridType} rowsPerColumnOverride={2} let:item>
+ <MediumLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/MediumStoryCardShelf.svelte b/src/components/jet/shelf/MediumStoryCardShelf.svelte
new file mode 100644
index 0000000..35c3ec3
--- /dev/null
+++ b/src/components/jet/shelf/MediumStoryCardShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+
+ import MediumStoryCardItem, {
+ type Item as MediumStoryCardItemModel,
+ } from '~/components/jet/item/MediumStoryCardItem.svelte';
+
+ interface MediumStoryCardShelf extends Shelf {
+ items: MediumStoryCardItemModel[];
+ }
+
+ export function isMediumStoryCardShelf(
+ shelf: Shelf,
+ ): shelf is MediumStoryCardShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'mediumStoryCard' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: MediumStoryCardShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <MediumStoryCardItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/PageHeaderShelf.svelte b/src/components/jet/shelf/PageHeaderShelf.svelte
new file mode 100644
index 0000000..59c99b2
--- /dev/null
+++ b/src/components/jet/shelf/PageHeaderShelf.svelte
@@ -0,0 +1,34 @@
+<script lang="ts" context="module">
+ import type { PageHeader, Shelf } from '@jet-app/app-store/api/models';
+
+ interface PageHeaderShelf extends Shelf {
+ items: [PageHeader];
+ }
+
+ export function isPageHeaderShelf(shelf: Shelf): shelf is PageHeaderShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'pageHeader' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+
+ export let shelf: PageHeaderShelf;
+
+ $: [item] = shelf.items;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div class="shelf-title-wrapper" slot="title">
+ <ShelfTitle title={item.title} subtitle={item.subtitle} />
+ </div>
+</ShelfWrapper>
+
+<style>
+ .shelf-title-wrapper {
+ --shelf-title-font: var(--title-1-emphasized);
+ display: contents;
+ }
+</style>
diff --git a/src/components/jet/shelf/ParagraphShelf.svelte b/src/components/jet/shelf/ParagraphShelf.svelte
new file mode 100644
index 0000000..777338e
--- /dev/null
+++ b/src/components/jet/shelf/ParagraphShelf.svelte
@@ -0,0 +1,52 @@
+<script lang="ts" context="module">
+ import type { Paragraph, Shelf } from '@jet-app/app-store/api/models';
+
+ interface ParagraphShelf extends Shelf {
+ contentType: 'paragraph';
+ items: Paragraph[];
+ }
+
+ export function isParagraphShelf(shelf: Shelf): shelf is ParagraphShelf {
+ return shelf.contentType === 'paragraph' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ParagraphShelfItem from '~/components/jet/item/ParagraphShelfItem.svelte';
+
+ export let shelf: ParagraphShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ <h2>{shelf.title}</h2>
+ {/if}
+ </div>
+
+ <div class="content-container">
+ {#each shelf.items as item}
+ <ParagraphShelfItem {item} />
+ {/each}
+ </div>
+</ShelfWrapper>
+
+<style>
+ h2 {
+ color: var(--systemPrimary);
+ font: var(--title-2-emphasized);
+ text-wrap: pretty;
+ margin: 16px 0;
+ }
+
+ .title-container,
+ .content-container {
+ margin: 0 var(--bodyGutter);
+ }
+
+ /* Whenever this shelf is nested in a modal, we don't want to add extra margin since the modal provides its own */
+ :global(.modal-content) .content-container {
+ margin: unset;
+ }
+</style>
diff --git a/src/components/jet/shelf/PosterLockupShelf.svelte b/src/components/jet/shelf/PosterLockupShelf.svelte
new file mode 100644
index 0000000..101c1d6
--- /dev/null
+++ b/src/components/jet/shelf/PosterLockupShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type { Shelf, PosterLockup } from '@jet-app/app-store/api/models';
+
+ interface PosterLockupShelf extends Shelf {
+ items: PosterLockup[];
+ }
+
+ export function isPosterLockupShelf(
+ shelf: Shelf,
+ ): shelf is PosterLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'posterLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import mediaQueries from '~/utils/media-queries';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import PosterLockupItem from '~/components/jet/item/PosterLockupItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: PosterLockupShelf;
+
+ $: gridType = $mediaQueries === 'xsmall' ? 'Spotlight' : 'PosterLockup';
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} {gridType} let:item>
+ <PosterLockupItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/PrivacyFooterShelf.svelte b/src/components/jet/shelf/PrivacyFooterShelf.svelte
new file mode 100644
index 0000000..dccade6
--- /dev/null
+++ b/src/components/jet/shelf/PrivacyFooterShelf.svelte
@@ -0,0 +1,40 @@
+<script lang="ts" context="module">
+ import type { PrivacyFooter, Shelf } from '@jet-app/app-store/api/models';
+
+ interface PrivacyFooterShelf extends Shelf {
+ items: [PrivacyFooter];
+ }
+
+ export function isPrivacyFooterShelf(
+ shelf: Shelf,
+ ): shelf is PrivacyFooterShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'privacyFooter' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: PrivacyFooterShelf;
+
+ $: bodyText = shelf.items[0].bodyText;
+</script>
+
+<ShelfWrapper {shelf} centered>
+ <p>
+ <LinkableTextItem item={bodyText} />
+ </p>
+</ShelfWrapper>
+
+<style>
+ p {
+ font: var(--body-tall);
+ }
+
+ p :global(a) {
+ color: var(--keyColor);
+ }
+</style>
diff --git a/src/components/jet/shelf/PrivacyHeaderShelf.svelte b/src/components/jet/shelf/PrivacyHeaderShelf.svelte
new file mode 100644
index 0000000..5ace666
--- /dev/null
+++ b/src/components/jet/shelf/PrivacyHeaderShelf.svelte
@@ -0,0 +1,145 @@
+<script lang="ts" context="module">
+ import {
+ type Action,
+ type FlowAction,
+ type GenericPage,
+ type PrivacyHeader,
+ type Shelf,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ import {
+ isPrivacyTypeShelf,
+ type PrivacyTypeShelf,
+ } from '~/components/jet/shelf/PrivacyTypeShelf.svelte';
+
+ interface PrivacyHeaderShelf extends Shelf {
+ items: [PrivacyHeader];
+ }
+
+ interface PrivacyDetailPage extends GenericPage {
+ shelves: (PrivacyTypeShelf | PrivacyHeaderShelf)[];
+ }
+
+ interface PrivacyDetailPageFlowAction extends FlowAction {
+ page: 'privacyDetail';
+ pageData: PrivacyDetailPage;
+ }
+
+ export function isPrivacyHeaderShelf(
+ shelf: Shelf,
+ ): shelf is PrivacyHeaderShelf {
+ let { contentType, items } = shelf;
+ return contentType === 'privacyHeader' && Array.isArray(items);
+ }
+
+ function isPrivacyDetailFlowAction(
+ action: Action,
+ ): action is PrivacyDetailPageFlowAction {
+ return isFlowAction(action) && action.page === 'privacyDetail';
+ }
+</script>
+
+<script lang="ts">
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import PrivacyHeaderItem from '~/components/jet/item/PrivacyHeaderItem.svelte';
+ import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { APP_PRIVACY_MODAL_ID } from '~/utils/metrics';
+
+ export let shelf: PrivacyHeaderShelf;
+
+ let modalComponent: Modal | undefined;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const { seeAllAction } = shelf;
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+ const handleModalClose = () => modalComponent?.close();
+ const handleOpenModalClick = (e: Event) => {
+ modalTriggerElement = e.target as HTMLElement;
+ modalComponent?.showModal();
+ };
+
+ const destination =
+ seeAllAction && isPrivacyDetailFlowAction(seeAllAction)
+ ? seeAllAction
+ : undefined;
+ const pageData = destination?.pageData;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ <button on:click={handleOpenModalClick}>
+ <ShelfTitle title={shelf.title} seeAllAction={destination} />
+ </button>
+ {/if}
+
+ {#if pageData}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ {translateFn}
+ on:close={handleModalClose}
+ title={pageData.title || null}
+ subtitle={null}
+ targetId={APP_PRIVACY_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <ul class="modal-content-container">
+ {#each pageData.shelves as shelf}
+ {#if isPrivacyHeaderShelf(shelf)}
+ {#each shelf.items as item}
+ <PrivacyHeaderItem {item} />
+ {/each}
+ {/if}
+
+ {#if isPrivacyTypeShelf(shelf)}
+ {#each shelf.items as item}
+ <PrivacyTypeItem
+ {item}
+ isDetailView={true}
+ />
+ {/each}
+ {/if}
+ {/each}
+ </ul>
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+ {/if}
+ </div>
+
+ <div class="header-container">
+ <div>
+ <PrivacyHeaderItem item={shelf.items[0]} />
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style>
+ .title-container {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 16px;
+ padding-inline-end: var(--bodyGutter);
+ }
+
+ .header-container {
+ margin: 0 var(--bodyGutter);
+ }
+
+ .header-container div {
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ .modal-content-container {
+ font: var(--body-tall);
+ white-space: normal;
+ }
+</style>
diff --git a/src/components/jet/shelf/PrivacyTypeShelf.svelte b/src/components/jet/shelf/PrivacyTypeShelf.svelte
new file mode 100644
index 0000000..3817251
--- /dev/null
+++ b/src/components/jet/shelf/PrivacyTypeShelf.svelte
@@ -0,0 +1,29 @@
+<script lang="ts" context="module">
+ import type { PrivacyType, Shelf } from '@jet-app/app-store/api/models';
+
+ export interface PrivacyTypeShelf extends Shelf {
+ items: PrivacyType[];
+ }
+
+ export function isPrivacyTypeShelf(
+ shelf: Shelf,
+ ): shelf is PrivacyTypeShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'privacyType' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte';
+
+ export let shelf: PrivacyTypeShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <ShelfItemLayout {shelf} gridType="B" let:item>
+ <PrivacyTypeItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ProductBadgeShelf.svelte b/src/components/jet/shelf/ProductBadgeShelf.svelte
new file mode 100644
index 0000000..cded0b7
--- /dev/null
+++ b/src/components/jet/shelf/ProductBadgeShelf.svelte
@@ -0,0 +1,59 @@
+<script lang="ts" context="module">
+ import type { Badge, Shelf } from '@jet-app/app-store/api/models';
+
+ interface ProductBadgeShelf extends Shelf {
+ items: Badge[];
+ }
+
+ export function isProductBadgeShelf(
+ shelf: Shelf,
+ ): shelf is ProductBadgeShelf {
+ const { contentType, items } = shelf || {};
+ return contentType === 'productBadge' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductBadgeItem from '~/components/jet/item/ProductBadgeItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductBadgeShelf;
+
+ $: shelf.items = shelf.items.filter(
+ (item) => item.type !== 'friendsPlaying',
+ );
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false} withTopMargin={true}>
+ <div class="inforibbon-shelf-wrapper">
+ <ShelfItemLayout {shelf} gridType="ProductBadge" let:item>
+ <ProductBadgeItem {item} />
+ </ShelfItemLayout>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ .inforibbon-shelf-wrapper {
+ padding-bottom: 16px;
+ }
+
+ .inforibbon-shelf-wrapper :global(ul) {
+ display: grid;
+
+ /*
+ Here we are overriding the grid template styles from `ShelfItemLayout -> Grid`,
+ to make it so the badge row always takes up the full-width of the browser until
+ when not in the XS/mobile view.
+ */
+ @media (--range-small-up) {
+ display: flex;
+ justify-content: space-between;
+ }
+ }
+
+ // prevent collapse of focus outlines
+ .inforibbon-shelf-wrapper :global(a) {
+ display: block;
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductCapabilityShelf.svelte b/src/components/jet/shelf/ProductCapabilityShelf.svelte
new file mode 100644
index 0000000..6a4307a
--- /dev/null
+++ b/src/components/jet/shelf/ProductCapabilityShelf.svelte
@@ -0,0 +1,31 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ProductCapability,
+ } from '@jet-app/app-store/api/models';
+
+ interface ProductCapabilityShelf extends Shelf {
+ items: ProductCapability[];
+ }
+
+ export function isProductCapabilityShelf(
+ shelf: Shelf,
+ ): shelf is ProductCapabilityShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'productCapability' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductCapabilityItem from '../item/ProductCapabilityItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductCapabilityShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="SearchLink" let:item>
+ <ProductCapabilityItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ProductDescriptionShelf.svelte b/src/components/jet/shelf/ProductDescriptionShelf.svelte
new file mode 100644
index 0000000..7cddcee
--- /dev/null
+++ b/src/components/jet/shelf/ProductDescriptionShelf.svelte
@@ -0,0 +1,95 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ProductDescription,
+ } from '@jet-app/app-store/api/models';
+
+ interface ProductDescriptionShelf extends Shelf {
+ items: [ProductDescription];
+ }
+
+ export function isProductDescriptionShelf(
+ shelf: Shelf,
+ ): shelf is ProductDescriptionShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'productDescription' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let shelf: ProductDescriptionShelf;
+
+ const i18n = getI18n();
+ const description = shelf.items[0]?.paragraph.text;
+ const handleMoreClick = () => (isOpen = true);
+ let isOpen = false;
+
+ function handleLineClampResize(event: CustomEvent) {
+ if (!event.detail.truncated) {
+ isOpen = true;
+ }
+ }
+</script>
+
+<ShelfWrapper centered>
+ <article>
+ <p>
+ {#if isOpen}
+ {@html sanitizeHtml(description)}
+ {:else}
+ <LineClamp observe clamp={5} on:resize={handleLineClampResize}>
+ {@html sanitizeHtml(description)}
+ </LineClamp>
+ {/if}
+
+ {#if !isOpen}
+ <button on:click={handleMoreClick}>
+ {$i18n.t('ASE.Web.AppStore.More')}
+ </button>
+ {/if}
+ </p>
+ </article>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ p {
+ white-space: break-spaces;
+ font: var(--body-tall);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+
+ @media (--range-medium-up) {
+ width: 66%;
+ }
+ }
+
+ button {
+ --gradient-direction: 270deg;
+ display: flex;
+ justify-content: end;
+ position: absolute;
+ bottom: 0;
+ inset-inline-end: 0;
+ padding-inline-start: 20px;
+ color: var(--keyColor);
+ background: linear-gradient(
+ var(--gradient-direction),
+ var(--pageBg) 72%,
+ transparent 100%
+ );
+
+ @include rtl {
+ --gradient-direction: 90deg;
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductMediaShelf.svelte b/src/components/jet/shelf/ProductMediaShelf.svelte
new file mode 100644
index 0000000..f57fee7
--- /dev/null
+++ b/src/components/jet/shelf/ProductMediaShelf.svelte
@@ -0,0 +1,269 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ProductMedia,
+ AppPlatform,
+ MediaType,
+ MediaPlatform,
+ } from '@jet-app/app-store/api/models';
+
+ interface ProductMediaShelf extends Shelf, ProductMedia {
+ items: ProductMedia['items'];
+ expandedMedia?: ProductMediaShelf[];
+ }
+
+ export function isProductMediaShelf(
+ shelf: Shelf,
+ ): shelf is ProductMediaShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'productMediaItem' && Array.isArray(items);
+ }
+
+ const platformToIconNameMap: Record<AppPlatform, string> = {
+ phone: 'iphone.gen2',
+ pad: 'ipad.gen2',
+ tv: 'tv',
+ watch: 'applewatch',
+ mac: 'macbook.gen2',
+ messages: 'message',
+ vision: 'visionpro',
+ };
+
+ const platformToDescriptionMap: Record<AppPlatform, string> = {
+ phone: 'AppStore.AppPlatform.Phone',
+ pad: 'AppStore.AppPlatform.Pad',
+ tv: 'AppStore.AppPlatform.TV',
+ watch: 'AppStore.AppPlatform.Watch',
+ mac: 'AppStore.AppPlatform.Mac',
+ messages: 'AppStore.AppPlatform.Messages',
+ vision: 'AppStore.AppPlatform.Vision',
+ };
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductMediaVisionItem from '~/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte';
+ import ProductMediaPhoneItem from '~/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte';
+ import ProductMediaMacItem from '~/components/jet/item/ProductMedia/ProductMediaMacItem.svelte';
+ import ProductMediaPadItem from '~/components/jet/item/ProductMedia/ProductMediaPadItem.svelte';
+ import ProductMediaWatchItem from '~/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte';
+ import ProductMediaTVItem from '~/components/jet/item/ProductMedia/ProductMediaTVItem.svelte';
+ import ChevronDown from '~/sf-symbols/chevron.down.svg';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { slide } from 'svelte/transition';
+ import { getJet } from '~/jet';
+
+ export let shelf: ProductMediaShelf;
+ export let isExpandedMedia: boolean = false;
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ let appPlatform: AppPlatform | undefined;
+ let allPlatforms: MediaPlatform[] | undefined;
+ let mediaType: MediaType | undefined;
+ let hasPortraitMedia: boolean = false;
+ let shouldDisplayExpandedMedia: boolean = false;
+
+ $: {
+ if (shelf.contentsMetadata.type === 'productMedia') {
+ ({ hasPortraitMedia, allPlatforms } = shelf.contentsMetadata);
+ ({ appPlatform, mediaType } = shelf.contentsMetadata.platform);
+ }
+ }
+
+ $: allPlatformsDescription = allPlatforms
+ ?.map(({ appPlatform }) =>
+ $i18n.t(platformToDescriptionMap[appPlatform]),
+ )
+ ?.join($i18n.t('AppStore.AppPlatform.Component.Separator'));
+
+ $: shouldShowPlatform =
+ isExpandedMedia ||
+ shouldDisplayExpandedMedia ||
+ allPlatforms?.length === 1;
+
+ const displayExpandedMedia = () => {
+ shouldDisplayExpandedMedia = true;
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ actionDetails: { type: 'platformSelect' },
+ targetType: 'button',
+ targetId: 'productMediaShelf',
+ });
+ };
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={!shelf.expandedMedia}>
+ {#if appPlatform === 'vision'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotVision" let:item>
+ <ProductMediaVisionItem {item} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'phone' || appPlatform === 'messages'}
+ <ShelfItemLayout
+ {shelf}
+ gridType={hasPortraitMedia ? 'ScreenshotPhone' : 'ScreenshotLarge'}
+ let:item
+ >
+ <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'pad'}
+ <ShelfItemLayout
+ {shelf}
+ gridType={hasPortraitMedia ? 'ScreenshotPad' : 'ScreenshotLarge'}
+ let:item
+ >
+ <ProductMediaPadItem {item} {hasPortraitMedia} {mediaType} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'mac'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item>
+ <ProductMediaMacItem {item} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'tv'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item>
+ <ProductMediaTVItem {item} />
+ </ShelfItemLayout>
+ {:else if appPlatform === 'watch'}
+ <ShelfItemLayout {shelf} gridType="ScreenshotPhone" let:item>
+ <ProductMediaWatchItem {item} {mediaType} />
+ </ShelfItemLayout>
+ {:else}
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} />
+ </ShelfItemLayout>
+ {/if}
+
+ {#if appPlatform && shouldShowPlatform}
+ <div class="platform-description">
+ <div class="icon" aria-hidden="true">
+ <SFSymbol name={platformToIconNameMap[appPlatform]} />
+ </div>
+ <div class="platform-label">
+ {$i18n.t(platformToDescriptionMap[appPlatform])}
+ </div>
+ </div>
+ {/if}
+</ShelfWrapper>
+
+{#if shelf.expandedMedia && allPlatforms && allPlatforms.length > 1}
+ <div class="expanded-media">
+ {#if !shouldDisplayExpandedMedia}
+ <button
+ class="expanded-media-header"
+ on:click={displayExpandedMedia}
+ >
+ <div class="all-platforms">
+ <div class="all-platforms-icons">
+ {#each allPlatforms as platform}
+ <div class="icon" aria-hidden="true">
+ <SFSymbol
+ name={platformToIconNameMap[
+ platform.appPlatform
+ ]}
+ />
+ </div>
+ {/each}
+ </div>
+ <div class="all-platforms-names">
+ {allPlatformsDescription}
+ </div>
+ </div>
+ <div class="chevron-container icon" aria-hidden="true">
+ <ChevronDown />
+ </div>
+ </button>
+ {/if}
+ {#if shouldDisplayExpandedMedia}
+ <div class="expanded-media-content" transition:slide>
+ {#each shelf.expandedMedia as expandedMediaShelf}
+ <svelte:self
+ shelf={expandedMediaShelf}
+ isExpandedMedia={true}
+ />
+ {/each}
+ </div>
+ {/if}
+ </div>
+{/if}
+
+{#if !isExpandedMedia}
+ <div class="divider" />
+{/if}
+
+<style>
+ .expanded-media {
+ margin: 15px 0;
+ }
+
+ .expanded-media-header {
+ width: 100%;
+ padding-inline: var(--bodyGutter);
+ display: inline-flex;
+ align-items: center;
+ justify-content: space-between;
+ }
+
+ .platform-description {
+ display: inline-flex;
+ align-items: center;
+ font: var(--body-reduced-semibold);
+ color: var(--systemSecondary);
+ margin-top: 15px;
+ gap: 10px;
+ margin-inline: var(--bodyGutter);
+ }
+
+ .all-platforms {
+ display: inline-flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 10px;
+ color: var(--systemSecondary);
+ }
+
+ .all-platforms-icons {
+ display: inline-flex;
+ gap: 10px;
+ }
+
+ .all-platforms-names {
+ font: var(--body-reduced-semibold);
+ }
+
+ .icon :global(svg) {
+ overflow: visible;
+ height: 16px;
+ max-width: 25px;
+ fill: var(--systemSecondary);
+ position: relative;
+ display: flex;
+ }
+
+ .divider {
+ margin: 10px var(--bodyGutter);
+ border-bottom: 1px solid var(--systemGray4);
+ }
+
+ .chevron-container {
+ top: 2px;
+ }
+
+ .expanded-media-content :global(.shelf:last-of-type) {
+ padding-bottom: 0;
+ }
+
+ .expanded-media-header .all-platforms,
+ .expanded-media-header .chevron-container :global(svg) {
+ transition-duration: 210ms;
+ transition-timing-function: ease-out;
+ transition-property: color, fill;
+ }
+
+ .expanded-media-header:hover .all-platforms,
+ .expanded-media-header:hover .chevron-container :global(svg) {
+ color: var(--systemPrimary);
+ fill: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductPageLinkShelf.svelte b/src/components/jet/shelf/ProductPageLinkShelf.svelte
new file mode 100644
index 0000000..7b41e80
--- /dev/null
+++ b/src/components/jet/shelf/ProductPageLinkShelf.svelte
@@ -0,0 +1,59 @@
+<script lang="ts" context="module">
+ import type { Shelf, ProductPageLink } from '@jet-app/app-store/api/models';
+
+ interface ProductPageLinkShelf extends Shelf {
+ items: ProductPageLink[];
+ }
+
+ export function isProductPageLinkShelf(
+ shelf: Shelf,
+ ): shelf is ProductPageLinkShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'productPageLink' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ProductPageLinkItem from '~/components/jet/item/ProductPageLinkItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductPageLinkShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <div class="product-page-link-shelf">
+ {#each shelf.items as item}
+ <li class="product-page-link-item">
+ <ProductPageLinkItem {item} />
+ </li>
+ {/each}
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ $product-page-link-border: 1px solid var(--systemGray4);
+
+ .product-page-link-shelf {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ column-gap: 20px;
+
+ @media (--range-xsmall-down) {
+ flex-direction: column;
+ align-items: flex-start;
+ }
+ }
+
+ @media (--range-xsmall-down) {
+ .product-page-link-item:first-child {
+ border-top: $product-page-link-border;
+ }
+
+ .product-page-link-item {
+ width: 100%;
+ border-bottom: $product-page-link-border;
+ padding: 0 var(--bodyGutter);
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/ProductRatingsShelf.svelte b/src/components/jet/shelf/ProductRatingsShelf.svelte
new file mode 100644
index 0000000..8f09ab5
--- /dev/null
+++ b/src/components/jet/shelf/ProductRatingsShelf.svelte
@@ -0,0 +1,29 @@
+<script lang="ts" context="module">
+ import { type Ratings, type Shelf } from '@jet-app/app-store/api/models';
+
+ interface ProductRatingsShelf extends Shelf {
+ items: Ratings[];
+ }
+
+ export function isProductRatingsShelf(
+ shelf: Shelf,
+ ): shelf is ProductRatingsShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'productRatings' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ProductRatingsItem from '~/components/jet/item/ProductRatingsItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: ProductRatingsShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <ProductRatingsItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/ProductReviewShelf.svelte b/src/components/jet/shelf/ProductReviewShelf.svelte
new file mode 100644
index 0000000..6bc4ecb
--- /dev/null
+++ b/src/components/jet/shelf/ProductReviewShelf.svelte
@@ -0,0 +1,38 @@
+<script lang="ts" context="module">
+ import type { ProductReview, Shelf } from '@jet-app/app-store/api/models';
+
+ interface ProductReviewShelf extends Shelf {
+ items: ProductReview[];
+ }
+
+ export function isProductReviewShelf(
+ shelf: Shelf,
+ ): shelf is ProductReviewShelf {
+ let { contentType, items } = shelf;
+
+ return contentType === 'productReview' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import EditorsChoiceReviewItem, {
+ isEditorsChoiceReviewItem,
+ } from '~/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte';
+ import UserReviewItem, {
+ isUserReviewItem,
+ } from '~/components/jet/item/ProductReview/UserReviewItem.svelte';
+
+ export let shelf: ProductReviewShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ {#if isUserReviewItem(item)}
+ <UserReviewItem {item} />
+ {:else if isEditorsChoiceReviewItem(item)}
+ <EditorsChoiceReviewItem {item} />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/QuoteShelf.svelte b/src/components/jet/shelf/QuoteShelf.svelte
new file mode 100644
index 0000000..3a14f4f
--- /dev/null
+++ b/src/components/jet/shelf/QuoteShelf.svelte
@@ -0,0 +1,80 @@
+<script lang="ts" context="module">
+ import type { Quote, Shelf } from '@jet-app/app-store/api/models';
+
+ interface QuoteShelf extends Shelf {
+ contentType: 'quote';
+ items: [Quote];
+ }
+
+ export function isQuoteShelf(shelf: Shelf): shelf is QuoteShelf {
+ return shelf.contentType === 'quote' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: QuoteShelf;
+
+ $: item = shelf.items[0];
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false}>
+ <div class="outer">
+ <div class="inner">
+ <blockquote>
+ {item.text}
+ </blockquote>
+ <span>{item.credit}</span>
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/locale' as *;
+
+ .outer {
+ display: flex;
+ margin-bottom: 24px;
+ padding: 0 var(--bodyGutter);
+ gap: 6px;
+ }
+
+ .outer::before {
+ content: '❝';
+ font-size: 40px;
+ line-height: 2.2rem;
+ color: var(--systemSecondary);
+
+ @include rtl {
+ content: '❞';
+ }
+ }
+
+ .inner {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ blockquote {
+ font: var(--large-title-emphasized);
+ text-wrap: pretty;
+ }
+
+ blockquote::after {
+ content: '❞';
+ color: var(--systemSecondary);
+
+ @include rtl {
+ content: '❝';
+ }
+ }
+
+ span {
+ font: var(--title-3);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/jet/shelf/ReviewsContainerShelf.svelte b/src/components/jet/shelf/ReviewsContainerShelf.svelte
new file mode 100644
index 0000000..a55fe40
--- /dev/null
+++ b/src/components/jet/shelf/ReviewsContainerShelf.svelte
@@ -0,0 +1,84 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ ReviewsContainer,
+ } from '@jet-app/app-store/api/models';
+
+ export interface ReviewsContainerShelf extends Shelf {
+ items: [ReviewsContainer];
+ }
+
+ export function isReviewsContainerShelf(
+ shelf: Shelf,
+ ): shelf is ReviewsContainerShelf {
+ return (
+ shelf.contentType === 'reviewsContainer' &&
+ Array.isArray(shelf.items)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte';
+
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import Grid from '~/components/Grid.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getJet } from '~/jet/svelte';
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ export let shelf: ReviewsContainerShelf;
+
+ $: reviewsContainer = shelf.items[0];
+ $: ({ productAction, ratings } = reviewsContainer);
+
+ $: numberOfRatings = jet.localization.formattedCount(
+ ratings.totalNumberOfRatings,
+ );
+</script>
+
+<ShelfWrapper {shelf}>
+ <header slot="title">
+ {#if productAction}
+ <div class="product-action">
+ <LinkWrapper action={productAction}>
+ {productAction.title}
+ </LinkWrapper>
+ </div>
+ {/if}
+
+ <ShelfTitle title={shelf.title ?? ''} />
+
+ <Grid gridType="A" items={[1]}>
+ <div class="rating">
+ <RatingComponent
+ averageRating={ratings.ratingAverage}
+ ratingCount={ratings.totalNumberOfRatings}
+ ratingCountText={$i18n.t(
+ 'ASE.Web.AppStore.Ratings.CountText',
+ {
+ numberOfRatings,
+ },
+ )}
+ ratingCountsList={ratings.ratingCounts}
+ totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')}
+ />
+ </div>
+ </Grid>
+ </header>
+</ShelfWrapper>
+
+<style>
+ .product-action {
+ --linkColor: var(--keyColor);
+ margin: 0 var(--bodyGutter) 6px;
+ }
+
+ .rating {
+ --ratingBarColor: var(--systemPrimary);
+ }
+</style>
diff --git a/src/components/jet/shelf/ReviewsShelf.svelte b/src/components/jet/shelf/ReviewsShelf.svelte
new file mode 100644
index 0000000..8304444
--- /dev/null
+++ b/src/components/jet/shelf/ReviewsShelf.svelte
@@ -0,0 +1,28 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ Review as ReviewModel,
+ } from '@jet-app/app-store/api/models';
+
+ export interface ReviewsShelf extends Shelf {
+ items: ReviewModel[];
+ }
+
+ export function isReviewsShelf(shelf: Shelf): shelf is ReviewsShelf {
+ return shelf.contentType === 'reviews' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import ReviewItem from '~/components/jet/item/ReviewItem.svelte';
+
+ export let shelf: ReviewsShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="A" let:item>
+ <ReviewItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/RibbonBarShelf.svelte b/src/components/jet/shelf/RibbonBarShelf.svelte
new file mode 100644
index 0000000..44a8ae9
--- /dev/null
+++ b/src/components/jet/shelf/RibbonBarShelf.svelte
@@ -0,0 +1,135 @@
+<script lang="ts" context="module">
+ import type { Shelf, RibbonBarItem } from '@jet-app/app-store/api/models';
+
+ interface RibbonBarShelf extends Shelf {
+ items: RibbonBarItem[];
+ }
+
+ export function isRibbonBarShelf(shelf: Shelf): shelf is RibbonBarShelf {
+ return shelf.contentType === 'ribbonBar' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+
+ export let shelf: RibbonBarShelf;
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={false} withPaddingTop={false}>
+ <div class="scroll">
+ <ul>
+ {#each shelf.items as ribbonBarItem}
+ {@const action = ribbonBarItem.clickAction}
+ {@const artwork = ribbonBarItem.artwork}
+ {@const title = ribbonBarItem.title}
+ <li>
+ <LinkWrapper {action}>
+ {#if artwork}
+ <div
+ class="artwork-container"
+ style:--aspect-ratio={artwork.width /
+ artwork.height}
+ >
+ {#if isSystemImageArtwork(artwork)}
+ <SystemImage {artwork} />
+ {:else}
+ <Artwork
+ {artwork}
+ profile={getNaturalProfile(artwork, [
+ 17,
+ ])}
+ hasTransparentBackground
+ />
+ {/if}
+ </div>
+ {/if}
+ {title}
+ </LinkWrapper>
+ </li>
+ {/each}
+ </ul>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use 'ac-sasskit/core/helpers' as *;
+ @use 'ac-sasskit/core/locale' as *;
+
+ .scroll {
+ --gradient-direction: 90deg;
+ overflow-x: auto;
+ scrollbar-width: none;
+ padding-inline-start: var(--bodyGutter);
+ margin-inline-end: var(--bodyGutter);
+ // A small gradient that fades out the ribbon, to indicate that there is more
+ mask-image: linear-gradient(
+ var(--gradient-direction),
+ black calc(100% - 8px),
+ transparent 100%
+ );
+
+ @include rtl {
+ --gradient-direction: -90deg;
+ }
+ }
+
+ ul {
+ font: var(--body-emphasized);
+ display: flex;
+ gap: 4px;
+ padding-bottom: 16px;
+ padding-top: 13px;
+ }
+
+ li {
+ display: flex;
+ margin-inline-end: 8px;
+ flex-shrink: 0;
+ }
+
+ li:last-of-type {
+ padding-inline-end: 8px;
+ }
+
+ li :global(a) {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-shrink: 0;
+ background: var(--pageBG);
+ border-radius: var(--global-border-radius-small);
+ padding: 6px 10px;
+
+ &::after {
+ content: '';
+ display: block;
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ border-radius: var(--global-border-radius-small);
+ box-shadow: var(--shadow-small);
+ z-index: calc(var(--z-default) - 1);
+ }
+
+ @media (prefers-color-scheme: dark) {
+ background: var(--systemGray5-default_IC);
+ }
+ }
+
+ .artwork-container {
+ --artwork-override-height: 17px;
+ flex-shrink: 0;
+ aspect-ratio: var(--aspect-ratio);
+ height: 17px;
+ }
+</style>
diff --git a/src/components/jet/shelf/SearchLinkShelf.svelte b/src/components/jet/shelf/SearchLinkShelf.svelte
new file mode 100644
index 0000000..6b29780
--- /dev/null
+++ b/src/components/jet/shelf/SearchLinkShelf.svelte
@@ -0,0 +1,26 @@
+<script lang="ts" context="module">
+ import type { Shelf, SearchLink } from '@jet-app/app-store/api/models';
+
+ interface SearchLinkShelf extends Shelf {
+ items: SearchLink[];
+ }
+
+ export function isSearchLinkShelf(shelf: Shelf): shelf is SearchLinkShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'searchLink' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import SearchLinkItem from '~/components/jet/item/SearchLinkItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SearchLinkShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="SearchLink" let:item>
+ <SearchLinkItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SearchResultShelf.svelte b/src/components/jet/shelf/SearchResultShelf.svelte
new file mode 100644
index 0000000..9c15d3e
--- /dev/null
+++ b/src/components/jet/shelf/SearchResultShelf.svelte
@@ -0,0 +1,49 @@
+<script lang="ts" context="module">
+ import type {
+ AppSearchResult,
+ Shelf,
+ SearchResult,
+ AppEventSearchResult,
+ } from '@jet-app/app-store/api/models';
+
+ import AppSearchResultItem, {
+ isAppSearchResult,
+ isAppEventSearchResult,
+ } from '~/components/jet/item/SearchResult/AppSearchResultItem.svelte';
+
+ /**
+ * All sub-classes of {@linkcode SearchResult} that this component can handle rendering
+ */
+ type RenderableSearchResult = AppSearchResult | AppEventSearchResult;
+
+ interface SearchResultShelf extends Shelf {
+ items: SearchResult[];
+ }
+
+ export function isSearchResultShelf(
+ shelf: Shelf,
+ ): shelf is SearchResultShelf {
+ return (
+ shelf.contentType === 'searchResult' && Array.isArray(shelf.items)
+ );
+ }
+
+ export function isRenderableInSearchResultsShelf(
+ item: SearchResult,
+ ): item is RenderableSearchResult {
+ return isAppSearchResult(item) || isAppEventSearchResult(item);
+ }
+</script>
+
+<script lang="ts">
+ import Grid from '~/components/Grid.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SearchResultShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <Grid gridType="SearchResult" items={shelf.items} let:item>
+ <AppSearchResultItem {item} />
+ </Grid>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/Shelf.svelte b/src/components/jet/shelf/Shelf.svelte
new file mode 100644
index 0000000..6cbb0f6
--- /dev/null
+++ b/src/components/jet/shelf/Shelf.svelte
@@ -0,0 +1,320 @@
+<script lang="ts">
+ import type { Shelf } from '@jet-app/app-store/api/models';
+
+ // Components for specific types of `Shelf`
+ import AccessibilityHeaderShelf, {
+ isAccessibilityHeaderShelf,
+ } from '~/components/jet/shelf/AccessibilityHeaderShelf.svelte';
+ import AccessibilityFeaturesShelf, {
+ isAccessibilityFeaturesShelf,
+ } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte';
+ import AccessibilityDeveloperLinkShelf, {
+ isAccessibilityDeveloperLinkShelf,
+ } from './AccessibilityDeveloperLinkShelf.svelte';
+ import ActionShelf, {
+ isActionShelf,
+ } from '~/components/jet/shelf/ActionShelf.svelte';
+ import AnnotationShelf, {
+ isAnnotationShelf,
+ } from '~/components/jet/shelf/AnnotationShelf.svelte';
+ import AppEventDetailShelf, {
+ isAppEventDetailShelf,
+ } from '~/components/jet/shelf/AppEventDetailShelf.svelte';
+ import AppPromotionShelf, {
+ isAppPromotionShelf,
+ } from '~/components/jet/shelf/AppPromotionShelf.svelte';
+ import AppShowcaseShelf, {
+ isAppShowcaseShelf,
+ } from '~/components/jet/shelf/AppShowcaseShelf.svelte';
+ import AppTrailerLockupShelf, {
+ isAppTrailerLockupShelf,
+ } from '~/components/jet/shelf/AppTrailerLockupShelf.svelte';
+ import ArcadeFooterShelf, {
+ isArcadeFooterShelf,
+ } from '~/components/jet/shelf/ArcadeFooterShelf.svelte';
+ import { isBannerShelf } from '~/components/jet/shelf/BannerShelf.svelte';
+ import BrickShelf, {
+ isBrickShelf,
+ } from '~/components/jet/shelf/BrickShelf.svelte';
+ import CategoryBrickShelf, {
+ isCategoryBrickShelf,
+ } from '~/components/jet/shelf/CategoryBrickShelf.svelte';
+ import EditorialCardShelf, {
+ isEditorialCardShelf,
+ } from '~/components/jet/shelf/EditorialCardShelf.svelte';
+ import EditorialLinkShelf, {
+ isEditorialLinkShelf,
+ } from '~/components/jet/shelf/EditorialLinkShelf.svelte';
+ import FramedArtworkShelf, {
+ isFramedArtworkShelf,
+ } from '~/components/jet/shelf/FramedArtworkShelf.svelte';
+ import FramedVideoShelf, {
+ isFramedVideoShelf,
+ } from '~/components/jet/shelf/FramedVideoShelf.svelte';
+ import HeroCarouselShelf, {
+ isHeroCarouselShelf,
+ } from '~/components/jet/shelf/HeroCarouselShelf.svelte';
+ import HorizontalRuleShelf, {
+ isHorizontalRuleShelf,
+ } from '~/components/jet/shelf/HorizontalRuleShelf.svelte';
+ import InAppPurchaseLockupShelf, {
+ isInAppPurchaseLockupShelf,
+ } from '~/components/jet/shelf/InAppPurchaseLockupShelf.svelte';
+ import LargeHeroBreakoutShelf, {
+ isLargeHeroBreakoutShelf,
+ } from '~/components/jet/shelf/LargeHeroBreakoutShelf.svelte';
+ import LargeBrickShelf, {
+ isLargeBrickShelf,
+ } from '~/components/jet/shelf/LargeBrickShelf.svelte';
+ import LargeImageLockupShelf, {
+ isLargeImageLockupShelf,
+ } from '~/components/jet/shelf/LargeImageLockupShelf.svelte';
+ import LargeLockupShelf, {
+ isLargeLockupShelf,
+ } from '~/components/jet/shelf/LargeLockupShelf.svelte';
+ import LargeStoryCardShelf, {
+ isLargeStoryCardShelf,
+ } from '~/components/jet/shelf/LargeStoryCardShelf.svelte';
+ import LinkableTextShelf, {
+ isLinkableTextShelf,
+ } from '~/components/jet/shelf/LinkableTextShelf.svelte';
+ import {
+ isMarkerShelf,
+ type MarkerShelf as MarkerShelfModel,
+ } from '~/components/jet/shelf/MarkerShelf.svelte';
+ import MediumImageLockupShelf, {
+ isMediumImageLockupShelf,
+ } from '~/components/jet/shelf/MediumImageLockupShelf.svelte';
+ import MediumLockupShelf, {
+ isMediumLockupShelf,
+ } from '~/components/jet/shelf/MediumLockupShelf.svelte';
+ import MediumStoryCardShelf, {
+ isMediumStoryCardShelf,
+ } from '~/components/jet/shelf/MediumStoryCardShelf.svelte';
+ import ProductBadgeShelf, {
+ isProductBadgeShelf,
+ } from '~/components/jet/shelf/ProductBadgeShelf.svelte';
+ import PageHeaderShelf, {
+ isPageHeaderShelf,
+ } from '~/components/jet/shelf/PageHeaderShelf.svelte';
+ import ParagraphShelf, {
+ isParagraphShelf,
+ } from '~/components/jet/shelf/ParagraphShelf.svelte';
+ import PosterLockupShelf, {
+ isPosterLockupShelf,
+ } from '~/components/jet/shelf/PosterLockupShelf.svelte';
+ import ProductMediaShelf, {
+ isProductMediaShelf,
+ } from '~/components/jet/shelf/ProductMediaShelf.svelte';
+ import ProductDescriptionShelf, {
+ isProductDescriptionShelf,
+ } from '~/components/jet/shelf/ProductDescriptionShelf.svelte';
+ import ProductRatingsShelf, {
+ isProductRatingsShelf,
+ } from '~/components/jet/shelf/ProductRatingsShelf.svelte';
+ import ProductReviewShelf, {
+ isProductReviewShelf,
+ } from '~/components/jet/shelf/ProductReviewShelf.svelte';
+ import RibbonBarShelf, {
+ isRibbonBarShelf,
+ } from '~/components/jet/shelf/RibbonBarShelf.svelte';
+ import SearchLinkShelf, {
+ isSearchLinkShelf,
+ } from '~/components/jet/shelf/SearchLinkShelf.svelte';
+ import SearchResultShelf, {
+ isSearchResultShelf,
+ } from '~/components/jet/shelf/SearchResultShelf.svelte';
+ import SmallBreakoutShelf, {
+ isSmallBreakoutShelf,
+ } from '~/components/jet/shelf/SmallBreakoutShelf.svelte';
+ import SmallBrickShelf, {
+ isSmallBrickShelf,
+ } from '~/components/jet/shelf/SmallBrickShelf.svelte';
+ import SmallLockupShelf, {
+ isSmallLockupShelf,
+ } from '~/components/jet/shelf/SmallLockupShelf.svelte';
+ import SmallStoryCardShelf, {
+ isSmallStoryCardShelf,
+ } from '~/components/jet/shelf/SmallStoryCardShelf.svelte';
+ import PrivacyHeaderShelf, {
+ isPrivacyHeaderShelf,
+ } from '~/components/jet/shelf/PrivacyHeaderShelf.svelte';
+ import PrivacyFooterShelf, {
+ isPrivacyFooterShelf,
+ } from '~/components/jet/shelf/PrivacyFooterShelf.svelte';
+ import PrivacyTypeShelf, {
+ isPrivacyTypeShelf,
+ } from '~/components/jet/shelf/PrivacyTypeShelf.svelte';
+ import ProductCapabilityShelf, {
+ isProductCapabilityShelf,
+ } from '~/components/jet/shelf/ProductCapabilityShelf.svelte';
+ import ProductPageLinkShelf, {
+ isProductPageLinkShelf,
+ } from './ProductPageLinkShelf.svelte';
+ import QuoteShelf, {
+ isQuoteShelf,
+ } from '~/components/jet/shelf/QuoteShelf.svelte';
+ import ReviewsContainerShelf, {
+ isReviewsContainerShelf,
+ } from '~/components/jet/shelf/ReviewsContainerShelf.svelte';
+ import ReviewsShelf, {
+ isReviewsShelf,
+ } from '~/components/jet/shelf/ReviewsShelf.svelte';
+ import TitledParagraphShelf, {
+ isTitledParagraphShelf,
+ } from '~/components/jet/shelf/TitledParagraphShelf.svelte';
+ import TodayCardShelf, {
+ isTodayCardShelf,
+ } from '~/components/jet/shelf/TodayCardShelf.svelte';
+ import UberShelf, {
+ isUberShelf,
+ } from '~/components/jet/shelf//UberShelf.svelte';
+ import FallbackShelf, {
+ isFallbackShelf,
+ } from '~/components/jet/shelf/FallbackShelf.svelte';
+
+ interface $$Slots {
+ /**
+ * If the `shelf` is recognized to be a {@linkcode MarkerShelfModel}, this
+ * slot is rendered with the `shelf` as data rather than rendering the
+ * shelf directly.
+ *
+ * This is done because "marker" shelves need the whole "page" definition to
+ * be rendered, which is not available at this level of the UI. Rather than
+ * having to pass that data down to this level, we yield rendering back to
+ * the "parent" component that can provide that data directly.
+ */
+ 'marker-shelf': {
+ shelf: MarkerShelfModel;
+ };
+ }
+
+ export let shelf: Shelf;
+</script>
+
+<!--
+@component
+Render a generic `Shelf`
+
+This component is responsible for rendering any kind of `Shelf` that
+the App Store is capable of rendering. It primarily does this by trying
+to narrow the generic `Shelf` down to a more-specific type and then
+rendering a component specifically made for it
+-->
+
+{#if isAccessibilityHeaderShelf(shelf)}
+ <AccessibilityHeaderShelf {shelf} />
+{:else if isAccessibilityFeaturesShelf(shelf)}
+ <AccessibilityFeaturesShelf {shelf} />
+{:else if isAccessibilityDeveloperLinkShelf(shelf)}
+ <AccessibilityDeveloperLinkShelf {shelf} />
+{:else if isActionShelf(shelf)}
+ <ActionShelf {shelf} />
+{:else if isAnnotationShelf(shelf)}
+ <AnnotationShelf {shelf} />
+{:else if isAppEventDetailShelf(shelf)}
+ <AppEventDetailShelf {shelf} />
+{:else if isAppPromotionShelf(shelf)}
+ <AppPromotionShelf {shelf} />
+{:else if isAppShowcaseShelf(shelf)}
+ <AppShowcaseShelf {shelf} />
+{:else if isAppTrailerLockupShelf(shelf)}
+ <AppTrailerLockupShelf {shelf} />
+{:else if isArcadeFooterShelf(shelf)}
+ <ArcadeFooterShelf {shelf} />
+{:else if isBannerShelf(shelf)}
+ <!-- a no-op until we determine if we actually want to support these banners -->
+ <!-- <BannerShelf {shelf} /> -->
+{:else if isBrickShelf(shelf)}
+ <BrickShelf {shelf} />
+{:else if isCategoryBrickShelf(shelf)}
+ <CategoryBrickShelf {shelf} />
+{:else if isEditorialCardShelf(shelf)}
+ <EditorialCardShelf {shelf} />
+{:else if isEditorialLinkShelf(shelf)}
+ <EditorialLinkShelf {shelf} />
+{:else if isFramedArtworkShelf(shelf)}
+ <FramedArtworkShelf {shelf} />
+{:else if isFramedVideoShelf(shelf)}
+ <FramedVideoShelf {shelf} />
+{:else if isHeroCarouselShelf(shelf)}
+ <HeroCarouselShelf {shelf} />
+{:else if isHorizontalRuleShelf(shelf)}
+ <HorizontalRuleShelf {shelf} />
+{:else if isInAppPurchaseLockupShelf(shelf)}
+ <InAppPurchaseLockupShelf {shelf} />
+{:else if isLargeHeroBreakoutShelf(shelf)}
+ <LargeHeroBreakoutShelf {shelf} />
+{:else if isLargeBrickShelf(shelf)}
+ <LargeBrickShelf {shelf} />
+{:else if isLargeImageLockupShelf(shelf)}
+ <LargeImageLockupShelf {shelf} />
+{:else if isLargeLockupShelf(shelf)}
+ <LargeLockupShelf {shelf} />
+{:else if isLargeStoryCardShelf(shelf)}
+ <LargeStoryCardShelf {shelf} />
+{:else if isLinkableTextShelf(shelf)}
+ <LinkableTextShelf {shelf} />
+{:else if isProductDescriptionShelf(shelf)}
+ <ProductDescriptionShelf {shelf} />
+{:else if isMediumImageLockupShelf(shelf)}
+ <MediumImageLockupShelf {shelf} />
+{:else if isMediumLockupShelf(shelf)}
+ <MediumLockupShelf {shelf} />
+{:else if isMediumStoryCardShelf(shelf)}
+ <MediumStoryCardShelf {shelf} />
+{:else if isPosterLockupShelf(shelf)}
+ <PosterLockupShelf {shelf} />
+{:else if isProductBadgeShelf(shelf)}
+ <ProductBadgeShelf {shelf} />
+{:else if isPageHeaderShelf(shelf)}
+ <PageHeaderShelf {shelf} />
+{:else if isParagraphShelf(shelf)}
+ <ParagraphShelf {shelf} />
+{:else if isPrivacyHeaderShelf(shelf)}
+ <PrivacyHeaderShelf {shelf} />
+{:else if isPrivacyFooterShelf(shelf)}
+ <PrivacyFooterShelf {shelf} />
+{:else if isPrivacyTypeShelf(shelf)}
+ <PrivacyTypeShelf {shelf} />
+{:else if isProductMediaShelf(shelf)}
+ <ProductMediaShelf {shelf} />
+{:else if isProductRatingsShelf(shelf)}
+ <ProductRatingsShelf {shelf} />
+{:else if isProductReviewShelf(shelf)}
+ <ProductReviewShelf {shelf} />
+{:else if isRibbonBarShelf(shelf)}
+ <RibbonBarShelf {shelf} />
+{:else if isSearchLinkShelf(shelf)}
+ <SearchLinkShelf {shelf} />
+{:else if isSearchResultShelf(shelf)}
+ <SearchResultShelf {shelf} />
+{:else if isSmallBreakoutShelf(shelf)}
+ <SmallBreakoutShelf {shelf} />
+{:else if isSmallBrickShelf(shelf)}
+ <SmallBrickShelf {shelf} />
+{:else if isSmallStoryCardShelf(shelf)}
+ <SmallStoryCardShelf {shelf} />
+{:else if isSmallLockupShelf(shelf)}
+ <SmallLockupShelf {shelf} />
+{:else if isProductCapabilityShelf(shelf)}
+ <ProductCapabilityShelf {shelf} />
+{:else if isProductPageLinkShelf(shelf)}
+ <ProductPageLinkShelf {shelf} />
+{:else if isQuoteShelf(shelf)}
+ <QuoteShelf {shelf} />
+{:else if isReviewsContainerShelf(shelf)}
+ <ReviewsContainerShelf {shelf} />
+{:else if isReviewsShelf(shelf)}
+ <ReviewsShelf {shelf} />
+{:else if isTodayCardShelf(shelf)}
+ <TodayCardShelf {shelf} />
+{:else if isTitledParagraphShelf(shelf)}
+ <TitledParagraphShelf {shelf} />
+{:else if isUberShelf(shelf)}
+ <UberShelf {shelf} />
+{:else if isMarkerShelf(shelf)}
+ <slot name="marker-shelf" {shelf} />
+{:else if isFallbackShelf(shelf)}
+ <FallbackShelf {shelf} />
+{/if}
diff --git a/src/components/jet/shelf/SmallBreakoutShelf.svelte b/src/components/jet/shelf/SmallBreakoutShelf.svelte
new file mode 100644
index 0000000..095cf7f
--- /dev/null
+++ b/src/components/jet/shelf/SmallBreakoutShelf.svelte
@@ -0,0 +1,32 @@
+<script lang="ts" context="module">
+ import type {
+ LargeHeroBreakout,
+ Shelf,
+ SmallBreakout,
+ } from '@jet-app/app-store/api/models';
+
+ interface SmallBreakoutShelf extends Shelf {
+ items: SmallBreakout[];
+ }
+
+ export function isSmallBreakoutShelf(
+ shelf: Shelf,
+ ): shelf is SmallBreakoutShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallBreakout' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import SmallBreakoutItem from '~/components/jet/item/SmallBreakoutItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SmallBreakoutShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="Spotlight" let:item>
+ <SmallBreakoutItem {item} />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SmallBrickShelf.svelte b/src/components/jet/shelf/SmallBrickShelf.svelte
new file mode 100644
index 0000000..34426cf
--- /dev/null
+++ b/src/components/jet/shelf/SmallBrickShelf.svelte
@@ -0,0 +1,26 @@
+<script lang="ts" context="module">
+ import type { Brick, Shelf } from '@jet-app/app-store/api/models';
+
+ interface SmallBrickShelf extends Shelf {
+ items: Brick[];
+ }
+
+ export function isSmallBrickShelf(shelf: Shelf): shelf is SmallBrickShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallBrick' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import BrickItem from '~/components/jet/item/BrickItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SmallBrickShelf;
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout {shelf} gridType="C" let:item>
+ <BrickItem {item} shouldOverlayDescription />
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SmallLockupShelf.svelte b/src/components/jet/shelf/SmallLockupShelf.svelte
new file mode 100644
index 0000000..e286671
--- /dev/null
+++ b/src/components/jet/shelf/SmallLockupShelf.svelte
@@ -0,0 +1,54 @@
+<script lang="ts" context="module">
+ import type { Lockup, Shelf } from '@jet-app/app-store/api/models';
+
+ interface SmallLockupShelf extends Shelf {
+ items: Lockup[];
+ }
+
+ export function isSmallLockupShelf(
+ shelf: Shelf,
+ ): shelf is SmallLockupShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallLockup' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+ import SmallLockupWithOrdinalItem, {
+ isSmallLockupWithOrdinalItem,
+ } from '~/components/jet/item/SmallLockupWithOrdinalItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: SmallLockupShelf;
+
+ $: ({ isArticleContext = false } = shelf.presentationHints ?? {});
+ $: itemHasOrdinal = shelf.items.some((item) => item.ordinal);
+ $: gridType = (() => {
+ if (itemHasOrdinal) {
+ return 'SmallLockupWithOrdinal';
+ }
+
+ if (isArticleContext) {
+ return 'Spotlight';
+ }
+
+ return 'SmallLockup';
+ })();
+</script>
+
+<ShelfWrapper {shelf}>
+ <ShelfItemLayout
+ {shelf}
+ {gridType}
+ rowsPerColumnOverride={gridType === 'SmallLockup' ? 3 : null}
+ let:item
+ >
+ {#if isSmallLockupWithOrdinalItem(item)}
+ <SmallLockupWithOrdinalItem {item} />
+ {:else}
+ <SmallLockupItem {item} --margin-inline-end="16px" />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/SmallStoryCardShelf.svelte b/src/components/jet/shelf/SmallStoryCardShelf.svelte
new file mode 100644
index 0000000..c1a85ad
--- /dev/null
+++ b/src/components/jet/shelf/SmallStoryCardShelf.svelte
@@ -0,0 +1,66 @@
+<script lang="ts" context="module">
+ import type { Shelf, TodayCard } from '@jet-app/app-store/api/models';
+
+ interface SmallStoryCardShelf extends Shelf {
+ contentType: 'smallStoryCard';
+ items: TodayCard[];
+ }
+
+ export function isSmallStoryCardShelf(
+ shelf: Shelf,
+ ): shelf is SmallStoryCardShelf {
+ const { contentType, items } = shelf;
+ return contentType === 'smallStoryCard' && Array.isArray(items);
+ }
+</script>
+
+<script lang="ts">
+ import SmallStoryCardWithMediaItem, {
+ isSmallStoryCardWithMediaItem,
+ } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte';
+ import SmallStoryCardWithArtworkItem, {
+ isSmallStoryCardWithArtworkItem,
+ } from '~/components/jet/item/SmallStoryCardWithArtworkItem.svelte';
+ import SmallStoryCardWithMediaRiver, {
+ isSmallStoryCardWithMediaRiver,
+ } from '~/components/jet/item/SmallStoryCardWithMediaRiver.svelte';
+ import SmallStoryCardWithMediaAppIcon, {
+ isSmallStoryCardWithMediaAppIcon,
+ } from '~/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte';
+ import SmallStoryCardMediaBrandedSingleApp, {
+ isSmallStoryCardMediaBrandedSingleApp,
+ } from '~/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
+
+ export let shelf: SmallStoryCardShelf;
+
+ $: ({ isArticleContext = false } = shelf.presentationHints ?? {});
+ $: gridType = (() => {
+ if (isArticleContext) {
+ return 'SmallStoryCard';
+ }
+
+ if (shelf.items.some(isSmallStoryCardWithArtworkItem)) {
+ return 'D';
+ }
+
+ return 'B';
+ })();
+</script>
+
+<ShelfWrapper {shelf} withBottomPadding={!isArticleContext}>
+ <ShelfItemLayout {shelf} {gridType} let:item>
+ {#if isSmallStoryCardWithMediaRiver(item)}
+ <SmallStoryCardWithMediaRiver {item} />
+ {:else if isSmallStoryCardWithMediaAppIcon(item)}
+ <SmallStoryCardWithMediaAppIcon {item} />
+ {:else if isSmallStoryCardMediaBrandedSingleApp(item)}
+ <SmallStoryCardMediaBrandedSingleApp {item} />
+ {:else if isSmallStoryCardWithMediaItem(item)}
+ <SmallStoryCardWithMediaItem {item} />
+ {:else if isSmallStoryCardWithArtworkItem(item)}
+ <SmallStoryCardWithArtworkItem {item} />
+ {/if}
+ </ShelfItemLayout>
+</ShelfWrapper>
diff --git a/src/components/jet/shelf/TitledParagraphShelf.svelte b/src/components/jet/shelf/TitledParagraphShelf.svelte
new file mode 100644
index 0000000..41c1d74
--- /dev/null
+++ b/src/components/jet/shelf/TitledParagraphShelf.svelte
@@ -0,0 +1,118 @@
+<script lang="ts" context="module">
+ import {
+ type Action,
+ type FlowAction,
+ type GenericPage,
+ type Shelf,
+ type TitledParagraph,
+ isFlowAction,
+ } from '@jet-app/app-store/api/models';
+
+ interface TitledParagraphShelf extends Shelf {
+ items: [TitledParagraph];
+ }
+
+ interface VersionHistoryPage extends FlowAction {
+ page: 'versionHistory';
+ pageData: GenericPage;
+ }
+
+ export function isTitledParagraphShelf(
+ shelf: Shelf,
+ ): shelf is TitledParagraphShelf {
+ const { contentType, items } = shelf;
+
+ return contentType === 'titledParagraph' && Array.isArray(items);
+ }
+
+ function isVersionHistoryFlowAction(
+ action: Action,
+ ): action is VersionHistoryPage {
+ return isFlowAction(action) && action.page === 'versionHistory';
+ }
+</script>
+
+<script lang="ts">
+ import { createEventDispatcher, type SvelteComponent } from 'svelte';
+ import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
+ import ContentModal from '~/components/jet/item/ContentModal.svelte';
+ import TitledParagraphItem, {
+ isTitledParagraphItem,
+ } from '~/components/jet/item/TitledParagraphItem.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getJetPerform } from '~/jet';
+ import { VERSION_HISTORY_MODAL_ID } from '~/utils/metrics';
+
+ const perform = getJetPerform();
+ export let shelf: TitledParagraphShelf;
+
+ let modalComponent: SvelteComponent;
+ let modalTriggerElement: HTMLElement | null = null;
+
+ const { seeAllAction } = shelf;
+ const i18n = getI18n();
+ const translateFn = (key: string) => $i18n.t(key);
+ const handleModalClose = () => modalComponent.close();
+ const handleOpenModalClick = (e: Event) => {
+ modalTriggerElement = e.target as HTMLElement;
+ modalComponent?.showModal();
+ perform(destination);
+ };
+
+ const destination =
+ seeAllAction && isVersionHistoryFlowAction(seeAllAction)
+ ? seeAllAction
+ : undefined;
+
+ const pageData = destination?.pageData;
+</script>
+
+<ShelfWrapper {shelf}>
+ <div slot="title" class="title-container">
+ {#if shelf.title}
+ <button on:click={handleOpenModalClick}>
+ <ShelfTitle title={shelf.title} seeAllAction={destination} />
+ </button>
+ {/if}
+
+ {#if pageData}
+ <Modal {modalTriggerElement} bind:this={modalComponent}>
+ <ContentModal
+ on:close={handleModalClose}
+ title={pageData.title || null}
+ subtitle={null}
+ targetId={VERSION_HISTORY_MODAL_ID}
+ >
+ <svelte:fragment slot="content">
+ <ul>
+ {#each pageData.shelves as shelf}
+ {#each shelf.items || [] as item}
+ {#if isTitledParagraphItem(item)}
+ <li>
+ <TitledParagraphItem {item} />
+ </li>
+ {/if}
+ {/each}
+ {/each}
+ </ul>
+ </svelte:fragment>
+ </ContentModal>
+ </Modal>
+ {/if}
+ </div>
+
+ {#each shelf.items as item}
+ <TitledParagraphItem {item} />
+ {/each}
+</ShelfWrapper>
+
+<style>
+ .title-container {
+ display: flex;
+ justify-content: space-between;
+ padding-top: 16px;
+ padding-inline-end: var(--bodyGutter);
+ }
+</style>
diff --git a/src/components/jet/shelf/TodayCardShelf.svelte b/src/components/jet/shelf/TodayCardShelf.svelte
new file mode 100644
index 0000000..e872112
--- /dev/null
+++ b/src/components/jet/shelf/TodayCardShelf.svelte
@@ -0,0 +1,187 @@
+<script lang="ts" context="module">
+ import type {
+ Shelf,
+ TodayCard as TodayCardModel,
+ } from '@jet-app/app-store/api/models';
+
+ export interface TodayCardShelf extends Shelf {
+ contentType: 'todayCard';
+
+ items: TodayCardModel[];
+ }
+
+ export function isTodayCardShelf(shelf: Shelf): shelf is TodayCardShelf {
+ return shelf.contentType === 'todayCard' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import TodayCard from '~/components/jet/today-card/TodayCard.svelte';
+
+ import { getTodayCardLayoutConfiguration } from '~/context/today-card-layout';
+
+ export let shelf: TodayCardShelf;
+
+ $: ({
+ wrap: { shouldStretchFirstCard: shouldStretchFirstCardWrap },
+ nowrap: { shouldStretchFirstCard: shouldStretchFirstCardNoWrap },
+ } = getTodayCardLayoutConfiguration(shelf));
+</script>
+
+<ShelfWrapper {shelf}>
+ <div>
+ <div
+ class="today-card-row"
+ class:today-card-row__stretch-first-wrap={shouldStretchFirstCardWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__stretch-first-nowrap={shouldStretchFirstCardNoWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__stretch-last-wrap={!shouldStretchFirstCardWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__stretch-last-nowrap={!shouldStretchFirstCardNoWrap &&
+ shelf.items.length >= 2}
+ class:today-card-row__1-card={shelf.items.length == 1}
+ class:today-card-row__2-card={shelf.items.length == 2}
+ class:today-card-row__3-card={shelf.items.length == 3}
+ class:today-card-row__4-card={shelf.items.length >= 4}
+ >
+ {#each shelf.items.slice(0, 4) as card}
+ <div class="today-card-wrapper">
+ <TodayCard {card} />
+ </div>
+ {/each}
+ </div>
+ </div>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+ @use 'amp/stylekit/core/mixins/browser-targets' as *;
+
+ @mixin stretch-card($flex-shrink: 1) {
+ aspect-ratio: unset;
+ justify-self: stretch;
+ align-self: stretch;
+ width: auto;
+ flex-shrink: $flex-shrink;
+ flex-grow: 1;
+ }
+
+ .today-card-row {
+ --card-default-width: 407px;
+ --card-default-height: 534px;
+ --card-row-gap: 16px;
+ min-width: min(var(--card-default-width), 100vw);
+ padding: 0 25px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--card-row-gap);
+
+ @media (--range-medium-up) {
+ padding: 0 40px;
+ flex-direction: row;
+ flex-wrap: wrap;
+ }
+ }
+
+ .today-card-wrapper {
+ --artworkShadowInset: 0;
+ --afterShadowBorderRadius: 0px;
+ aspect-ratio: 3 / 4;
+ width: 100%;
+ flex-shrink: 0;
+ max-height: 600px;
+ min-height: 100px;
+
+ > :global(a) {
+ display: block;
+ width: 100%;
+ height: 100%;
+ }
+
+ @include target-safari {
+ @media screen and (760px <= width) {
+ height: 600px;
+ aspect-ratio: unset;
+ }
+ }
+
+ @media (--range-medium-up) {
+ width: auto;
+ height: var(--card-default-height);
+ aspect-ratio: 3 / 4;
+ }
+ }
+
+ @media (--range-medium-up) {
+ .today-card-row__1-card .today-card-wrapper {
+ @include stretch-card;
+ }
+ }
+
+ @media (--range-medium-up) and (--range-large-down) {
+ .today-card-row__2-card {
+ &.today-card-row__stretch-first-wrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+
+ .today-card-row__3-card {
+ .today-card-wrapper:first-child {
+ flex-basis: 100%;
+
+ @include stretch-card(0);
+ }
+
+ &.today-card-row__stretch-first-wrap
+ .today-card-wrapper:nth-child(2),
+ &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+ }
+
+ @media (--range-medium-up) {
+ .today-card-row__4-card {
+ &.today-card-row__stretch-first-wrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-first-wrap .today-card-wrapper:last-child,
+ &.today-card-row__stretch-last-wrap
+ .today-card-wrapper:nth-child(2),
+ &.today-card-row__stretch-last-wrap
+ .today-card-wrapper:nth-child(3) {
+ flex-basis: calc(
+ 100% - var(--card-default-width) - var(--card-row-gap)
+ );
+
+ @include stretch-card;
+ }
+ }
+ }
+
+ @media (--range-xlarge-up) {
+ .today-card-row__2-card {
+ &.today-card-row__stretch-first-nowrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-last-nowrap
+ .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+
+ .today-card-row__3-card {
+ &.today-card-row__stretch-first-nowrap
+ .today-card-wrapper:first-child,
+ &.today-card-row__stretch-last-nowrap
+ .today-card-wrapper:last-child {
+ @include stretch-card;
+ }
+ }
+ }
+</style>
diff --git a/src/components/jet/shelf/UberShelf.svelte b/src/components/jet/shelf/UberShelf.svelte
new file mode 100644
index 0000000..6cdf004
--- /dev/null
+++ b/src/components/jet/shelf/UberShelf.svelte
@@ -0,0 +1,40 @@
+<script lang="ts" context="module">
+ import type { Shelf, Uber } from '@jet-app/app-store/api/models';
+
+ interface UberShelf extends Shelf {
+ contentType: 'uber';
+ items: [Uber];
+ }
+
+ export function isUberShelf(shelf: Shelf): shelf is UberShelf {
+ return shelf.contentType === 'uber' && Array.isArray(shelf.items);
+ }
+</script>
+
+<script lang="ts">
+ import Artwork from '~/components/Artwork.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+
+ export let shelf: UberShelf;
+
+ $: uber = shelf.items[0];
+ $: artwork = uber.artwork;
+</script>
+
+{#if artwork}
+ <ShelfWrapper withPaddingTop={false} withBottomPadding={false}>
+ <div class="artwork-container">
+ <Artwork {artwork} profile="uber-shelf" />
+ </div>
+ </ShelfWrapper>
+{/if}
+
+<style>
+ .artwork-container {
+ border-bottom: 1px solid var(--systemQuaternary-onDark);
+
+ @media (--range-xlarge-only) {
+ border: 1px solid var(--systemQuaternary-onDark);
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/TodayCard.svelte b/src/components/jet/today-card/TodayCard.svelte
new file mode 100644
index 0000000..84d760f
--- /dev/null
+++ b/src/components/jet/today-card/TodayCard.svelte
@@ -0,0 +1,401 @@
+<script lang="ts">
+ import type { TodayCard } from '@jet-app/app-store/api/models';
+
+ import Artwork, {
+ type Profile,
+ getNaturalProfile,
+ } from '~/components/Artwork.svelte';
+ import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
+ import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
+ import TodayCardMedia from '~/components/jet/today-card/TodayCardMedia.svelte';
+ import TodayCardOverlay from '~/components/jet/today-card/TodayCardOverlay.svelte';
+ import { isTodayCardMediaList } from '~/components/jet/today-card/media/TodayCardMediaList.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ import { colorAsString } from '~/utils/color';
+ import { bestBackgroundColor } from './background-color-utils';
+
+ export let card: TodayCard;
+
+ /**
+ * When set to `true`, this component will not enable the `clickAction` provided by the
+ * `card`
+ *
+ * This can be useful on the "story" page, where the card will link back to the page
+ * currently being viewed
+ */
+ export let suppressClickAction: boolean = false;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+
+ let useProtectionLayer: boolean;
+ let useBlurryProtectionLayer: boolean;
+ let useGradientProtectionLayer: boolean;
+ let useListStyle: boolean;
+ let accentColor: string;
+
+ $: ({
+ heading,
+ title,
+ inlineDescription,
+ titleArtwork,
+ overlay,
+ media,
+ editorialDisplayOptions,
+ style = 'light',
+ clickAction,
+ } = card);
+ $: action = suppressClickAction ? undefined : clickAction;
+
+ $: {
+ const isAppEvent = media?.kind === 'appEvent';
+ const isList = !!media && isTodayCardMediaList(media);
+
+ useListStyle = isList;
+ useProtectionLayer =
+ editorialDisplayOptions?.useTextProtectionColor ||
+ editorialDisplayOptions?.useMaterialBlur ||
+ false;
+ useBlurryProtectionLayer = useProtectionLayer && !isAppEvent && !isList;
+ useGradientProtectionLayer = useProtectionLayer && isAppEvent;
+ accentColor = colorAsString(bestBackgroundColor(card.media));
+ }
+</script>
+
+<!--
+ We don't wrap the entire card with an action if there is an `overlay`, since the overlay has
+ it's own link / action (and we don't want nesting `a` tags, of course).
+-->
+<LinkWrapper action={overlay || useListStyle ? null : action}>
+ <div
+ class="today-card"
+ class:light={style === 'light'}
+ class:dark={style === 'dark'}
+ class:white={style === 'white'}
+ class:list={useListStyle}
+ class:with-overlay={overlay}
+ style:--today-card-accent-color={accentColor}
+ >
+ {#if media && !useListStyle}
+ <TodayCardMedia {media} {artworkProfile} />
+ {/if}
+
+ <div class="wrapper">
+ <div
+ class="information-layer"
+ class:with-gradient={useGradientProtectionLayer}
+ class:with-action={!!action}
+ >
+ <LinkWrapper action={useListStyle ? null : action}>
+ <div class="content-container">
+ {#if useBlurryProtectionLayer}
+ <div class="protection-layer" />
+ {/if}
+
+ <div class="title-container">
+ {#if heading && !titleArtwork}
+ <p class="badge">
+ <LineClamp clamp={1}>
+ {heading}
+ </LineClamp>
+ </p>
+ {/if}
+
+ {#if titleArtwork}
+ <div class="title-artwork-container">
+ <Artwork
+ artwork={titleArtwork}
+ profile={getNaturalProfile(
+ titleArtwork,
+ )}
+ />
+ </div>
+ {/if}
+
+ {#if title && !titleArtwork}
+ <h3 class="title">
+ <LinkWrapper
+ action={useListStyle ? action : null}
+ >
+ {@html sanitizeHtml(title)}
+ </LinkWrapper>
+ </h3>
+ {/if}
+
+ {#if inlineDescription}
+ <LineClamp clamp={2}>
+ <p class="description">
+ {@html sanitizeHtml(inlineDescription)}
+ </p>
+ </LineClamp>
+ {/if}
+ </div>
+ </div>
+ </LinkWrapper>
+
+ {#if overlay}
+ <div
+ class="overlay"
+ class:blur-only={!useProtectionLayer}
+ class:dark={useProtectionLayer && style !== 'dark'}
+ class:light={useProtectionLayer && style === 'dark'}
+ >
+ <TodayCardOverlay
+ {overlay}
+ buttonVariant={useProtectionLayer
+ ? 'transparent'
+ : 'dark-gray'}
+ --text-color="var(--today-card-text-color)"
+ --text-accent-color="var(--today-card-text-accent-color)"
+ --text-accent-blend-mode="var(--today-card-text-accent-blend-mode)"
+ />
+ </div>
+ {/if}
+ </div>
+ </div>
+
+ {#if media && useListStyle}
+ <TodayCardMedia {media} {artworkProfile} />
+ {/if}
+ </div>
+</LinkWrapper>
+
+<style lang="scss">
+ @property --gradient-color {
+ syntax: '<color>';
+ inherits: true;
+ initial-value: #000;
+ }
+
+ .today-card {
+ --today-card-gutter: 16px;
+ --today-card-border-radius: var(
+ --border-radius,
+ var(--global-border-radius-large)
+ );
+ --protection-layer-bottom-offset: 0px;
+ --gradient-color: var(--today-card-accent-color);
+ background-color: var(--today-card-accent-color);
+ position: relative;
+ display: flex;
+ align-items: end;
+ height: 100%;
+ overflow: hidden;
+ color: var(--today-card-text-color);
+ container-type: size;
+ container-name: today-card;
+ border-radius: var(--today-card-border-radius);
+ box-shadow: var(--shadow-small);
+ }
+
+ .today-card.with-overlay {
+ --protection-layer-bottom-offset: 80px;
+ }
+
+ .today-card.light,
+ .today-card.dark {
+ --today-card-text-color: rgb(255, 255, 255);
+ --today-card-text-accent-color: rgba(255, 255, 255, 0.56);
+ --today-card-text-accent-blend-mode: plus-lighter;
+ --today-card-background-tint-color: rgba(0, 0, 0, 0.18);
+ }
+
+ .today-card.white {
+ --today-card-text-color: var(--systemPrimary-onLight);
+ --today-card-text-accent-color: rgba(0, 0, 0, 0.56);
+ --today-card-background-tint-color: rgba(255, 255, 255, 0.33);
+ --today-card-text-accent-blend-mode: revert;
+ }
+
+ .today-card :global(.artwork-component) {
+ z-index: unset;
+ }
+
+ .wrapper {
+ position: absolute;
+ display: flex;
+ width: 100%;
+ height: 100%;
+ }
+
+ .content-container {
+ position: relative;
+ }
+
+ .information-layer {
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: end;
+ align-self: flex-end;
+ width: 100%;
+ height: 100%;
+ border-radius: var(--today-card-border-radius);
+ overflow: hidden;
+ }
+
+ .information-layer > :global(a) {
+ display: flex;
+ flex-grow: 1;
+ flex-direction: column;
+ justify-content: end;
+ }
+
+ .information-layer.with-gradient {
+ // A smooth bottom-to-top gradient with an intermediate stop at 60% of the accent color's
+ // opacity to ease the hard transition.
+ --gradient-color-end-position: 22%;
+ --gradient-fade-end-position: 50%;
+ background: linear-gradient(
+ 0deg,
+ var(--gradient-color) var(--gradient-color-end-position),
+ color-mix(in srgb, var(--gradient-color) 60%, transparent)
+ calc(
+ (
+ var(--gradient-color-end-position) +
+ var(--gradient-fade-end-position)
+ ) / 2
+ ),
+ transparent var(--gradient-fade-end-position)
+ );
+ transition: --accent-color-end 500ms ease-out, --fade-end 350ms ease-out,
+ --gradient-color 350ms ease-out;
+ }
+
+ .information-layer.with-gradient.with-action:has(> a:hover) {
+ // Darkens the color used in the gradient on hover
+ --gradient-color: color-mix(
+ in srgb,
+ var(--today-card-accent-color) 93%,
+ black
+ );
+ }
+
+ @container today-card (aspect-ratio >= 16/9) {
+ .information-layer.with-gradient {
+ --accent-color-end: 30%;
+ }
+ }
+
+ .protection-layer {
+ --brightness: 0.95;
+ position: absolute;
+ width: 100%;
+ // On cards with overlays (app lockups at the bottom), we increase the height of the
+ // protection layer and shift it downward the same amount, so it is aligned to bottom
+ // of the overlay.
+ height: calc(100% + var(--protection-layer-bottom-offset) + 60px);
+ bottom: calc(-1 * var(--protection-layer-bottom-offset));
+ background: var(--today-card-background-tint-color);
+ backdrop-filter: blur(34px) brightness(var(--brightness)) saturate(1.6)
+ contrast(1.1);
+ mask-image: linear-gradient(
+ to top,
+ black 30%,
+ rgba(0, 0, 0, 0.75) 70%,
+ rgba(0, 0, 0, 0.4) 86%,
+ transparent 100%
+ );
+ transition: backdrop-filter 210ms ease-in;
+ }
+
+ .information-layer:has(> a:hover) .protection-layer {
+ --brightness: 0.88;
+ }
+
+ .badge {
+ font: var(--callout-emphasized);
+ margin-bottom: 4px;
+ mix-blend-mode: var(--today-card-text-accent-blend-mode);
+ color: var(--today-card-text-accent-color);
+ }
+
+ .title-container {
+ width: auto;
+ position: relative;
+ padding: 0 var(--today-card-gutter) var(--today-card-gutter);
+ }
+
+ @container today-card (orientation: landscape) {
+ .title-artwork-container {
+ width: 33%;
+ min-width: 200px;
+ max-width: 300px;
+ padding-bottom: 8px;
+ }
+ }
+
+ @container today-card (orientation: portrait) {
+ .title-artwork-container {
+ max-width: 75%;
+ padding-bottom: 8px;
+ }
+ }
+
+ .title {
+ font: var(--header-emphasized);
+ color: var(--today-card-text-color);
+ text-wrap: pretty;
+ }
+
+ .description {
+ font: var(--body);
+ padding-top: calc(var(--today-card-gutter) / 2);
+ mix-blend-mode: var(--today-card-text-accent-blend-mode);
+ color: var(--today-card-text-accent-color);
+ text-wrap: pretty;
+ z-index: 1;
+ position: relative;
+ }
+
+ .overlay {
+ z-index: 1;
+ position: relative;
+ padding: var(--today-card-gutter);
+ }
+
+ .overlay.blur-only {
+ backdrop-filter: blur(50px);
+ }
+
+ .overlay.light {
+ background-image: linear-gradient(rgba(225, 225, 225, 0.15) 0 0);
+ }
+
+ .overlay.dark {
+ background-image: linear-gradient(rgba(0, 0, 0, 0.15) 0 0);
+ }
+
+ .list {
+ background: var(--systemPrimary-onDark);
+ padding: var(--today-card-gutter) 0;
+ width: 100%;
+ flex-direction: column;
+
+ @media (prefers-color-scheme: dark) {
+ --title-color: var(--systemPrimary);
+ background: var(--systemQuaternary);
+
+ .title {
+ --today-card-text-color: var(--systemPrimary);
+ }
+
+ .badge {
+ --today-card-text-accent-color: var(--systemSecondary);
+ }
+ }
+ }
+
+ .list .wrapper {
+ position: relative;
+ height: auto;
+ width: 100%;
+ }
+
+ .list .information-layer {
+ padding-top: 0;
+ }
+</style>
diff --git a/src/components/jet/today-card/TodayCardMedia.svelte b/src/components/jet/today-card/TodayCardMedia.svelte
new file mode 100644
index 0000000..99f444f
--- /dev/null
+++ b/src/components/jet/today-card/TodayCardMedia.svelte
@@ -0,0 +1,49 @@
+<script lang="ts">
+ import type { TodayCardMedia } from '@jet-app/app-store/api/models';
+
+ import type { Profile } from '~/components/Artwork.svelte';
+ import TodayCardMediaAppEvent, {
+ isTodayCardMediaAppEvent,
+ } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte';
+ import TodayCardMediaAppIcon, {
+ isTodayCardMediAppIcon,
+ } from '~/components/jet/today-card/media/TodayCardMediaAppIcon.svelte';
+ import TodayCardMediaBrandedSingleApp, {
+ isTodayCardMediaBrandedSingleApp,
+ } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte';
+ import TodayCardMediaList, {
+ isTodayCardMediaList,
+ } from '~/components/jet/today-card/media/TodayCardMediaList.svelte';
+ import TodayCardMediaRiver, {
+ isTodayCardMediaRiver,
+ } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte';
+ import TodayCardMediaWithArtwork, {
+ isTodayCardMediaWithArtwork,
+ } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo, {
+ isTodayCardMediaVideo,
+ } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+
+ export let media: TodayCardMedia;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+</script>
+
+{#if isTodayCardMediaAppEvent(media)}
+ <TodayCardMediaAppEvent {media} {artworkProfile} />
+{:else if isTodayCardMediAppIcon(media)}
+ <TodayCardMediaAppIcon {media} />
+{:else if isTodayCardMediaBrandedSingleApp(media)}
+ <TodayCardMediaBrandedSingleApp {media} {artworkProfile} />
+{:else if isTodayCardMediaList(media)}
+ <TodayCardMediaList {media} />
+{:else if isTodayCardMediaWithArtwork(media)}
+ <TodayCardMediaWithArtwork {media} {artworkProfile} />
+{:else if isTodayCardMediaRiver(media)}
+ <TodayCardMediaRiver {media} />
+{:else if isTodayCardMediaVideo(media)}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+{/if}
diff --git a/src/components/jet/today-card/TodayCardOverlay.svelte b/src/components/jet/today-card/TodayCardOverlay.svelte
new file mode 100644
index 0000000..4e3c405
--- /dev/null
+++ b/src/components/jet/today-card/TodayCardOverlay.svelte
@@ -0,0 +1,48 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardOverlay,
+ TodayCardLockupOverlay,
+ } from '@jet-app/app-store/api/models';
+
+ export function isLockupOverlay(
+ overlay: TodayCardOverlay,
+ ): overlay is TodayCardLockupOverlay {
+ return overlay.kind === 'lockup';
+ }
+</script>
+
+<script lang="ts">
+ import TodayCardLockupListOverlay, {
+ isLockupListOverlay,
+ } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+
+ export let overlay: TodayCardOverlay;
+ export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'transparent';
+</script>
+
+{#if isLockupOverlay(overlay)}
+ <div class="small-lockup-item-config">
+ <SmallLockupItem
+ {buttonVariant}
+ item={overlay.lockup}
+ titleLineCount={1}
+ appIconProfile="app-icon"
+ />
+ </div>
+{:else if isLockupListOverlay(overlay)}
+ <TodayCardLockupListOverlay {overlay} />
+{/if}
+
+<style>
+ .small-lockup-item-config {
+ --title-color: var(--text-color, currentColor);
+ --subtitle-color: var(--text-accent-color, currentColor);
+ --linkColor: currentColor;
+ --eyebrow-color: var(--text-accent-color, currentColor);
+ --button-blend-mode: var(--text-accent-blend-mode);
+ --subtitle-blend-mode: var(--text-accent-blend-mode);
+ --eyebrow-blend-mode: var(--text-accent-blend-mode);
+ display: contents;
+ }
+</style>
diff --git a/src/components/jet/today-card/background-color-utils.ts b/src/components/jet/today-card/background-color-utils.ts
new file mode 100644
index 0000000..c2c0fe6
--- /dev/null
+++ b/src/components/jet/today-card/background-color-utils.ts
@@ -0,0 +1,54 @@
+import { type Optional, isSome } from '@jet/environment/types/optional';
+import type {
+ Color,
+ TodayCardMedia,
+ TodayCardMediaWithArtwork,
+} from '@jet-app/app-store/api/models';
+
+import { isTodayCardMediaBrandedSingleApp } from './media/TodayCardMediaBrandedSingleApp.svelte';
+import { isTodayCardMediaAppEvent } from './media/TodayCardMediaAppEvent.svelte';
+import { isTodayCardMediaWithArtwork } from './media/TodayCardMediaWithArtwork.svelte';
+
+const DEFAULT_COLOR: Color = {
+ type: 'named',
+ name: 'defaultBackground',
+};
+
+function getBackgroundFromMediaWithArtwork(
+ media: TodayCardMediaWithArtwork,
+): Optional<Color> {
+ return (
+ media.videos[0]?.preview.backgroundColor ??
+ media.artworks[0]?.backgroundColor
+ );
+}
+
+/**
+ * Onyx App Store alternative to the `bestBackgroundColor` method that exists on
+ * the {@linkcode TodayCardMedia} type
+ *
+ * This is necessary because the functions on those class instances are not
+ * carried over to the client when serializing the view-model, making them
+ * impossible to call in a consistent way from our codebase
+ */
+export function bestBackgroundColor(media: Optional<TodayCardMedia>): Color {
+ if (isSome(media)) {
+ if (isTodayCardMediaAppEvent(media)) {
+ return media.tintColor;
+ }
+
+ if (isTodayCardMediaBrandedSingleApp(media)) {
+ return (
+ getBackgroundFromMediaWithArtwork(media) ??
+ media.icon.backgroundColor ??
+ DEFAULT_COLOR
+ );
+ }
+
+ if (isTodayCardMediaWithArtwork(media)) {
+ return getBackgroundFromMediaWithArtwork(media) ?? DEFAULT_COLOR;
+ }
+ }
+
+ return DEFAULT_COLOR;
+}
diff --git a/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte
new file mode 100644
index 0000000..1faa933
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaAppEvent,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaAppEvent(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaAppEvent {
+ return media.kind === 'appEvent';
+ }
+</script>
+
+<script lang="ts">
+ import type { Profile } from '~/components/Artwork.svelte';
+ import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+ import AppEventDate from '~/components/AppEventDate.svelte';
+
+ export let media: TodayCardMediaAppEvent;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+</script>
+
+<div class="event-container">
+ <span class="time-container">
+ <AppEventDate formattedDates={media.formattedDates} />
+ </span>
+
+ <div class="artwork-container">
+ {#if media.videos.length > 0}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+ {:else if media.artworks.length > 0}
+ <TodayCardMediaWithArtwork {media} {artworkProfile} />
+ {/if}
+ </div>
+</div>
+
+<style>
+ .event-container {
+ --today-card-border-width: 4px;
+ border: var(--today-card-border-width) solid
+ var(--today-card-accent-color);
+ border-radius: var(--today-card-border-radius);
+ position: relative;
+ aspect-ratio: 0.75;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ }
+
+ @container (orientation: landscape) {
+ .event-container {
+ aspect-ratio: 16/9;
+ }
+ }
+
+ .artwork-container {
+ height: 100%;
+ border-radius: calc(
+ var(--today-card-border-radius) - var(--today-card-border-width)
+ );
+ }
+
+ .time-container :global(time),
+ .time-container :global(span) {
+ background: var(--today-card-accent-color);
+ border-end-end-radius: var(--today-card-border-radius);
+ font: var(--headline);
+ padding: 6px 10px 6px 8px;
+ position: absolute;
+ top: 0;
+ z-index: 3;
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte
new file mode 100644
index 0000000..a6db985
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte
@@ -0,0 +1,62 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaAppIcon,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediAppIcon(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaAppIcon {
+ return media.kind === 'appIcon';
+ }
+</script>
+
+<script lang="ts">
+ import AppIcon from '~/components/AppIcon.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let media: TodayCardMediaAppIcon;
+
+ $: backgroundColor = media.icon.backgroundColor
+ ? colorAsString(media.icon.backgroundColor)
+ : null;
+</script>
+
+<div class="container" style:--background-color={backgroundColor}>
+ <div class="artwork-container">
+ <AppIcon
+ icon={media.icon}
+ profile="app-icon-xlarge"
+ fixedWidth={false}
+ />
+ </div>
+</div>
+
+<style>
+ .container {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ background-color: var(--background-color);
+ border-radius: var(--today-card-border-radius);
+ }
+
+ .artwork-container {
+ width: 50%;
+ height: 50%;
+ }
+
+ @container (orientation: landscape) {
+ .container {
+ align-items: start;
+ padding-top: 5%;
+ }
+
+ .artwork-container {
+ width: 30%;
+ height: 30%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte
new file mode 100644
index 0000000..dfdaa0f
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte
@@ -0,0 +1,41 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaBrandedSingleApp,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaBrandedSingleApp(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaBrandedSingleApp {
+ return media.kind === 'brandedSingleApp';
+ }
+</script>
+
+<script lang="ts">
+ import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
+ import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte';
+ import type { Profile } from '~/components/Artwork.svelte';
+
+ export let media: TodayCardMediaBrandedSingleApp;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: Profile | undefined = undefined;
+
+ // There is a small but non-zero set of old legacy Today Cards that can appear on the Today page,
+ // and those cards have their safe area on the left side of the artwork, rather than the center,
+ // like all the modern artwork. For those cases, we pin the artwork to the left edge of the card.
+ $: pinnedToLeft =
+ media.artworkLayoutsWithMetrics[0].ltr.collapsedLayoutInsets.left < 0;
+</script>
+
+{#if media.videos.length > 0}
+ <TodayCardMediaVideo {media} {artworkProfile} />
+{:else if media.artworks.length > 0}
+ <TodayCardMediaWithArtwork
+ {media}
+ {artworkProfile}
+ pinArtworkToLeft={pinnedToLeft}
+ />
+{/if}
diff --git a/src/components/jet/today-card/media/TodayCardMediaList.svelte b/src/components/jet/today-card/media/TodayCardMediaList.svelte
new file mode 100644
index 0000000..00f8688
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaList.svelte
@@ -0,0 +1,86 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaList,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaList(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaList {
+ return media.kind === 'list';
+ }
+</script>
+
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
+
+ export let media: TodayCardMediaList;
+
+ let container: HTMLDivElement;
+ let fadeTop = '0%';
+ let fadeBottom = '0%';
+
+ function calculateFadeAmounts() {
+ const { scrollTop, scrollHeight, clientHeight } = container;
+
+ fadeTop = scrollTop > 0 ? '10%' : `${scrollTop}%`;
+ fadeBottom = scrollTop + clientHeight < scrollHeight - 1 ? '15%' : '0%';
+ }
+
+ onMount(() => {
+ calculateFadeAmounts();
+ container.addEventListener('scroll', calculateFadeAmounts);
+
+ return () =>
+ container.removeEventListener('scroll', calculateFadeAmounts);
+ });
+</script>
+
+<div
+ class="container"
+ style:--fade-top-size={fadeTop}
+ style:--fade-bottom-size={fadeBottom}
+ bind:this={container}
+>
+ <ul>
+ {#each media.lockups as item}
+ <li>
+ <SmallLockupItem {item} />
+ </li>
+ {/each}
+ </ul>
+</div>
+
+<style>
+ @property --fade-top-size {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 0%;
+ }
+
+ @property --fade-bottom-size {
+ syntax: '<percentage>';
+ inherits: false;
+ initial-value: 0%;
+ }
+
+ .container {
+ width: 100%;
+ overflow: scroll;
+ padding: 0 var(--today-card-gutter);
+ mask-image: linear-gradient(
+ to bottom,
+ transparent 0%,
+ black var(--fade-top-size),
+ black calc(100% - var(--fade-bottom-size)),
+ transparent 100%
+ );
+ transition: --fade-top-size 105ms cubic-bezier(0.5, 1, 0.89, 1),
+ --fade-bottom-size 420ms cubic-bezier(0.45, 0, 0.55, 1);
+ }
+
+ li {
+ margin-bottom: 16px;
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaRiver.svelte b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte
new file mode 100644
index 0000000..d3f9666
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte
@@ -0,0 +1,78 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaRiver,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaRiver(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaRiver {
+ return media.kind === 'river';
+ }
+</script>
+
+<script lang="ts">
+ import {
+ getBackgroundGradientCSSVarsFromArtworks,
+ getLuminanceForRGB,
+ } from '~/utils/color';
+ import AppIconRiver from '~/components/AppIconRiver.svelte';
+
+ /**
+ * The actual properties of {@linkcode TodayCardMediaRiver} that are required
+ * to render this component
+ */
+ type TodayCardMediaRiverRequirements = Pick<TodayCardMediaRiver, 'lockups'>;
+
+ export let media: TodayCardMediaRiverRequirements;
+
+ $: icons = media.lockups.map((lockup) => lockup.icon);
+ $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
+ icons,
+ {
+ // sorts from darkest to lightest
+ sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
+ },
+ );
+</script>
+
+<div class="container" style={backgroundGradientCssVars}>
+ {#if icons.length}
+ <AppIconRiver {icons} />
+ {/if}
+</div>
+
+<style>
+ .container {
+ --app-icon-river-icon-width: 96px;
+ height: 100%;
+ width: 100%;
+ padding-top: 10%;
+ overflow: hidden;
+ border-radius: var(--today-card-border-radius);
+ background: radial-gradient(
+ circle at 3% -50%,
+ var(--top-left, #000) 20%,
+ transparent 70%
+ ),
+ radial-gradient(
+ circle at -50% 120%,
+ var(--bottom-left, #000) 40%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 140% -50%,
+ var(--top-right, #000) 60%,
+ transparent 80%
+ ),
+ radial-gradient(
+ circle at 62% 100%,
+ var(--bottom-right, #000) 50%,
+ transparent 100%
+ );
+
+ @media (--range-small-only) {
+ padding-top: 5%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaVideo.svelte b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte
new file mode 100644
index 0000000..f2524c6
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte
@@ -0,0 +1,72 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardMedia,
+ TodayCardMediaVideo,
+ Video as VideoModel,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaVideo(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaVideo {
+ return (
+ media.kind === 'video' ||
+ (media.kind === 'artwork' && 'videos' in media)
+ );
+ }
+</script>
+
+<script lang="ts">
+ import mediaQueries from '~/utils/media-queries';
+ import type { Profile as ArtworkProfile } from '~/components/Artwork.svelte';
+ import Video from '~/components/jet/Video.svelte';
+ import { colorAsString } from '~/utils/color';
+
+ export let media: TodayCardMediaVideo;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: ArtworkProfile | undefined = undefined;
+
+ let videoToDisplay: VideoModel | undefined;
+ $: videoToDisplay = media.videos[0];
+
+ let profile: ArtworkProfile;
+ $: profile =
+ artworkProfile ??
+ ($mediaQueries === 'small' ? 'card' : 'card-horizontal');
+ $: backgroundColor = videoToDisplay?.preview.backgroundColor
+ ? colorAsString(videoToDisplay?.preview.backgroundColor)
+ : null;
+</script>
+
+{#if videoToDisplay}
+ <div class="video-wrapper" style:--background-color={backgroundColor}>
+ <Video
+ autoplay
+ loop
+ {profile}
+ useControls={false}
+ video={videoToDisplay}
+ />
+ </div>
+{/if}
+
+<style>
+ .video-wrapper {
+ background: black;
+ aspect-ratio: 3/4;
+ width: 100%;
+ position: relative;
+ overflow: hidden;
+ border-radius: var(--today-card-border-radius);
+ background-color: var(--background-color);
+ }
+
+ @container (orientation: landscape) {
+ .video-wrapper {
+ aspect-ratio: 16/9;
+ height: 100%;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte
new file mode 100644
index 0000000..e604708
--- /dev/null
+++ b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte
@@ -0,0 +1,100 @@
+<script lang="ts" context="module">
+ import type {
+ Artwork as ArtworkModel,
+ TodayCardMedia,
+ TodayCardMediaWithArtwork,
+ } from '@jet-app/app-store/api/models';
+
+ export function isTodayCardMediaWithArtwork(
+ media: TodayCardMedia,
+ ): media is TodayCardMediaWithArtwork {
+ return (
+ media.kind === 'artwork' &&
+ 'artworks' in media &&
+ Array.isArray(media.artworks) &&
+ media.artworks.length > 0
+ );
+ }
+</script>
+
+<script lang="ts">
+ import Artwork, {
+ type Profile as ArtworkProfile,
+ } from '~/components/Artwork.svelte';
+
+ export let media: TodayCardMediaWithArtwork;
+
+ export let pinArtworkToLeft: boolean = false;
+
+ /**
+ * A `Profile` to override the default for the card's media
+ */
+ export let artworkProfile: ArtworkProfile | undefined = undefined;
+
+ let artworkToDisplay: ArtworkModel;
+ // Today Card artwork comes back from Jet with a width of 800px, even though the source artwork
+ // is _much_ larger. The shared `Artwork` component doesn't let us render an image beyond the
+ // artwork's `width` and `height` properties, and we absolutely need to render these images
+ // larger than 800px wide, so we are forcing these new upper bounds for the artworks dimensions.
+ // Eventually, we should rethink this and have the proper dimensions come back from Jet:
+ // rdar://148730199 (Bigger images for TodayCard)
+ $: artworkToDisplay = Object.assign({}, media.artworks[0], {
+ width: 3840,
+ height: 2160,
+ });
+</script>
+
+{#if artworkProfile}
+ <Artwork profile={artworkProfile} artwork={artworkToDisplay} />
+{:else}
+ <div class="wrapper">
+ <div class="artwork-container portrait">
+ <Artwork profile="card" artwork={artworkToDisplay} />
+ </div>
+
+ <div
+ class="artwork-container landscape"
+ class:pinned-to-left={pinArtworkToLeft}
+ >
+ <Artwork profile="card-horizontal" artwork={artworkToDisplay} />
+ </div>
+ </div>
+{/if}
+
+<style>
+ .wrapper,
+ .artwork-container {
+ height: 100%;
+ width: 100%;
+ }
+
+ .wrapper .artwork-container :global(.artwork-component),
+ .wrapper .artwork-container :global(img) {
+ object-fit: cover;
+ height: 100%;
+ }
+
+ .pinned-to-left {
+ --artwork-override-object-position: left;
+ }
+
+ @container (orientation: landscape) {
+ .artwork-container.landscape {
+ display: block;
+ }
+
+ .artwork-container.portrait {
+ display: none;
+ }
+ }
+
+ @container (orientation: portrait) {
+ .artwork-container.landscape {
+ display: none;
+ }
+
+ .artwork-container.portrait {
+ display: block;
+ }
+ }
+</style>
diff --git a/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte
new file mode 100644
index 0000000..1e7d297
--- /dev/null
+++ b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte
@@ -0,0 +1,42 @@
+<script lang="ts" context="module">
+ import type {
+ TodayCardOverlay,
+ TodayCardLockupListOverlay,
+ } from '@jet-app/app-store/api/models';
+
+ export function isLockupListOverlay(
+ overlay: TodayCardOverlay,
+ ): overlay is TodayCardLockupListOverlay {
+ return overlay.kind === 'lockupList';
+ }
+</script>
+
+<script lang="ts">
+ import AppIcon from '~/components/AppIcon.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let overlay: TodayCardLockupListOverlay;
+</script>
+
+<div class="lockup-list">
+ {#each overlay.lockups as lockup}
+ <LinkWrapper action={lockup.clickAction}>
+ <AppIcon icon={lockup.icon} />
+ </LinkWrapper>
+ {/each}
+</div>
+
+<style>
+ .lockup-list {
+ display: flex;
+ gap: 12px;
+
+ @media (--range-xsmall-only) and (--sidebar-visible) {
+ gap: 10px;
+ }
+
+ @media (--range-small-up) {
+ gap: 16px;
+ }
+ }
+</style>
diff --git a/src/components/jet/web-navigation/CategoryTabItem.svelte b/src/components/jet/web-navigation/CategoryTabItem.svelte
new file mode 100644
index 0000000..61f2570
--- /dev/null
+++ b/src/components/jet/web-navigation/CategoryTabItem.svelte
@@ -0,0 +1,67 @@
+<script lang="ts">
+ import { createEventDispatcher } from 'svelte';
+ import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
+ import Item from '@amp/web-app-components/src/components/Navigation/Item.svelte';
+ import ItemContent from '@amp/web-app-components/src/components/Navigation/ItemContent.svelte';
+
+ const dispatch = createEventDispatcher();
+
+ export let item: any;
+ export let selected: boolean = false;
+ export let translateFn: (key: string) => string;
+ $$props; // lets the other props automatically passed to navigation item components enter without being delcared explicitly
+
+ const itemClicked = (): void => {
+ dispatch('selectItem', item);
+ };
+
+ $: backgroundImage = item.artwork
+ ? buildSrc(
+ item.artwork.template,
+ {
+ crop: 'bb',
+ width: 40,
+ height: 40,
+ fileType: 'webp',
+ },
+ {},
+ )
+ : undefined;
+</script>
+
+<Item {item} {selected} {translateFn}>
+ <!-- svelte-ignore a11y-click-events-have-key-events -->
+ <!-- svelte-ignore a11y-no-static-element-interactions -->
+ <a
+ href={item.url}
+ class="navigation-item__link"
+ role="button"
+ aria-pressed={selected}
+ on:click|preventDefault={itemClicked}
+ >
+ <ItemContent label={item.label}>
+ <div
+ slot="icon"
+ aria-hidden={true}
+ class="icon"
+ style:--background-image={`url(${backgroundImage})`}
+ />
+ </ItemContent>
+ </a>
+</Item>
+
+<style>
+ .icon {
+ display: flex;
+ align-self: center;
+ width: 20px;
+ height: 20px;
+ background: var(--keyColor);
+ mask: var(--background-image) center / contain no-repeat;
+
+ @media (--sidebar-visible) {
+ width: 18px;
+ height: 18px;
+ }
+ }
+</style>
diff --git a/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte
new file mode 100644
index 0000000..f0fe666
--- /dev/null
+++ b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte
@@ -0,0 +1,88 @@
+<script lang="ts">
+ import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation';
+
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import PlatformSelectorItem from '~/components/jet/web-navigation/PlatformSelectorItem.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import Menu from '~/components/Menu.svelte';
+ import { getJet } from '~/jet';
+
+ export let platformSelectors: WebNavigationLink[];
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ $: activeSelector = platformSelectors.find((selector) => selector.isActive);
+
+ const handleShowMenu = () => {
+ jet.recordCustomMetricsEvent({
+ eventType: 'click',
+ actionType: 'open',
+ targetType: 'button',
+ targetId: 'PlatformSelector',
+ });
+ };
+</script>
+
+{#if activeSelector}
+ <nav>
+ <Menu options={platformSelectors} forcedXPosition={25} {handleShowMenu}>
+ <svelte:fragment slot="trigger">
+ <span
+ class="platform-selector-text"
+ id="platform-selector-text"
+ aria-labelledby="app-store-icon-contianer platform-selector-text"
+ aria-haspopup="menu"
+ >
+ {$i18n.t(
+ 'ASE.Web.AppStore.Navigation.PlatformSelectorText',
+ {
+ platform: activeSelector.action.title,
+ },
+ )}
+
+ <SFSymbol name="chevron.down" />
+ </span>
+ </svelte:fragment>
+
+ <svelte:fragment slot="option" let:option>
+ <PlatformSelectorItem platformSelector={option} />
+ </svelte:fragment>
+ </Menu>
+ </nav>
+{/if}
+
+<style>
+ nav {
+ --menu-item-padding: 0;
+ --menu-item-margin: 0 0 8px 0;
+ --menu-popover-padding: 8px;
+ --menu-common-padding: 8px;
+ --menu-trigger-padding: 0;
+ --menu-popover-background-color: var(--pageBg);
+ --menu-popover-box-shadow: 10px 10px 10px 0
+ var(--systemQuaternary-onLight);
+ --menu-popover-border-radius: 14px;
+ --menu-popover-border: 1px solid var(--systemQuaternary);
+ --menu-popover-z-index: calc(var(--z-web-chrome) + 1);
+ }
+
+ .platform-selector-text {
+ display: flex;
+ align-items: center;
+ gap: var(--platform-selector-trigger-gap, 4px);
+ font: var(--title-2);
+ white-space: nowrap;
+ }
+
+ .platform-selector-text :global(svg) {
+ height: 0.7em;
+ position: relative;
+ top: 2px;
+ fill: var(--systemPrimary);
+ }
+
+ nav :global(.menu-popover) {
+ width: 211px;
+ }
+</style>
diff --git a/src/components/jet/web-navigation/PlatformSelectorItem.svelte b/src/components/jet/web-navigation/PlatformSelectorItem.svelte
new file mode 100644
index 0000000..9b72fda
--- /dev/null
+++ b/src/components/jet/web-navigation/PlatformSelectorItem.svelte
@@ -0,0 +1,97 @@
+<script lang="ts">
+ import { isSome } from '@jet/environment/types/optional';
+ import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation';
+ import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent';
+
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let platformSelector: WebNavigationLink;
+
+ const i18n = getI18n();
+
+ $: ({ action, isActive } = platformSelector);
+ $: ({ artwork } = action);
+</script>
+
+<FlowAction destination={action}>
+ <span class="platform-selector" class:is-active={isActive}>
+ {#if isSome(artwork) && isSystemImageArtwork(artwork)}
+ <div class="icon-container">
+ <SystemImage {artwork} />
+ </div>
+ {/if}
+
+ <span
+ class="platform-title"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.Navigation.AX.PlatformSelectorItem',
+ {
+ platform: action.title,
+ },
+ )}
+ >
+ {action.title}
+ </span>
+
+ {#if action.destination && isSearchResultsPageIntent(action.destination)}
+ <span aria-hidden={true} class="search-icon-container">
+ <SFSymbol name="magnifyingglass" />
+ </span>
+ {/if}
+ </span>
+</FlowAction>
+
+<style lang="scss">
+ @use '@amp/web-shared-styles/app/core/globalvars' as *;
+
+ .platform-selector {
+ display: flex;
+ border-radius: var(--global-border-radius-medium);
+ padding: 8px;
+ margin-bottom: 4px;
+ gap: 10px;
+ transition: background-color 175ms ease-in;
+ }
+
+ .platform-selector:not(.is-active):hover {
+ background-color: rgba(45, 45, 45, 0.035);
+
+ @media (prefers-color-scheme: dark) {
+ background-color: rgba(45, 45, 45, 0.35);
+ }
+ }
+
+ .platform-selector.is-active {
+ background-color: var(--systemQuinary);
+ }
+
+ .icon-container {
+ display: flex;
+ justify-content: center;
+ padding-inline-end: 2px;
+ }
+
+ .icon-container :global(svg) {
+ max-height: 16px;
+ width: 23px;
+ }
+
+ .search-icon-container {
+ display: flex;
+ }
+
+ .search-icon-container :global(svg) {
+ fill: var(--systemSecondary);
+ width: 16px;
+ }
+
+ .platform-title {
+ font: var(--body);
+ flex-grow: 1;
+ }
+</style>
diff --git a/src/components/navigation/Navigation.svelte b/src/components/navigation/Navigation.svelte
new file mode 100644
index 0000000..0114d4c
--- /dev/null
+++ b/src/components/navigation/Navigation.svelte
@@ -0,0 +1,423 @@
+<script lang="ts">
+ import { writable } from 'svelte/store';
+ import { isSome } from '@jet/environment/types/optional';
+ import type {
+ WebNavigation,
+ WebNavigationLink,
+ } from '@jet-app/app-store/api/models/web-navigation';
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+ import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent';
+
+ import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte';
+ import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
+
+ import AppStoreLogo from '~/components/icons/AppStoreLogo.svg';
+ import PlatformSelectorDropdown from '~/components/jet/web-navigation/PlatformSelectorDropdown.svelte';
+ import FlowAction from '~/components/jet/action/FlowAction.svelte';
+ import SystemImage, {
+ isSystemImageArtwork,
+ } from '~/components/SystemImage.svelte';
+ import SearchInput from '~/components/navigation/SearchInput.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+
+ import { getJetPerform } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+ import {
+ type NavigationItemWithTab,
+ navigationIdFromLink,
+ makeNavLinks,
+ } from '~/components/navigation/navigation-items';
+ import mediaQueries from '~/utils/media-queries';
+
+ import { fade, type EasingFunction } from 'svelte/transition';
+ import { circOut } from 'svelte/easing';
+ import { flyAndBlur } from '~/utils/transition';
+ import { makeCategoryTabsIntent } from '@jet-app/app-store/api/intents/category-tabs-intent';
+ import { getJet } from '~/jet';
+ import { getPlatformFromPage } from '~/utils/seo/common';
+ import type { NavigationId } from '@amp/web-app-components/src/types';
+
+ const i18n = getI18n();
+ const perform = getJetPerform();
+ const jet = getJet();
+
+ const categoryTabsCache: Record<string, WebNavigationLink[]> = {};
+ let categoryTabLinks: WebNavigationLink[] = [];
+ let currentTabStore = writable<NavigationId | null>(null);
+
+ export let webNavigation: WebNavigation;
+
+ $: isXSmallViewport = $mediaQueries === 'xsmall';
+ $: searchAction = webNavigation.searchAction as WebSearchFlowAction;
+ // Mobile first means the inline items are hidden
+ // However, we still want the list visible in SSR (which is fine for mobile
+ // since the menu won't be expanded by default)
+ $: inlinePlatformItems =
+ isXSmallViewport || typeof window === 'undefined'
+ ? webNavigation.platforms
+ : [];
+
+ $: if (webNavigation && typeof window !== 'undefined') {
+ fetchCategoryTabs(webNavigation);
+ }
+
+ async function fetchCategoryTabs(nav: WebNavigation) {
+ const platform = getPlatformFromPage({
+ webNavigation: nav,
+ });
+
+ if (!platform) {
+ categoryTabLinks = [];
+ return;
+ }
+
+ if (categoryTabsCache[platform]) {
+ categoryTabLinks = updateActiveStates(categoryTabsCache[platform]);
+ } else {
+ try {
+ const data = await jet.dispatch(
+ makeCategoryTabsIntent({
+ platform,
+ }),
+ );
+
+ categoryTabsCache[platform] = data;
+ categoryTabLinks = updateActiveStates(data);
+ } catch (error) {
+ categoryTabLinks = [];
+ }
+ }
+
+ updateCurrentTab();
+ }
+
+ function updateActiveStates(
+ tabs: WebNavigationLink[],
+ ): WebNavigationLink[] {
+ return tabs.map((link) => ({
+ ...link,
+ isActive: link.action?.destination?.id
+ ? window.location.pathname.includes(link.action.destination.id)
+ : false,
+ }));
+ }
+
+ function updateCurrentTab() {
+ const allLinks: WebNavigationLink[] = [
+ ...categoryTabLinks,
+ ...webNavigation.tabs,
+ ];
+
+ const activeLink = allLinks.find((link) => link.isActive);
+ currentTabStore.set(
+ activeLink ? navigationIdFromLink(activeLink) : null,
+ );
+ }
+
+ function handleMenuItemClick(event: CustomEvent<NavigationItemWithTab>) {
+ const navigationItem = event.detail;
+ const tab = navigationItem.tab;
+
+ perform(tab.action);
+ }
+
+ const BASE_DELAY = 80;
+ const BASE_DURATION = 150;
+ const DURATION_SPREAD = 300;
+
+ // Returns an eased duration for a list item based on its index, e.g. items later in the list
+ // get longer durations, between BASE_DURATION and BASE_DURATION + DURATION_SPREAD.
+ function getEasedDuration({
+ i,
+ totalNumberOfItems,
+ easing = circOut,
+ }: {
+ i: number;
+ totalNumberOfItems: number;
+ easing?: EasingFunction;
+ }) {
+ const t = i / (totalNumberOfItems - 1);
+ return BASE_DURATION + easing(t) * DURATION_SPREAD;
+ }
+</script>
+
+<div class="navigation-wrapper">
+ <Navigation
+ translateFn={$i18n.t}
+ items={makeNavLinks(webNavigation.tabs, {
+ shouldShowSearchTab: $sidebarIsHidden,
+ })}
+ personalizedItemsHeader={$i18n.t(
+ 'ASE.Web.AppStore.Navigation.Categories.Title',
+ )}
+ personalizedItems={makeNavLinks(categoryTabLinks, {
+ shouldShowSearchTab: $sidebarIsHidden,
+ })}
+ currentTab={currentTabStore}
+ libraryItems={[]}
+ on:menuItemClick={handleMenuItemClick}
+ >
+ <div slot="logo" class="platform-selector-container">
+ <span
+ id="app-store-icon-contianer"
+ class="app-store-icon-container"
+ role="img"
+ aria-label={$i18n.t(
+ 'ASE.Web.AppStore.Navigation.AX.AppStoreLogo',
+ )}
+ >
+ <AppStoreLogo focusable={false} />
+ </span>
+
+ {#if !$sidebarIsHidden && !isXSmallViewport}
+ <PlatformSelectorDropdown
+ platformSelectors={webNavigation.platforms}
+ />
+ {/if}
+ </div>
+
+ <svelte:fragment slot="search">
+ <div class="search-input-container">
+ <SearchInput {searchAction} />
+ </div>
+ </svelte:fragment>
+
+ <div slot="after-navigation-items" class="platform-selector-inline">
+ {#if isXSmallViewport}
+ <h3 in:fade out:fade={{ delay: 250, duration: BASE_DURATION }}>
+ {$i18n.t('ASE.Web.AppStore.Navigation.PlatformHeading')}
+ </h3>
+ {/if}
+
+ <ul>
+ {#each inlinePlatformItems as platformSelector, i (platformSelector.action.title)}
+ {@const { action, isActive } = platformSelector}
+ {@const artwork = action.artwork}
+ {@const totalNumberOfItems = inlinePlatformItems.length}
+ <li
+ in:flyAndBlur={{
+ y: -50,
+ delay: i * BASE_DELAY,
+ duration: getEasedDuration({
+ i,
+ totalNumberOfItems,
+ }),
+ }}
+ out:flyAndBlur={{
+ y: i * -5,
+ delay:
+ // This delay is calculated in a negative/backwards manner,
+ // which makes it so the items build out from the bottom to the top.
+ (totalNumberOfItems - i - 1) * (BASE_DELAY / 2),
+ duration: BASE_DURATION,
+ }}
+ >
+ <FlowAction destination={action}>
+ <span class="platform" class:is-active={isActive}>
+ {#if isSome(artwork) && isSystemImageArtwork(artwork)}
+ <div
+ class="icon-container"
+ aria-hidden="true"
+ >
+ <SystemImage {artwork} />
+ </div>
+ {/if}
+
+ <span class="platform-title">
+ {action.title}
+ </span>
+
+ {#if action.destination && isSearchResultsPageIntent(action.destination)}
+ <span
+ aria-hidden={true}
+ class="search-icon-container"
+ >
+ <SFSymbol name="magnifyingglass" />
+ </span>
+ {/if}
+ </span>
+ </FlowAction>
+ </li>
+ {/each}
+ </ul>
+ </div>
+ </Navigation>
+</div>
+
+<style lang="scss">
+ .navigation-wrapper {
+ display: contents;
+ }
+
+ .platform-selector-container {
+ --header-gap: 3px;
+ --platform-selector-trigger-gap: var(--header-gap);
+ display: flex;
+ gap: var(--header-gap);
+ position: relative;
+
+ @media (--sidebar-visible) {
+ padding: 19px 25px 14px;
+ }
+ }
+
+ // Japanese and Catalonian both require scaling down the platform selector in order to make it
+ // fit cleanly in the sidebar, due to their longer character lengths.
+ .platform-selector-container:lang(ja),
+ .platform-selector-container:lang(ca) {
+ --scale-factor: 0.1;
+ z-index: 3;
+ transform: scale(calc(1 - var(--scale-factor)));
+ transform-origin: center left;
+
+ & :global(dialog) {
+ top: 60px;
+ // Since the `dialog` is a child of `platform-selector-container, we re-scale it back
+ // to it's original size by applying the inverse scale transformation.
+ transform: scale(calc(1 + var(--scale-factor)));
+ transform-origin: center left;
+ }
+ }
+
+ .app-store-icon-container {
+ display: flex;
+ align-items: center;
+ gap: var(--header-gap);
+ font: var(--title-1);
+ font-weight: 600;
+ }
+
+ .app-store-icon-container :global(svg) {
+ height: 18px;
+ position: relative;
+ top: 0.33px;
+ width: auto;
+
+ @media (--sidebar-visible) and (--range-xsmall-only) {
+ height: 22px;
+ width: auto;
+ }
+ }
+
+ .search-input-container {
+ margin: 0 25px;
+ }
+
+ .navigation-wrapper :global(.navigation__header) {
+ @media (--sidebar-visible) {
+ display: flex;
+ flex-direction: column;
+ }
+ }
+
+ .navigation-wrapper :global(.navigation-item__link) {
+ height: 100%;
+ display: flex;
+ }
+
+ .navigation-wrapper :global(.navigation-item__icon) {
+ --navigation-item-icon-size: 32px;
+ width: var(--navigation-item-icon-size);
+ height: var(--navigation-item-icon-size);
+ display: flex;
+ justify-content: center;
+
+ @media (--sidebar-visible) {
+ --navigation-item-icon-size: 24px;
+ }
+ }
+
+ // Our SVG icons for the landing pages are sized differently than other Onyx apps,
+ // so we have to reach into the navigation component and style them so they look
+ // visually similar to the other Onyx apps
+ .navigation-wrapper :global(.navigation-item__icon svg) {
+ color: var(--keyColor);
+ width: 20px;
+
+ @media (--sidebar-visible) {
+ width: 18px;
+ }
+ }
+
+ // Below is styling for the "inline" version of the Platform Selector
+ .platform-selector-inline {
+ margin: 8px 32px;
+ }
+
+ ul {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ }
+
+ h3 {
+ color: var(--systemTertiary);
+ font: var(--body-emphasized);
+ margin: 0 0 10px;
+ padding-top: 20px;
+
+ @media (--sidebar-visible) {
+ font: var(--footnote-emphasized);
+ margin: 0 0 6px;
+ padding-top: 7px;
+ }
+ }
+
+ .platform {
+ display: flex;
+ gap: 10px;
+ padding: 8px 0;
+ color: var(--systemTertiary);
+
+ @media (prefers-color-scheme: dark) {
+ color: var(--systemSecondary);
+ }
+ }
+
+ .platform,
+ .platform :global(svg) {
+ transition: color 210ms ease-out;
+ }
+
+ .platform:not(.is-active):hover,
+ .platform:not(.is-active):hover :global(svg) {
+ color: var(--systemPrimary);
+ }
+
+ .platform.is-active {
+ color: var(--systemPrimary);
+ font: var(--body-emphasized);
+ }
+
+ .platform.is-active :global(svg) {
+ color: currentColor;
+ }
+
+ .icon-container {
+ display: flex;
+ }
+
+ .icon-container :global(svg) {
+ color: var(--systemTertiary);
+ width: 18px;
+ max-height: 16px;
+
+ @media (prefers-color-scheme: dark) {
+ color: var(--systemSecondary);
+ }
+ }
+
+ .search-icon-container {
+ display: flex;
+ }
+
+ .search-icon-container :global(svg) {
+ fill: var(--systemSecondary);
+ width: 16px;
+ }
+
+ .platform-title {
+ font: var(--body);
+ flex-grow: 1;
+ }
+</style>
diff --git a/src/components/navigation/SearchInput.svelte b/src/components/navigation/SearchInput.svelte
new file mode 100644
index 0000000..a04fa4b
--- /dev/null
+++ b/src/components/navigation/SearchInput.svelte
@@ -0,0 +1,82 @@
+<script lang="ts" context="module">
+ import type { ComponentProps } from 'svelte';
+ import { writable } from 'svelte/store';
+
+ import SearchInput from '@amp/web-app-components/src/components/SearchInput/SearchInput.svelte';
+
+ type UnusedSearchInputProps = Pick<
+ ComponentProps<SearchInput>,
+ 'currentTab' | 'menuItem'
+ >;
+
+ // `SearchInput` requires a bunch of properties that are unnecessary
+ // for our use-case; they're defined here to keep them grouped together
+ const UNNEEDED_SEARCH_INPUT_PROPS: UnusedSearchInputProps = {
+ currentTab: writable(null),
+ menuItem: {
+ id: { type: 'search' },
+ // @ts-expect-error the `menuItem` is not relevant to us; we don't
+ // need to provide an icon for this
+ icon: null,
+ },
+ };
+</script>
+
+<script lang="ts">
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+ import { makeCanonicalSearchResultsPageUrl } from '@jet-app/app-store/common/search/search-page-url';
+
+ import { getJet } from '~/jet';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+ const jet = getJet();
+
+ export let searchAction: WebSearchFlowAction;
+ export let big: boolean = false;
+
+ function dispatchSearchAction(event: CustomEvent<{ term: string }>) {
+ const { term } = event.detail;
+
+ searchAction.destination.term = term;
+
+ searchAction.pageUrl = makeCanonicalSearchResultsPageUrl(
+ jet.objectGraph,
+ searchAction.destination,
+ );
+
+ jet.perform(searchAction);
+ }
+</script>
+
+<div class="search-input-wrapper" class:big>
+ <SearchInput
+ {...UNNEEDED_SEARCH_INPUT_PROPS}
+ defaultValue={searchAction?.destination?.term}
+ translateFn={(key) => $i18n.t(key)}
+ on:makeSearchQueryFromInput={dispatchSearchAction}
+ />
+</div>
+
+<style>
+ .search-input-wrapper {
+ --searchBoxIconFill: var(--keyColor);
+ position: relative;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ }
+
+ .search-input-wrapper.big :global(.search-input__text-field) {
+ height: 48px;
+ padding-inline-start: 40px;
+ font: var(--title-2);
+ border-radius: 8px;
+ }
+
+ .search-input-wrapper.big :global(.search-svg) {
+ width: 16px;
+ height: auto;
+ inset: 16px 0 0 13px;
+ }
+</style>
diff --git a/src/components/navigation/Skeleton.svelte b/src/components/navigation/Skeleton.svelte
new file mode 100644
index 0000000..508e523
--- /dev/null
+++ b/src/components/navigation/Skeleton.svelte
@@ -0,0 +1,85 @@
+<script lang="ts">
+ import { writable } from 'svelte/store';
+
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+
+ import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte';
+ import AppStoreLogo from '~/components/icons/AppStoreLogo.svg';
+ import SearchInput from '~/components/navigation/SearchInput.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ const i18n = getI18n();
+
+ $: searchAction = {} as WebSearchFlowAction;
+</script>
+
+<div class="navigation-wrapper">
+ <Navigation
+ translateFn={$i18n.t}
+ items={[]}
+ currentTab={writable(null)}
+ libraryItems={[]}
+ personalizedItems={[]}
+ >
+ <div slot="logo" class="platform-selector-container">
+ <span class="app-store-icon-container">
+ <AppStoreLogo />
+ </span>
+ </div>
+
+ <svelte:fragment slot="search">
+ <div class="search-input-container">
+ <SearchInput {searchAction} />
+ </div>
+ </svelte:fragment>
+ </Navigation>
+</div>
+
+<style lang="scss">
+ .navigation-wrapper {
+ display: contents;
+ }
+
+ .platform-selector-container {
+ @media (--sidebar-visible) {
+ padding: 19px 25px 14px;
+ }
+ }
+
+ .app-store-icon-container {
+ display: flex;
+ align-items: center;
+ padding: 2px 0;
+ }
+
+ .app-store-icon-container :global(svg) {
+ height: 18px;
+ position: relative;
+ top: 0.33px;
+ width: auto;
+
+ @media (--sidebar-visible) and (--range-xsmall-only) {
+ height: 22px;
+ width: auto;
+ }
+ }
+
+ .search-input-container {
+ margin: 0 25px;
+ }
+
+ .navigation-wrapper :global(.navigation-item__link) {
+ height: 100%;
+ display: flex;
+ }
+
+ .navigation-wrapper :global(.navigation-item__icon) {
+ --navigation-item-icon-size: 32px;
+ width: var(--navigation-item-icon-size);
+ height: var(--navigation-item-icon-size);
+
+ @media (--sidebar-visible) {
+ --navigation-item-icon-size: 24px;
+ }
+ }
+</style>
diff --git a/src/components/navigation/navigation-items.ts b/src/components/navigation/navigation-items.ts
new file mode 100644
index 0000000..8692765
--- /dev/null
+++ b/src/components/navigation/navigation-items.ts
@@ -0,0 +1,79 @@
+import {
+ isSome,
+ unwrapOptional as unwrap,
+} from '@jet/environment/types/optional';
+
+import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types';
+import type { NavigationId } from '@amp/web-app-components/src/types';
+import type {
+ WebNavigation,
+ WebNavigationLink,
+} from '@jet-app/app-store/api/models/web-navigation';
+
+import {
+ isSystemImageArtwork,
+ getIconNameFromTemplate,
+} from '~/components/SystemImage.svelte';
+import { getIconComponentByName } from '../SFSymbol.svelte';
+import type { Artwork } from '@jet-app/app-store/api/models';
+import CategoryTabItem from '~/components/jet/web-navigation/CategoryTabItem.svelte';
+
+/**
+ * A {@linkcode NavigationItem} that includes the original {@linkcode WebNavigationLink}
+ * it was defined from, which is needed for the
+ */
+export interface NavigationItemWithTab extends NavigationItem {
+ tab: WebNavigationLink;
+ artwork?: Artwork;
+ isActive?: boolean;
+}
+
+export function navigationIdFromLink(link: WebNavigationLink): NavigationId {
+ const intent = unwrap(link.action.destination);
+
+ return {
+ type: intent.$kind,
+ // `intent.$kind` will be unique for each `Intent` used here as they are
+ // each a different `LandingPageIntent`
+ resourceId: link.action.pageUrl ?? intent.$kind,
+ };
+}
+
+/**
+ * Transform the "tabs" in the `WebNavigation` into a shape that works with our
+ * shared navigation side-bar components.
+ */
+export function makeNavLinks(
+ tabs: WebNavigationLink[],
+ { shouldShowSearchTab = false },
+): NavigationItemWithTab[] {
+ return tabs
+ .filter((tab) => {
+ const isSearchTab =
+ tab.action?.destination?.['$kind'].includes('search_Intent');
+
+ // Allows for filtering our the search tab, which we use when the sidebar is visible,
+ // since there is a search input in the sidebar
+ return isSearchTab ? shouldShowSearchTab : true;
+ })
+ .map((tab) => {
+ const { action, artwork: tabArtwork } = tab;
+ const { artwork } = action || {};
+ const hasSystemImageArtwork =
+ isSome(artwork) && isSystemImageArtwork(artwork);
+
+ return {
+ id: navigationIdFromLink(tab),
+ label: unwrap(tab.action.title),
+ url: action.pageUrl ?? undefined,
+ icon: hasSystemImageArtwork
+ ? getIconComponentByName(
+ getIconNameFromTemplate(artwork.template),
+ )
+ : undefined,
+ artwork: tabArtwork,
+ component: !hasSystemImageArtwork ? CategoryTabItem : undefined,
+ tab,
+ };
+ });
+}
diff --git a/src/components/pages/AppEventDetailPage.svelte b/src/components/pages/AppEventDetailPage.svelte
new file mode 100644
index 0000000..a2b798e
--- /dev/null
+++ b/src/components/pages/AppEventDetailPage.svelte
@@ -0,0 +1,44 @@
+<script lang="ts" context="module">
+ import type { DefaultPageRequirements } from './DefaultPage.svelte';
+</script>
+
+<script lang="ts">
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+
+ export let page: DefaultPageRequirements;
+</script>
+
+<div class="app-event-detail-page-container">
+ <div class="shelf-container">
+ {#each page.shelves as shelf}
+ <ShelfComponent {shelf} />
+ {/each}
+ </div>
+</div>
+
+<style>
+ .app-event-detail-page-container {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ padding: 0 var(--bodyGutter);
+ }
+
+ .shelf-container {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto;
+
+ @media (--range-small-up) {
+ justify-content: center;
+ }
+ }
+
+ .shelf-container :global(.shelf) {
+ margin: 0;
+ padding: var(--bodyGutter) 0 0;
+ }
+</style>
diff --git a/src/components/pages/ArticlePage.svelte b/src/components/pages/ArticlePage.svelte
new file mode 100644
index 0000000..32cacb0
--- /dev/null
+++ b/src/components/pages/ArticlePage.svelte
@@ -0,0 +1,141 @@
+<script lang="ts" context="module">
+ import type { ArticlePage } from '@jet-app/app-store/api/models';
+
+ import type { DefaultPageRequirements } from './DefaultPage.svelte';
+
+ /**
+ * Just the `Page` props that we actually need to render this component
+ */
+ export type ArticlePageRequirements = DefaultPageRequirements & {
+ card: ArticlePage['card'];
+ footerLockup: ArticlePage['footerLockup'];
+ };
+</script>
+
+<script lang="ts">
+ import TodayCard from '~/components/jet/today-card/TodayCard.svelte';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import FooterLockupItem from '~/components/jet/item/FooterLockupItem.svelte';
+ export let page: ArticlePageRequirements;
+
+ $: ({ card } = page);
+</script>
+
+<div class="article-page-container" data-testid="article-page-container">
+ <div class="article-layout">
+ {#if card}
+ <div class="card-container">
+ <TodayCard {card} suppressClickAction />
+ </div>
+ {/if}
+
+ <div class="story-container">
+ {#each page.shelves as shelf}
+ {#if !shelf.isHidden}
+ <ShelfComponent {shelf} />
+ {/if}
+ {/each}
+
+ {#if page.footerLockup}
+ <div class="footer-lockup-container">
+ <FooterLockupItem item={page.footerLockup} />
+ </div>
+ {/if}
+ </div>
+ </div>
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .article-page-container {
+ flex-grow: 1;
+ width: 100%;
+ margin: 0 auto;
+ }
+
+ .article-layout {
+ --article-page-padding: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--article-page-padding);
+ max-width: 1600px;
+ margin: 0 auto;
+
+ @media (--range-small-up) {
+ padding: 2em var(--bodyGutter);
+ }
+
+ @media (--range-small-only) {
+ --article-page-padding: 40px;
+ }
+
+ @media (--range-medium-up) {
+ align-items: flex-start;
+ flex-direction: row;
+ }
+
+ @media (--range-medium-only) {
+ --article-page-padding: 20px;
+ }
+
+ @media (--range-large-up) {
+ --article-page-padding: 40px;
+ }
+ }
+
+ .card-container {
+ flex-shrink: 0;
+ aspect-ratio: 3/4;
+ width: 100%;
+
+ @media (--range-xsmall-only) {
+ --border-radius: 0;
+ }
+
+ @media (--range-small-only) {
+ aspect-ratio: 16/9;
+ }
+
+ @media (--range-small-up) {
+ width: 100%;
+ }
+
+ @media (--range-medium-up) {
+ position: sticky;
+ top: 2em;
+ aspect-ratio: 3 / 4;
+ height: min(calc(100vh - 80px), calc(33vw * 4 / 3));
+ min-height: 420px;
+ max-height: 700px;
+ width: auto;
+ }
+ }
+
+ .story-container {
+ width: 100%;
+ margin-top: 20px;
+ padding-bottom: var(--bodyGutter);
+
+ @media (--range-small-up) {
+ width: calc(100%);
+ margin-top: 0;
+ }
+
+ @media (--range-medium-up) {
+ min-width: calc(50% - calc(var(--article-page-padding)));
+ }
+ }
+
+ .story-container :global(.shelf:first-of-type) {
+ padding-top: 0;
+ padding-bottom: 13px;
+ }
+
+ .footer-lockup-container {
+ margin: var(--bodyGutter);
+ }
+</style>
diff --git a/src/components/pages/ChartsHubPage.svelte b/src/components/pages/ChartsHubPage.svelte
new file mode 100644
index 0000000..a75cb64
--- /dev/null
+++ b/src/components/pages/ChartsHubPage.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ import type { ChartsHubPage } from '@jet-app/app-store/api/models';
+
+ import TopChartsPage from './TopChartsPage.svelte';
+
+ export let page: ChartsHubPage;
+</script>
+
+{#each page.charts as chart}
+ <TopChartsPage page={chart} />
+{/each}
diff --git a/src/components/pages/DefaultPage.svelte b/src/components/pages/DefaultPage.svelte
new file mode 100644
index 0000000..7905b07
--- /dev/null
+++ b/src/components/pages/DefaultPage.svelte
@@ -0,0 +1,173 @@
+<script lang="ts" context="module">
+ import type {
+ PagePresentationOptions,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ /**
+ * Just the `Page` props that we actually need to render this component
+ */
+ export interface DefaultPageRequirements extends WebRenderablePage {
+ shelves: Shelf[];
+ presentationOptions?: PagePresentationOptions;
+ }
+</script>
+
+<script lang="ts">
+ import type { MarkerShelf } from '~/components/jet/shelf/MarkerShelf.svelte';
+ import { isUberShelf } from '~/components/jet/shelf/UberShelf.svelte';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import { partition } from '~/utils/array';
+ import { carouselMediaStyle } from '~/stores/carousel-media-style';
+ import mediaQueries from '~/utils/media-queries';
+ import { isHeroCarouselShelf } from '../jet/shelf/HeroCarouselShelf.svelte';
+ import { isRtl } from '~/utils/locale';
+
+ interface $$Slots {
+ 'before-shelves': {};
+
+ /**
+ * If {@linkcode ShelfComponent}` recognizes a shelf to be a {@linkcode MarkerShelf},
+ * this slot will be rendered so that the "page" data can be supplied by a "parent"
+ * component
+ */
+ 'marker-shelf': {
+ shelf: MarkerShelf;
+ };
+ }
+
+ export let page: DefaultPageRequirements;
+
+ $: ({ title, presentationOptions = [] } = page);
+
+ // Some shelves are meant to be rendered above the title, rather than below it
+ $: [aboveTitleShelves, belowTitleShelves] = partition(
+ page.shelves,
+ (shelf) => {
+ // Some "uber" shelves might be placed above the title
+ if (isUberShelf(shelf)) {
+ const [uber] = shelf.items;
+ return uber.style === 'above';
+ }
+
+ // Everything else should be below it
+ return false;
+ },
+ );
+
+ $: prefersHiddenPageTitle = presentationOptions.includes(
+ 'prefersHiddenPageTitle',
+ );
+ $: prefersLargeTitle = presentationOptions.includes('prefersLargeTitle');
+ $: prefersOverlayedPageHeader =
+ $mediaQueries === 'xsmall' &&
+ presentationOptions.includes('prefersOverlayedPageHeader');
+ $: isOnDarkBackground =
+ prefersOverlayedPageHeader && $carouselMediaStyle === 'dark';
+
+ $: isTitleDuplicatedInHero = (() => {
+ const firstShelf = page.shelves?.[0];
+
+ if (
+ !firstShelf ||
+ !isHeroCarouselShelf(firstShelf) ||
+ firstShelf.items?.length !== 1
+ ) {
+ return false;
+ }
+
+ const { items: ltrItems, rtlItems } = firstShelf.items?.[0] ?? {};
+ const firstItem = isRtl() && rtlItems?.length ? rtlItems : ltrItems;
+ const firstTitle = firstItem?.[0]?.overlay?.titleText;
+
+ return title === firstTitle;
+ })();
+</script>
+
+<div
+ class="default-page-container"
+ data-testid="default-page-container"
+ class:with-overlaid-title={prefersOverlayedPageHeader}
+ class:with-title-in-hero={isTitleDuplicatedInHero}
+>
+ {#each aboveTitleShelves as shelf}
+ <ShelfComponent {shelf}>
+ <slot name="marker-shelf" slot="marker-shelf" let:shelf {shelf} />
+ </ShelfComponent>
+ {/each}
+
+ {#if title && !prefersHiddenPageTitle && !isTitleDuplicatedInHero}
+ <h1
+ data-test-id="page-title"
+ class:large-title={prefersLargeTitle}
+ class:overlaid={prefersOverlayedPageHeader}
+ class:on-dark-background={isOnDarkBackground}
+ >
+ {title}
+ </h1>
+ {/if}
+
+ <slot name="before-shelves" />
+
+ {#each belowTitleShelves as shelf}
+ {#if !shelf.isHidden}
+ <ShelfComponent {shelf}>
+ <slot
+ name="marker-shelf"
+ slot="marker-shelf"
+ let:shelf
+ {shelf}
+ />
+ </ShelfComponent>
+ {/if}
+ {/each}
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .default-page-container {
+ flex-grow: 1;
+ width: 100%;
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ }
+
+ .default-page-container.with-overlaid-title {
+ margin-top: -13px;
+ }
+
+ .default-page-container.with-title-in-hero {
+ @media (--range-small-up) {
+ margin-top: 10px;
+ }
+ }
+
+ h1 {
+ padding: 11px var(--bodyGutter);
+ font: var(--large-title-emphasized);
+ letter-spacing: -0.5px;
+ word-wrap: break-word;
+ color: var(--systemPrimary, #000);
+ position: relative;
+ z-index: 1;
+ transition: color 210ms ease-in;
+ }
+
+ h1.large-title {
+ font: var(--large-title-emphasized-tall);
+ }
+
+ h1.overlaid {
+ position: absolute;
+ z-index: 3;
+ padding: var(--bodyGutter) var(--bodyGutter) 0;
+ color: var(--systemPrimary-onLight, #000);
+ }
+
+ h1.on-dark-background {
+ color: var(--systemPrimary-onDark);
+ }
+</style>
diff --git a/src/components/pages/ErrorPage.svelte b/src/components/pages/ErrorPage.svelte
new file mode 100644
index 0000000..5756d78
--- /dev/null
+++ b/src/components/pages/ErrorPage.svelte
@@ -0,0 +1,23 @@
+<script lang="ts" context="module">
+ import type { ErrorPage } from '~/jet/models';
+</script>
+
+<script lang="ts">
+ import SharedErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: ErrorPage;
+
+ const i18n = getI18n();
+</script>
+
+<div class="error-page-container">
+ <SharedErrorPage translateFn={$i18n.t} error={page.error} />
+</div>
+
+<style>
+ .error-page-container :global(.page-error) {
+ /* -50px compensates for the global footer */
+ top: calc(50% - 50px);
+ }
+</style>
diff --git a/src/components/pages/ProductPage.svelte b/src/components/pages/ProductPage.svelte
new file mode 100644
index 0000000..30b0ad8
--- /dev/null
+++ b/src/components/pages/ProductPage.svelte
@@ -0,0 +1,77 @@
+<script lang="ts">
+ import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models';
+ import { isFlowAction } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import DefaultPage, {
+ type DefaultPageRequirements,
+ } from '~/components/pages/DefaultPage.svelte';
+ import MarkerShelf from '~/components/jet/shelf/MarkerShelf.svelte';
+ import ProductPageArcadeFooter from '~/components/ProductPageArcadeFooter.svelte';
+ import { getProductPageShelvesWithExpandedMedia } from '~/utils/shelves';
+ import { setAccessibilityLayoutContext } from '~/context/accessibility-layout';
+ import { getJet } from '~/jet';
+ import { isProductPageLinkShelf } from '~/components/jet/shelf/ProductPageLinkShelf.svelte';
+ import { isEulaPageIntent } from '@jet-app/app-store/api/intents/eula-page-intent';
+ export let page: ShelfBasedProductPage & WebRenderablePage;
+
+ const jet = getJet();
+
+ $: ({ presentationOptions, webNavigation } = page);
+
+ $: shelves = getProductPageShelvesWithExpandedMedia(page);
+
+ let defaultPageRequirements: DefaultPageRequirements;
+
+ $: defaultPageRequirements = {
+ shelves,
+ presentationOptions,
+ webNavigation,
+ };
+
+ // Set up accessibility layout context for neighbor shelf detection
+ $: {
+ setAccessibilityLayoutContext({ shelves });
+
+ /**
+ * We suppport "deep linking" to the product page with the License Agreement modal open by
+ * default, based on the presence of the `lic` query parameter. No other modals support
+ * opening via deep link, which is why there isn't a more robust solution for this use case.
+ * Instead, we are just firing off the click action from the license agreement shelf.
+ */
+ if (page.canonicalURL) {
+ const canonicalUrl = new URL(page.canonicalURL);
+ const hasLic = canonicalUrl.searchParams.has('lic');
+
+ if (hasLic && shelves) {
+ const eulaItem = shelves
+ .find(isProductPageLinkShelf)
+ ?.items.find(
+ ({ clickAction }) =>
+ isFlowAction(clickAction) &&
+ clickAction.destination &&
+ isEulaPageIntent(clickAction.destination),
+ );
+
+ if (eulaItem) {
+ jet.perform(eulaItem.clickAction);
+ }
+ }
+ }
+ }
+
+ // TODO: replace with `supportsArcade` from Jet
+ // rdar://143706610 (Support `supportsArcade` attribute)
+ $: supportsArcade =
+ page.lockup.offerDisplayProperties?.offerType === 'arcadeApp';
+</script>
+
+<DefaultPage page={defaultPageRequirements}>
+ <svelte:fragment slot="marker-shelf" let:shelf>
+ <MarkerShelf {shelf} {page} />
+ </svelte:fragment>
+</DefaultPage>
+
+{#if supportsArcade}
+ <ProductPageArcadeFooter />
+{/if}
diff --git a/src/components/pages/SearchLandingPage.svelte b/src/components/pages/SearchLandingPage.svelte
new file mode 100644
index 0000000..3594ece
--- /dev/null
+++ b/src/components/pages/SearchLandingPage.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+ import type { SearchLandingPage } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+ import { unwrapOptional as unwrap } from '@jet/environment/types/optional';
+
+ type SearchPage = SearchLandingPage;
+
+ import DefaultPage from './DefaultPage.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SearchInput from '~/components/navigation/SearchInput.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: SearchPage;
+
+ const i18n = getI18n();
+
+ $: webNavigation = unwrap((page as WebRenderablePage).webNavigation);
+ $: searchAction = webNavigation.searchAction as WebSearchFlowAction;
+ $: hasShelves = !!page.shelves.filter(({ items }) => items?.length).length;
+
+ $: pageWithoutEmptyShelves = {
+ ...page,
+ shelves: hasShelves ? page.shelves : [],
+ title: $i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'),
+ };
+</script>
+
+<DefaultPage page={pageWithoutEmptyShelves}>
+ <ShelfWrapper slot="before-shelves" centered>
+ <SearchInput {searchAction} big />
+ </ShelfWrapper>
+</DefaultPage>
diff --git a/src/components/pages/SearchResultsPage.svelte b/src/components/pages/SearchResultsPage.svelte
new file mode 100644
index 0000000..c17b644
--- /dev/null
+++ b/src/components/pages/SearchResultsPage.svelte
@@ -0,0 +1,113 @@
+<script lang="ts">
+ import type { SearchResultsPage } from '@jet-app/app-store/api/models';
+
+ import type { Size } from '@amp/web-app-components/src/types';
+ import { ShelfConfig } from '@amp/web-app-components/config/components/shelf';
+
+ import DefaultPage from './DefaultPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import mediaQueries from '~/utils/media-queries';
+ import {
+ isSearchResultShelf,
+ isRenderableInSearchResultsShelf,
+ } from '~/components/jet/shelf/SearchResultShelf.svelte';
+ import { getPlatformFromPage } from '~/utils/seo/common';
+
+ export let page: SearchResultsPage;
+
+ const i18n = getI18n();
+
+ $: resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null;
+
+ $: renderableItems = (resultsShelf?.items ?? []).filter(
+ isRenderableInSearchResultsShelf,
+ );
+
+ $: columnConfig = ShelfConfig.get().GRID_VALUES.SearchResult;
+ $: numberOfColumns = columnConfig[$mediaQueries as Size] || 3;
+ $: numberOfRows = Math.ceil(renderableItems.length / numberOfColumns);
+ $: middleRow = Math.floor(numberOfRows / 2);
+ $: insertAt = middleRow * numberOfColumns;
+
+ /**
+ * This is unfortunate but only these three platforms support the transparency link.
+ * This link is enabled via the `transparencyLawEditorialItemId` bag key, but when defining
+ * bag keys, we do not have access to the platform being viewed, so we can't opt-out there.
+ * We could do this platform check in the Jet layer, but adding two forms of opting into this
+ * link felt cumbersome and unintuitive, so we can just do it here.
+ */
+ $: transparencyLink =
+ page.transparencyLink &&
+ ['iphone', 'ipad', 'mac'].includes(
+ getPlatformFromPage(page).toLowerCase(),
+ );
+
+ /**
+ * Here we are building constructing a new array of shelves _if_ there is a result shelf _and_
+ * a transparency link. This creates three shelves:
+ * 1) the search results before the transparency banner in the linkable text shelf
+ * 2) the transparency banner
+ * 3) the search results after the transparency banner
+ */
+ $: shelves = resultsShelf
+ ? transparencyLink && renderableItems.length
+ ? [
+ insertAt > 0 && {
+ ...resultsShelf,
+ items: renderableItems.slice(0, insertAt),
+ title: null,
+ isValid: () => true,
+ },
+ {
+ contentType: 'linkableText',
+ items: [page.transparencyLink],
+ },
+ {
+ ...resultsShelf,
+ items: renderableItems.slice(insertAt),
+ title: null,
+ isValid: () => true,
+ },
+ ]
+ : [{ ...resultsShelf, items: renderableItems, title: null }]
+ : [];
+</script>
+
+<DefaultPage
+ page={{
+ shelves,
+ title: renderableItems.length > 0 ? resultsShelf?.title : null,
+ }}
+>
+ <svelte:fragment slot="before-shelves">
+ {#if renderableItems.length === 0}
+ <div>
+ <h1>
+ {$i18n.t('ASE.Web.AppStore.Search.NoResults.FirstLine')}
+ </h1>
+ <p>
+ {$i18n.t('ASE.Web.AppStore.Search.NoResults.SecondLine', {
+ term: page.searchTermContext?.term,
+ })}
+ </p>
+ </div>
+ {/if}
+ </svelte:fragment>
+</DefaultPage>
+
+<style>
+ div {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 3px;
+ height: 70vh;
+ margin: var(--bodyGutter);
+ }
+
+ p {
+ font: var(--title-3);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/pages/SeeAllPage.svelte b/src/components/pages/SeeAllPage.svelte
new file mode 100644
index 0000000..d401f32
--- /dev/null
+++ b/src/components/pages/SeeAllPage.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ import type { SeeAllPage } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import { getProductPageShelvesForOrdering } from '~/utils/shelves';
+ import { setAccessibilityLayoutContext } from '~/context/accessibility-layout';
+ import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte';
+ import { isProductReviewShelf } from '~/components/jet/shelf/ProductReviewShelf.svelte';
+ import { isProductRatingsShelf } from '~/components/jet/shelf/ProductRatingsShelf.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let page: SeeAllPage & WebRenderablePage;
+
+ $: shelves = getProductPageShelvesForOrdering(page, 'notPurchasedOrdering')
+ .filter((shelf) => {
+ const isShelfForReviewPage =
+ isProductReviewShelf(shelf) || isProductRatingsShelf(shelf);
+
+ return (
+ isSmallLockupShelf(shelf) ||
+ (isShelfForReviewPage && page.seeAllType === 'reviews')
+ );
+ })
+ .map((shelf) => {
+ shelf.isHorizontal = false;
+ shelf.seeAllAction = null;
+ return shelf;
+ });
+
+ $: {
+ setAccessibilityLayoutContext({ shelves });
+ }
+</script>
+
+<DefaultPage page={{ shelves, title: null }}>
+ <svelte:fragment slot="before-shelves">
+ <h1>
+ <LinkWrapper action={page.lockup.clickAction}>
+ {page.lockup.title}
+ </LinkWrapper>
+ </h1>
+ </svelte:fragment>
+</DefaultPage>
+
+<style>
+ h1 {
+ font: var(--title-1);
+ color: var(--keyColor);
+ margin: 13px var(--bodyGutter) 0;
+ }
+
+ h1 :global(a:hover) {
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/components/pages/StaticMessagePage.svelte b/src/components/pages/StaticMessagePage.svelte
new file mode 100644
index 0000000..45c1a36
--- /dev/null
+++ b/src/components/pages/StaticMessagePage.svelte
@@ -0,0 +1,113 @@
+<script lang="ts" context="module">
+ import type { StaticMessagePage } from '~/jet/models';
+</script>
+
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: StaticMessagePage;
+
+ const i18n = getI18n();
+</script>
+
+<div class="static-message-page-container">
+ <div class="static-message-text-wrapper">
+ {#if page.titleLocKey}
+ <h1>{$i18n.t(page.titleLocKey)}</h1>
+ {/if}
+
+ <section>
+ {#if page.contentType === 'win-back' || page.contentType === 'contingent-price'}
+ <p>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Subhead')}
+ </p>
+
+ <p>
+ <b>
+ {$i18n.t('ASE.Web.AppStore.WinBack.DirectionalTitle')}
+ </b>
+ </p>
+
+ <ul>
+ <li>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Update.iOS')}
+ </li>
+ <li>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Update.macOS')}
+ </li>
+ </ul>
+
+ <p>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Body')}
+ </p>
+ {:else if page.contentType === 'carrier'}
+ <p class="carrier__instructions">
+ {$i18n.t('ASE.Web.AppStore.Carrier.Update.iOS')}
+ </p>
+ <p>
+ {$i18n.t('ASE.Web.AppStore.Carrier.Body')}
+ </p>
+ {:else if page.contentType === 'invoice'}
+ <p class="invoice__instructions">
+ {$i18n.t('ASE.Web.AppStore.Invoice.Body')}
+ </p>
+ {/if}
+ </section>
+ </div>
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .static-message-page-container {
+ display: flex;
+ flex-grow: 1;
+ width: 100%;
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ align-items: center;
+ }
+
+ @media (--range-sidebar-visible-up) {
+ .static-message-page-container {
+ height: 100%;
+ }
+ }
+
+ .static-message-text-wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-start;
+ width: auto;
+ margin: 0 auto;
+ }
+
+ .static-message-page-container h1 {
+ padding: 13px var(--bodyGutter) 0;
+ font: var(--header-emphasized);
+ color: var(--systemPrimary, #000);
+ position: relative;
+ z-index: 1;
+ margin-bottom: 16px;
+ }
+
+ .static-message-page-container section {
+ margin: 0 var(--bodyGutter);
+ font: var(--title-3);
+ }
+
+ .static-message-page-container li {
+ list-style-type: disc;
+ }
+
+ .static-message-page-container p,
+ .static-message-page-container ul {
+ margin-bottom: 16px;
+ text-wrap: pretty;
+ }
+
+ .static-message-page-container ul {
+ padding-inline-start: 1em;
+ }
+</style>
diff --git a/src/components/pages/TodayPage.svelte b/src/components/pages/TodayPage.svelte
new file mode 100644
index 0000000..3d38932
--- /dev/null
+++ b/src/components/pages/TodayPage.svelte
@@ -0,0 +1,22 @@
+<!--
+@component
+Page component for the "Today Page"
+
+This is required so that the correct layout of the cards within each `TodayCardShelf`
+can be computed at the page level, as the algorithm for stretching the correct cards
+in each shelf requires knowledge of the previously-rendered shelf
+-->
+<script lang="ts">
+ import type { TodayPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import { setTodayCardLayoutContext } from '~/context/today-card-layout';
+
+ export let page: TodayPage;
+
+ $: {
+ setTodayCardLayoutContext(page);
+ }
+</script>
+
+<DefaultPage {page} />
diff --git a/src/components/pages/TopChartsPage.svelte b/src/components/pages/TopChartsPage.svelte
new file mode 100644
index 0000000..4a3e7b7
--- /dev/null
+++ b/src/components/pages/TopChartsPage.svelte
@@ -0,0 +1,218 @@
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+ import { isSome } from '@jet/environment/types/optional';
+ import type { TopChartsPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import Shelf from '~/components/Shelf/Wrapper.svelte';
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import Menu from '~/components/Menu.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+
+ export let page: TopChartsPage;
+
+ const i18n = getI18n();
+
+ $: ({ categories, categoriesButtonTitle, segments, initialSegmentIndex } =
+ page);
+ $: segment = segments[initialSegmentIndex];
+</script>
+
+<DefaultPage page={{ shelves: segment.shelves, title: page.title }}>
+ <Shelf slot="before-shelves" centered>
+ <header>
+ <div class="dropdown-container">
+ {#if categoriesButtonTitle}
+ <Menu options={categories}>
+ <svelte:fragment slot="trigger">
+ <span class="menu-trigger-contents">
+ {categoriesButtonTitle}
+
+ <SFSymbol name="chevron.down" />
+ </span>
+ </svelte:fragment>
+
+ <svelte:fragment slot="option" let:option>
+ {@const { artwork, chartSelectAction, name } =
+ option}
+
+ <LinkWrapper action={chartSelectAction}>
+ <div
+ class="category-menu-item"
+ class:active={name ===
+ categoriesButtonTitle}
+ >
+ {#if isSome(artwork)}
+ <div class="artwork-container">
+ <Artwork
+ {artwork}
+ profile={getNaturalProfile(
+ artwork,
+ [24],
+ )}
+ />
+ </div>
+ {/if}
+
+ <span>{name}</span>
+ </div>
+ </LinkWrapper>
+ </svelte:fragment>
+ </Menu>
+ {/if}
+ </div>
+
+ <div class="segment-selector" aria-label={categoriesButtonTitle}>
+ {#each segments as segment, index}
+ {@const { segmentSelectAction } = segment}
+ {@const isSelected = initialSegmentIndex === index}
+ {@const filterLabel = $i18n.t(
+ isSelected
+ ? 'ASE.Web.AppStore.SelectedFilterApps.AX.Label'
+ : 'ASE.Web.AppStore.FilterApps.AX.Label',
+ { filterName: segment.shortName },
+ )}
+
+ <LinkWrapper
+ action={segmentSelectAction}
+ label={filterLabel}
+ >
+ <span class="segment" class:selected={isSelected}>
+ {segment.shortName}
+ </span>
+ </LinkWrapper>
+ {/each}
+ </div>
+ </header>
+ </Shelf>
+</DefaultPage>
+
+<style>
+ header {
+ --pill-button-border-radius: 1000px; /* Arbitrary large value for "pill-style" rounded sides */
+ --menu-item-padding: 0;
+ --menu-item-margin: 0 0 8px 0;
+ --menu-popover-padding: 12px 16px;
+ --menu-common-padding: 0;
+ --menu-trigger-border-radius: var(--pill-button-border-radius);
+ --menu-trigger-background-color: var(--systemPrimary-onDark);
+ --menu-trigger-padding: 6px 16px;
+ --menu-trigger-font: var(--body-semibold-tall);
+ --menu-popover-background-color: white;
+ --menu-popover-box-shadow: 10px 10px 10px 0
+ var(--systemQuaternary-onLight);
+ --menu-popover-border-radius: 14px;
+ --menu-popover-border: 1px solid var(--systemQuaternary);
+ --menu-popover-z-index: calc(var(--z-web-chrome) + 1);
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+
+ @media (--range-small-up) {
+ display: grid;
+ align-items: center;
+ justify-items: start;
+ grid-template-columns: 1fr max-content 1fr;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ --menu-trigger-background-color: var(--systemQuaternary);
+ --menu-popover-background-color: var(--systemQuaternary-vibrant);
+ }
+ }
+
+ .segment-selector {
+ display: flex;
+ justify-self: end;
+ gap: 4px;
+ padding: 2px;
+ background: var(--systemQuaternary);
+ border-radius: var(--pill-button-border-radius);
+
+ @media (--range-small-up) {
+ align-items: center;
+ justify-self: center;
+ grid-column: 2;
+ }
+ }
+
+ .segment-selector :global(a) {
+ display: contents;
+ }
+
+ .segment {
+ border-radius: var(--pill-button-border-radius);
+ font: var(--body-semibold-tall);
+ padding: 6px 16px;
+ }
+
+ .segment.selected {
+ background-color: var(--systemPrimary-onDark);
+ color: var(--systemPrimary);
+
+ @media (prefers-color-scheme: dark) {
+ background-color: var(--systemQuaternary);
+ }
+ }
+
+ .dropdown-container {
+ justify-self: start;
+
+ @media (--range-small-up) {
+ grid-column: 1;
+ }
+ }
+
+ .menu-trigger-contents {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .menu-trigger-contents :global(svg) {
+ height: 0.7em;
+ }
+
+ .menu-trigger-contents :global(path:not([fill='none'])) {
+ fill: currentColor;
+ }
+
+ .category-menu-item {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ border-radius: 10px;
+ height: 40px;
+ transition: background 150ms ease-in;
+ }
+
+ .category-menu-item.active {
+ background: var(--systemQuinary);
+ }
+
+ .category-menu-item:not(.active):hover {
+ background: rgba(0, 0, 0, 0.035);
+ }
+
+ .artwork-container {
+ width: 24px;
+ margin-inline-end: 8px;
+ flex-shrink: 0;
+ }
+
+ .category-menu-item span {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .dropdown-container :global(.menu-popover) {
+ max-width: 600px;
+ width: 100%;
+ column-count: 2;
+
+ @media (--range-medium-up) {
+ column-count: 3;
+ }
+ }
+</style>
diff --git a/src/components/pages/VisionProPage.svelte b/src/components/pages/VisionProPage.svelte
new file mode 100644
index 0000000..c87ee09
--- /dev/null
+++ b/src/components/pages/VisionProPage.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import type { GenericPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from './DefaultPage.svelte';
+ import VisionProFooter from '~/components/structure/VisionProFooter.svelte';
+
+ export let page: GenericPage;
+</script>
+
+<DefaultPage {page} />
+
+<VisionProFooter />
diff --git a/src/components/structure/Fonts.svelte b/src/components/structure/Fonts.svelte
new file mode 100644
index 0000000..63af7b6
--- /dev/null
+++ b/src/components/structure/Fonts.svelte
@@ -0,0 +1,19 @@
+<script lang="ts">
+ import { BASE, getFontURL } from '@amp/web-apps-fonts';
+
+ export let language: string;
+
+ $: fontURL = getFontURL(language);
+</script>
+
+<svelte:head>
+ <link rel="preconnect" href={BASE} crossorigin="anonymous" />
+
+ <link
+ rel="stylesheet"
+ as="style"
+ href={fontURL}
+ type="text/css"
+ referrerpolicy="strict-origin-when-cross-origin"
+ />
+</svelte:head>
diff --git a/src/components/structure/Footer.svelte b/src/components/structure/Footer.svelte
new file mode 100644
index 0000000..ceabfec
--- /dev/null
+++ b/src/components/structure/Footer.svelte
@@ -0,0 +1,47 @@
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+ import Footer, {
+ type Translate,
+ } from '@amp/web-app-components/src/components/Footer/Footer.svelte';
+ import LocaleSwitcherButton from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte';
+ import { items } from '~/constants/footer-items';
+ import { getLocale } from '~/utils/locale';
+ import {
+ regions,
+ languages,
+ storefrontNameTranslations,
+ } from '~/utils/storefront-data';
+
+ const i18n = getI18n();
+ const locale = getLocale();
+
+ const translate: Translate = (key, options) => $i18n.t(key, options);
+</script>
+
+<section class="footer-container">
+ <Footer footerItems={items} translateFn={translate}>
+ <LocaleSwitcherButton
+ slot="secondary-content"
+ translateFn={translate}
+ {regions}
+ {languages}
+ {locale}
+ {storefrontNameTranslations}
+ defaultRoute="iphone/today"
+ />
+ </Footer>
+</section>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .footer-container {
+ background-color: var(--footerBg);
+ }
+
+ .footer-container :global(footer) {
+ max-width: calc(viewport-content-for(xlarge));
+ margin: 0 auto;
+ }
+</style>
diff --git a/src/components/structure/MetaTags.svelte b/src/components/structure/MetaTags.svelte
new file mode 100644
index 0000000..11b9477
--- /dev/null
+++ b/src/components/structure/MetaTags.svelte
@@ -0,0 +1,68 @@
+<script lang="ts">
+ import type { Opt } from '@jet/environment/types/optional';
+ import type { Organization, WithContext } from 'schema-dts';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import MetaTags from '@amp/web-app-components/src/components/MetaTags/MetaTags.svelte';
+ import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types';
+ import { getLocale } from '@amp/web-app-components/src/utils/internal/locale';
+ import { getPageDir } from '@amp/web-apps-localization/src';
+
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: WebRenderablePage;
+
+ const i18n = getI18n();
+ const locale = getLocale();
+
+ const organizationSchema: WithContext<Organization> = {
+ '@context': 'https://schema.org',
+ '@id': 'https://apps.apple.com/#organization',
+ '@type': 'Organization',
+ name: 'App Store',
+ url: 'https://apps.apple.com',
+ logo: 'https://apps.apple.com/assets/app-store.png',
+ sameAs: [
+ 'https://www.wikidata.org/wiki/Q368215',
+ 'https://twitter.com/AppStore',
+ 'https://www.instagram.com/appstore/',
+ 'https://www.facebook.com/appstore/',
+ ],
+ parentOrganization: {
+ '@type': 'Organization',
+ name: 'Apple',
+ '@id': 'https://www.apple.com/#organization',
+ url: 'https://www.apple.com/',
+ },
+ };
+
+ // This cast of `.seoData` is technically a little risky, but our app fully
+ // defines this property, which should make it fairly safe. Whatever is returned
+ // for the page from the `SEO` dependency on the Object Graph will be the value
+ // reflected here.
+ $: seoData = (page.seoData as Opt<SeoData>) ?? undefined;
+
+ // Provide default title for pages not yet set up with SEO data
+ $: defaultTitle = $i18n.t('ASE.Web.AppStore.Meta.SiteName');
+ $: pageDir = getPageDir(locale.language) ?? 'ltr';
+</script>
+
+<MetaTags
+ {defaultTitle}
+ {locale}
+ {pageDir}
+ {seoData}
+ origin={'https://apps.apple.com/'}
+>
+ <svelte:fragment slot="schemaOrganizationData">
+ {#if import.meta.env.SSR}
+ <svelte:element
+ this="script"
+ id="organization"
+ type="application/ld+json"
+ >
+ {JSON.stringify(organizationSchema)}
+ </svelte:element>
+ {/if}
+ </svelte:fragment>
+</MetaTags>
diff --git a/src/components/structure/VisionProFooter.svelte b/src/components/structure/VisionProFooter.svelte
new file mode 100644
index 0000000..59dcd5b
--- /dev/null
+++ b/src/components/structure/VisionProFooter.svelte
@@ -0,0 +1,142 @@
+<script lang="ts">
+ import ShelfTitle from '~/components/Shelf/Title.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import Grid from '~/components/Grid.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import { getLocale } from '~/utils/locale';
+
+ const locale = getLocale();
+ const i18n = getI18n();
+
+ let links: Record<string, string>;
+
+ function getAboutAppStoreUrl(storefront: string, language: string) {
+ let storefrontSlug = `${storefront}/`;
+
+ if (storefront === 'us') {
+ storefrontSlug = '';
+ } else if (storefront === 'gb') {
+ // The UK "About App Store" link is https://www.apple.com/uk/app-store/, not https://www.apple.com/gb/app-store/.
+ storefrontSlug = 'uk/';
+ } else if (storefront === 'ae' && language === 'ar') {
+ storefrontSlug = 'ae-ar/';
+ }
+
+ return `https://www.apple.com/${storefrontSlug}app-store/`;
+ }
+
+ $: storefront = locale.storefront;
+ $: links = {
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutAppStore':
+ getAboutAppStoreUrl(storefront, locale.language),
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutPurchases': `https://apps.apple.com/${storefront}/story/id1436214772`,
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.RequestRefund': `https://www.apple.com/${storefront}/shop/goto/help/sales_refunds`,
+ 'ASE.Web.AppStore.VisionPro.Footer.Links.PaymentMethods': `https://support.apple.com/118429`,
+ };
+
+ $: if (storefront === 'fr') {
+ links[
+ 'AppStore.QuickLinks.AboutFrenchAppStore.Title'
+ ] = `https://apps.apple.com/${storefront}/story/1700848501`;
+ }
+</script>
+
+<ShelfWrapper centered={false} withBottomPadding={false}>
+ <section data-test-id="vision-footer">
+ <p class="blurb">
+ {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Blurb')}
+ </p>
+
+ <article class="quick-links-container">
+ <ShelfTitle
+ title={$i18n.t('ASE.Web.AppStore.VisionPro.Footer.LinksTitle')}
+ />
+
+ <navigation>
+ <Grid
+ items={Object.entries(links)}
+ gridType="FooterLink"
+ let:item
+ >
+ {@const [title, href] = item}
+ <a {href}>{$i18n.t(title)}</a>
+ </Grid>
+ </navigation>
+ </article>
+
+ <article class="disclaimer-container">
+ <p>
+ {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Disclaimer')}
+ </p>
+ </article>
+ </section>
+</ShelfWrapper>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ section {
+ font: var(--body-tall);
+ }
+
+ .blurb {
+ flex-grow: 1;
+ width: 100%;
+ max-width: calc(viewport-content-for(xlarge) * 0.66);
+ margin: 40px auto 50px;
+ padding: 0 var(--shelfGridPaddingInline, 40px);
+ text-align: center;
+ }
+
+ .quick-links-container {
+ max-width: viewport-content-for(xlarge);
+ margin: 50px auto;
+ padding: 0 var(--bodyGutter);
+ }
+
+ a {
+ display: block;
+ padding: var(--grid-column-gap-medium) 0 var(--grid-column-gap-medium);
+ word-break: break-all;
+ font: var(--title-2);
+ color: var(--keyColor);
+ border-bottom: 1px solid var(--systemQuinary);
+
+ @media (--range-xsmall-down) {
+ padding: var(--grid-column-gap-xsmall) 0
+ var(--grid-column-gap-xsmall);
+ }
+ }
+
+ @media (--range-medium-up) {
+ .quick-links-container li:nth-child(n + 4) a {
+ border-bottom: none;
+ }
+ }
+
+ @media (--small) {
+ .quick-links-container li:nth-child(n + 5) a {
+ border-bottom: none;
+ }
+ }
+
+ @media (--range-xsmall-down) {
+ .quick-links-container li:last-child a {
+ border-bottom: none;
+ }
+ }
+
+ .disclaimer-container {
+ flex-grow: 1;
+ width: 100%;
+ color: var(--systemTertiary);
+ background-color: var(--footerBg);
+ }
+
+ .disclaimer-container p {
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ padding: 32px var(--bodyGutter, 40px);
+ }
+</style>