summaryrefslogtreecommitdiff
path: root/src/components/jet
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src/components/jet
init commit
Diffstat (limited to 'src/components/jet')
-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
134 files changed, 11902 insertions, 0 deletions
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>