init commit

This commit is contained in:
rxliuli
2025-11-04 05:03:50 +08:00
commit bce557cc2d
1396 changed files with 172991 additions and 0 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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'}
/>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>