summaryrefslogtreecommitdiff
path: root/src/components/jet/item
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/jet/item')
-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
54 files changed, 5866 insertions, 0 deletions
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>