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

161
src/App.svelte Normal file
View File

@@ -0,0 +1,161 @@
<script lang="ts">
import { onMount } from 'svelte';
import { BUILD } from '~/config/build';
import { getJet } from '~/jet';
import { makeErrorPageIntent } from '~/jet/intents/error-page-intent-controller';
import { getLocale } from '~/utils/locale';
// Types
import type { Page } from './jet/models/page';
// Components
import Fonts from '~/components/structure/Fonts.svelte';
import Footer from '~/components/structure/Footer.svelte';
import Navigation from '~/components/navigation/Navigation.svelte';
import NavigationSkeleton from '~/components/navigation/Skeleton.svelte';
import PageResolver from '~/components/PageResolver.svelte';
const locale = getLocale();
const jet = getJet();
$: language = locale.language;
export let page: Promise<Page> | Page = new Promise(() => {});
export let isFirstPage: boolean = true;
$: pageWithRejectionErrorPage = transformRejectionIntoErrorPage(page);
// Critically, this function is not async. We want to preserve the behavior
// where if page is not a promise than neither is
// pageWithRejectionErrorPage.
function transformRejectionIntoErrorPage(
page: Promise<Page> | Page,
): Promise<Page> | Page {
if (!(page instanceof Promise)) {
return page;
}
// The async IIFE allows this function to return synchronously.
return (async (): Promise<Page> => {
try {
return await page;
} catch (error) {
return jet.dispatch(
makeErrorPageIntent({
// This allows the error page to pick the right platform
// and display the correct mesage (ex. "Page not found" for
// a 404)
error: error instanceof Error ? error : null,
}),
);
}
})();
}
// NOTE: The use of page instead of pageWithRejectionErrorPage here is very
// intentional. Since pageWithRejectionErrorPage is reactive, it will
// be undefined in this initializer. This is intentionally not
// not derived (eg. defined as $: webNavigation = ...), since we only
// want to update it _after_ the page promise resolves (so the nav
// doesn't disappear on navigation). But then for SSR, there are no
// promises, so we need a sync value here so the nav renders, which
// is why we have the initializer.
let webNavigation = page instanceof Promise ? null : page.webNavigation;
$: {
if (pageWithRejectionErrorPage instanceof Promise) {
// Clientside once the new page resolves, update the navigation
// (in case it changed)
pageWithRejectionErrorPage.then((page: Page) => {
webNavigation = page.webNavigation;
});
} else {
// Sometimes clientside a promise is not passed to updateApp, so
// we need to handle a WebRenderablePage (possible with a
// different webNavigation).
webNavigation = pageWithRejectionErrorPage.webNavigation;
}
}
onMount(() => {
//@ts-ignore
window.__ASOTW = {
version: BUILD,
};
});
</script>
<svelte:head>
<meta name="version" content={BUILD} />
</svelte:head>
<Fonts {language} />
{#if import.meta.env.DEV}
{#await import('~/components/ArtworkBreakpointLogger.svelte') then { default: ArtworkBreakpointLogger }}
<ArtworkBreakpointLogger />
{/await}
{/if}
<div class="app-container" data-testid="app-container">
<div class="navigation-container">
{#if webNavigation}
<Navigation {webNavigation} />
{:else}
<NavigationSkeleton />
{/if}
</div>
<div
style="display: flex;
position: relative;
flex-direction: column;
min-height: 100vh;
"
>
<main class="page-container">
<PageResolver page={pageWithRejectionErrorPage} {isFirstPage} />
</main>
<Footer />
</div>
</div>
<style lang="scss">
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use '@amp/web-shared-styles/app/core/viewports' as *;
@use '@amp/web-shared-styles/app/core/globalvars' as *;
.app-container {
min-height: 100vh;
min-height: 100dvh;
display: grid;
grid-template-areas:
'structure-header'
'structure-main-section';
grid-template-columns: minmax(0, 1fr);
grid-gap: 0;
grid-template-rows: 44px auto;
@media (--sidebar-visible) {
grid-template-rows: auto;
grid-template-columns: 260px minmax(0, 1fr);
}
@media (--sidebar-large-visible) {
grid-template-columns: $global-sidebar-width-large minmax(0, 1fr);
}
}
.navigation-container {
@media (--range-small-up) {
height: 100vh;
position: sticky;
top: 0;
}
}
.page-container {
flex-grow: 1;
}
</style>

97
src/bootstrap.ts Normal file
View File

@@ -0,0 +1,97 @@
// Sets up app specific configurations
import type { Opt } from '@jet/environment';
import type { Intent } from '@jet/environment/dispatching';
import type { ActionModel } from '@jet/environment/types/models';
import { initializeUniqueIdContext } from '@amp/web-app-components/src/utils/uniqueId';
import { setLocale as setSharedLocale } from '@amp/web-app-components/src/utils/locale';
import type {
NormalizedStorefront,
NormalizedLanguage,
} from '@jet-app/app-store/api/locale';
import {
DEFAULT_STOREFRONT_CODE,
DEFAULT_LANGUAGE_BCP47,
} from '~/constants/storefront';
import { Jet } from '~/jet';
import { setup as setupI18n } from '~/stores/i18n';
import type { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
import type { LoggerFactory } from '@amp/web-apps-logger';
import type { Locale as Language } from '@amp/web-apps-localization';
import type I18N from '@amp/web-apps-localization';
import '~/config/components/artwork';
import '~/config/components/shelf';
import type { FeaturesCallbacks } from './jet/dependencies/net';
export type Context = Map<string, unknown>;
export async function bootstrap({
loggerFactory,
initialUrl,
fetch,
prefetchedIntents,
featuresCallbacks,
}: {
loggerFactory: LoggerFactory;
initialUrl: string;
fetch: typeof window.fetch;
prefetchedIntents: PrefetchedIntents;
featuresCallbacks?: FeaturesCallbacks;
}): Promise<{
context: Context;
jet: Jet;
initialAction: Opt<ActionModel>;
intent: Opt<Intent<unknown>>;
storefront: NormalizedStorefront;
language: NormalizedLanguage;
i18n: I18N;
}> {
const log = loggerFactory.loggerFor('bootstrap');
const context = new Map();
const jet = Jet.load({
loggerFactory,
context,
fetch,
prefetchedIntents,
featuresCallbacks,
});
initializeUniqueIdContext(context, loggerFactory);
const routing = await jet.routeUrl(initialUrl);
if (routing) {
log.info('initial URL routed to:', routing);
} else {
log.warn('initial URL was unroutable:', initialUrl);
}
const {
intent = null,
action: initialAction = null,
storefront = DEFAULT_STOREFRONT_CODE,
language = DEFAULT_LANGUAGE_BCP47,
} = routing || {};
// TODO: rdar://78109398 (i18n Improvements)
const i18nStore = await setupI18n(
context,
loggerFactory,
language.toLowerCase() as Language,
);
jet.setLocale(i18nStore, storefront, language);
setSharedLocale(context, { storefront, language });
return {
context,
jet,
initialAction,
intent,
storefront,
language,
i18n: i18nStore,
};
}

100
src/browser.ts Normal file
View File

@@ -0,0 +1,100 @@
// This must be imported first to ensure base styles are imported first
import '~/styles/app-store.scss';
import App from '~/App.svelte';
import { bootstrap } from '~/bootstrap';
import { registerActionHandlers } from '~/jet/action-handlers';
import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
import {
CompositeLoggerFactory,
ConsoleLoggerFactory,
DeferredLoggerFactory,
setContext,
} from '@amp/web-apps-logger';
import { setHTMLAttributes } from '@amp/web-apps-localization';
import { ERROR_KIT_CONFIG } from '~/config/errorkit';
import {
ErrorKitLoggerFactory,
setupErrorKit,
} from '@amp/web-apps-logger/src/errorkit';
import { setupRuntimeFeatures } from '~/utils/features/runtime';
export async function startApplication() {
const onyxFeatures = await setupRuntimeFeatures(
new DeferredLoggerFactory(() => logger),
);
const consoleLogger = new ConsoleLoggerFactory();
const errorKit = setupErrorKit(ERROR_KIT_CONFIG, consoleLogger);
const logger = new CompositeLoggerFactory([
consoleLogger,
new ErrorKitLoggerFactory(errorKit),
...(onyxFeatures ? [onyxFeatures.recordingLogger] : []),
]);
let url = window.location.href;
// TODO: this is busted for some reason? rdar://111465791 ([Onyx] Foundation - PerfKit)
// const perfkit = setupBrowserPerfkit(PERF_KIT_CONFIG, logger);
// Initialize Jet, and get starting state.
const { context, jet, initialAction, storefront, language, i18n } =
await bootstrap({
loggerFactory: logger,
initialUrl: url,
fetch: window.fetch.bind(window),
prefetchedIntents: PrefetchedIntents.fromDom(logger, {
evenIfSignedIn: true,
featureKitItfe: onyxFeatures?.featureKit?.itfe,
}),
featuresCallbacks: {
getITFEValues(): string | undefined {
return onyxFeatures?.featureKit?.itfe;
},
},
});
// TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit)
// setPageSpeedContext(context, perfkit, logger);
setContext(context, logger);
// Add lang + dir tag to HTML node
setHTMLAttributes(language);
// Using a container element to avoid svelte hydration
// "clean up" from removing tags that have
// been add to the <body> tag in our HTML file.
const container = document.querySelector('.body-container');
const app = new App({
target: container,
context,
hydrate: true,
});
// Initialize action-handlers.
registerActionHandlers({
jet,
logger,
updateApp: (props) => app.$set(props),
});
if (initialAction) {
// TODO: rdar://73165545 (Error Handling Across App): handle throw
await jet.perform(initialAction);
} else {
app.$set({
page: Promise.reject(new Error('404')),
isFirstPage: true,
});
}
}
// If we export default here, this will run during tests when we do
// `import { startApplication } from '~/browser';`. To avoid this, we guard using the
// presence of an ENV var only set by Vitest.
// This is covered by acceptance tests
if (!import.meta.env?.VITEST) {
startApplication();
}

View File

@@ -0,0 +1,202 @@
<script lang="ts">
import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
import ResizeDetector from '@amp/web-app-components/src/components/helpers/ResizeDetector.svelte';
import { colorAsString } from '~/utils/color';
export let artwork: JetArtworkType;
export let active: boolean = false;
$: isBackgroundImageLoaded = false;
$: backgroundImage = artwork
? buildSrc(
artwork.template,
{
crop: 'sr',
width: 400,
height: Math.floor(400 / 1.6667),
fileType: 'webp',
},
{},
)
: undefined;
$: if (backgroundImage) {
const img = new Image();
img.onload = () => (isBackgroundImageLoaded = true);
img.src = backgroundImage;
}
let resizing = false;
const handleResizeUpdate = (e: CustomEvent<{ isResizing: boolean }>) =>
(resizing = e.detail.isResizing);
let isOutOfView = true;
const handleIntersectionOberserverUpdate = (
isIntersectingViewport: boolean,
) => (isOutOfView = !isIntersectingViewport);
</script>
{#if backgroundImage}
<ResizeDetector on:resizeUpdate={handleResizeUpdate} />
<div
class="container"
class:active
class:resizing
class:loaded={isBackgroundImageLoaded}
class:out-of-view={isOutOfView}
style:--background-image={`url(${backgroundImage})`}
style:--background-color={artwork.backgroundColor &&
colorAsString(artwork.backgroundColor)}
use:intersectionObserver={{
callback: handleIntersectionOberserverUpdate,
threshold: 0,
}}
>
<div class="overlay" />
</div>
{/if}
<style>
.container {
--veil: rgb(240, 240, 240, 0.65);
--speed: 0.66s;
--aspect-ratio: 16/9;
--scale: 1.2;
position: absolute;
top: 0;
left: 0;
width: 100%;
aspect-ratio: var(--aspect-ratio);
max-height: 900px;
opacity: 0;
/*
This stack of background images represents the following three layers, listed front-to-back:
1) A gradient from transparent to white that acts as a mask for the entire container.
`mask-image` caused too much thrashing and CPU usage when animating and resizing,
so we are mimicking its functionality with this top-layer background image.
2) A semi-transparent veil to evenly fade out the bg. Note that this is not technically
a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
used in `background-image`.
3) The joe color of the background image that will eventualy be loaded.
*/
background-image: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 50%,
var(--pageBg) 80%
),
linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
linear-gradient(
0deg,
var(--background-color) 0%,
var(--background-color) 80%
);
background-position: center;
background-size: 120%;
/*
Blurring via the CSS filter does not extend edge-to-edge of the contents width, but we
can mitigate that by ever-so-slightly bumping up the `scale` of content so it bleeds off
the page cleanly.
*/
filter: blur(20px) saturate(1.3);
transform: scale(var(--scale));
transition: opacity calc(var(--speed) * 2) ease-out,
background-size var(--speed) ease-in;
@media (prefers-color-scheme: dark) {
--veil: rgba(0, 0, 0, 0.5);
}
}
.container.loaded {
/*
This stack of background images represents the following three layers, listed front-to-back:
1) A gradient from transparent to white that acts as a mask for the entire container.
`mask-image` caused too much thrashing and CPU usage when animating and resizing,
so we are mimicking its functionality with this top-layer background image.
2) A semi-transparent veil to evenly fade out the image. Note that this is not technically
a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
used in `background-image`.
3) The actual background image.
*/
background-image: linear-gradient(
180deg,
rgba(255, 255, 255, 0) 50%,
var(--pageBg) 80%
),
linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
var(--background-image);
}
.container.active {
opacity: 1;
transition: opacity calc(var(--speed) / 2) ease-in;
background-size: 100%;
}
.overlay {
position: absolute;
z-index: 2;
top: 0;
left: 0;
width: 100%;
aspect-ratio: var(--aspect-ratio);
max-height: 900px;
opacity: 0;
background-image: var(--background-image);
background-position: 100% 100%;
background-size: 250%;
filter: brightness(1.3) saturate(0);
mix-blend-mode: overlay;
will-change: opacity, background-position;
animation: shift-background 60s infinite linear alternate;
animation-play-state: paused;
transition: opacity var(--speed) ease-in;
}
.active .overlay {
opacity: 0.3;
animation-play-state: running;
transition: opacity calc(var(--speed) * 2) ease-in
calc(var(--speed) * 2);
}
.active.out-of-view .overlay,
.active.resizing .overlay {
animation-play-state: paused;
opacity: 0;
}
@keyframes shift-background {
0% {
background-position: 0% 50%;
background-size: 250%;
}
25% {
background-position: 60% 20%;
background-size: 300%;
}
50% {
background-position: 100% 50%;
background-size: 320%;
}
75% {
background-position: 40% 100%;
background-size: 220%;
}
100% {
background-position: 20% 50%;
background-size: 300%;
}
}
</style>

View File

@@ -0,0 +1,72 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import type { Optional } from '@jet/environment/types/optional';
import type { AppEvent } from '@jet-app/app-store/api/models';
import { getJet } from '~/jet';
import {
chooseAppEventDate,
renderDate,
computeAppEventFormattedDates,
type RequiredAppEventFormattedDate,
} from '~/jet/utils/app-event-formatted-date';
const jet = getJet();
/**
* New pattern (*prefered*): accept appEvent object and compute formattedDates on client-side.
* This avoids timezone differences in SSR server (UTC) which cause incorrect event date and time.
* By computing dates in the browser, we ensure the user sees dates in their local timezone.
*/
export let appEvent:
| Pick<AppEvent, 'appEventBadgeKind' | 'startDate' | 'endDate'>
| undefined = undefined;
// Legacy pattern: accept pre-computed formattedDates from Jet
export let formattedDates: RequiredAppEventFormattedDate[] | undefined =
undefined;
let appEventDate: Optional<RequiredAppEventFormattedDate>;
onMount(() => {
const dates = appEvent
? computeAppEventFormattedDates(
jet.objectGraph,
appEvent.appEventBadgeKind,
appEvent.startDate,
appEvent.endDate,
)
: formattedDates;
if (dates) {
appEventDate = chooseAppEventDate(dates);
}
});
/**
* `Date` instances in the view-model will have been serialized to `string`
* instances by ServerKit when delivered to the client; we need to normalize
* this so that we have a `string` both client- and server-side.
*/
function normalizeDate(date: Date | string): string {
return typeof date === 'string' ? date : date.toISOString();
}
</script>
{#if appEventDate}
<time
transition:fade={{ duration: 210 }}
datetime={appEventDate.displayFromDate &&
normalizeDate(appEventDate.displayFromDate)}
>
{renderDate(jet.objectGraph.loc, appEventDate)}
</time>
{:else}
<span aria-hidden="true">&hellip;</span>
{/if}
<style>
span {
color: transparent;
}
</style>

View File

@@ -0,0 +1,131 @@
<script lang="ts" context="module">
import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
import type { NamedProfile } from '~/config/components/artwork';
export type AppIconProfile = Extract<
NamedProfile,
| 'app-icon'
| 'app-icon-large'
| 'app-icon-medium'
| 'app-icon-small'
| 'app-icon-xlarge'
| 'app-icon-river'
| 'brick-app-icon'
>;
export function doesAppIconNeedBorder(icon: JetArtworkType): boolean {
const doesIconHaveTransparentBackground =
icon.backgroundColor &&
isNamedColor(icon.backgroundColor) &&
icon.backgroundColor.name === 'clear';
const isIconPrerendered =
icon.style === 'roundedRectPrerendered' ||
icon.style === 'roundPrerendered';
const isIconUnadorned = icon.style === 'unadorned';
return (
!doesIconHaveTransparentBackground &&
!isIconPrerendered &&
!isIconUnadorned
);
}
</script>
<script lang="ts">
import Artwork from '~/components/Artwork.svelte';
import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
import { isNamedColor } from '~/utils/color';
export let icon: JetArtworkType;
export let profile: AppIconProfile = 'app-icon';
export let fixedWidth: boolean = true;
export let disableAutoCenter: boolean = false;
export let withBorder: boolean = false;
const profiles = ArtworkConfig.get().PROFILES;
$: computedProfile = (
icon.style === 'pill'
? `${profile}-pill`
: icon.style === 'tvRect'
? `${profile}-tv-rect`
: profile
) as NamedProfile;
$: widthFromProfile = profiles?.get(computedProfile)?.[0] ?? 0;
$: hasTransparentBackground =
!!icon.backgroundColor &&
isNamedColor(icon.backgroundColor) &&
icon.backgroundColor.name === 'clear';
$: needsBorder = withBorder || doesAppIconNeedBorder(icon);
// These prerendered "Solarium" icons need to use higher than normal quality due to how their
// rendering pipeline downscales/transforms sources.
$: quality =
icon.style &&
['roundedRectPrerendered', 'roundPrerendered'].includes(icon.style)
? 75
: undefined;
</script>
<div
class="app-icon"
class:pill={icon.style === 'pill'}
class:round={icon.style === 'round'}
class:rounded-rect={icon.style === 'roundedRect'}
class:tv-rect={icon.style === 'tvRect'}
class:rounded-rect-prerendered={icon.style === 'roundedRectPrerendered'}
class:round-prerendered={icon.style === 'roundPrerendered'}
class:with-border={needsBorder}
style={fixedWidth ? `--profileWidth: ${widthFromProfile}px` : ''}
>
<Artwork
{disableAutoCenter}
{hasTransparentBackground}
{quality}
artwork={icon}
profile={computedProfile}
noShelfChevronAnchor={true}
/>
</div>
<style>
.app-icon {
aspect-ratio: 1 / 1;
min-width: var(--profileWidth, auto);
}
.app-icon.pill {
aspect-ratio: 4 / 3;
/*
Creates elliptical corners with horizontal radii at 50% of the width and vertical radii
at 65% of the height, for a rounded, squished, pill-like effect
*/
border-radius: 50% 50% 50% 50% / 65% 65% 65% 65%;
}
.app-icon.round {
border-radius: 50%;
}
.app-icon.rounded-rect {
border-radius: 23%;
}
.app-icon.tv-rect {
aspect-ratio: 16/9;
border-radius: 9% / 16%;
}
.app-icon.rounded-rect-prerendered {
border-radius: 25%;
}
.app-icon.round-prerendered {
border-radius: 50%;
}
.app-icon.with-border {
box-shadow: 0 0 0 1px var(--systemQuaternary);
}
</style>

View File

@@ -0,0 +1,92 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { Artwork } from '@jet-app/app-store/api/models';
import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte';
export let icons: Artwork[];
export let profile: AppIconProfile = 'app-icon-river';
$: aspectRatio = icons[0].width / icons[0].height;
let mounted = false;
const numberOfIcons = icons.length;
// We shift the order of the bottom row of icons to ensure that the same icons aren't shown
// next to each other. Note that this is different from purely shuffling the icons, as that
// could still lead to the same icons being next to one another, due to how small the set is.
// The input and output here is as such:
// in = [1, 2, 3, 4, 5, 6, 7]
// out = [4, 5, 6, 7, 1, 2, 3]
const iconsInShiftedOrder = [
...icons.slice(numberOfIcons / 2),
...icons.slice(0, numberOfIcons / 2),
];
// We are quadrupling the icons we render so the flow is seamless and stretches across the
// full width of the container.
const topRow = Array(4).fill(icons).flat();
const bottomRow = Array(4).fill(iconsInShiftedOrder).flat();
// We use this `mounted` flag to defer the rendering of the `AppIconRiver`, since it's markup heavy
// and has no semantic meaning for SEO. This deferring saves about 190kb of initial HTML per instance.
onMount(() => (mounted = true));
</script>
{#if mounted}
{#each [topRow, bottomRow] as iconRow}
<ul class="app-icons">
{#each iconRow as icon}
<li
class="app-icon-container"
style:--aspect-ratio={aspectRatio}
>
<AppIcon {icon} {profile} fixedWidth={false} />
</li>
{/each}
</ul>
{/each}
{/if}
<style lang="scss">
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use 'ac-sasskit/core/locale' as *;
.app-icons {
--icon-width: var(--app-icon-river-icon-width, 128px);
--speed: var(--app-icon-river-speed, 240s);
--direction: -50%;
@include rtl {
--direction: 50%;
}
display: flex;
width: fit-content;
z-index: 2;
animation: scroll var(--speed) linear infinite;
}
.app-icons:last-of-type {
margin-bottom: 20px;
}
.app-icon-container {
width: var(--icon-width);
aspect-ratio: var(--aspect-ratio);
margin: 8px;
}
.app-icons:last-of-type .app-icon-container {
position: relative;
right: calc((var(--icon-width) / 2) + 8px);
}
@keyframes scroll {
0% {
transform: translateX(0);
}
100% {
transform: translateX(var(--direction));
}
}
</style>

View File

@@ -0,0 +1,118 @@
<script lang="ts" context="module">
import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
import type {
Artwork as ComponentArtworkType,
Profile as ArtworkProfile,
CropCode,
ImageSizes,
} from '@amp/web-app-components/src/components/Artwork/types';
import type { NamedProfile } from '~/config/components/artwork';
/**
* Creates a {@linkcode Profile} on-the-fly based on the properties of
* the {@linkcode artwork}
*/
export function getNaturalProfile(
artwork: JetArtworkType,
imageSizes: ImageSizes = [artwork.width],
): ArtworkProfile {
const aspectRatio = artwork.width / artwork.height;
return [imageSizes, aspectRatio, artwork.crop as CropCode];
}
export type Profile = NamedProfile | ArtworkProfile;
</script>
<script lang="ts">
import type { ImageSettings } from '@amp/web-app-components/src/components/Artwork/types';
import Artwork from '@amp/web-app-components/src/components/Artwork/Artwork.svelte';
import { colorAsString, isNamedColor } from '~/utils/color';
import {
ArtworkConfig,
type ArtworkProfileMap,
} from '@amp/web-app-components/config/components/artwork';
export let artwork: JetArtworkType;
export let profile: Profile;
export let alt: string = '';
export let topRoundedSecondary: boolean = false;
export let useContainerStyle: boolean = false;
export let forceFullWidth: boolean = true;
export let isDecorative: boolean = true;
export let lazyLoad: boolean = true;
export let disableAutoCenter: boolean = false;
export let noShelfChevronAnchor: boolean = false;
export let forceCropCode: boolean = false;
export let quality: number | undefined = undefined;
export let hasTransparentBackground: boolean =
!!artwork.backgroundColor &&
isNamedColor(artwork.backgroundColor) &&
artwork.backgroundColor.name === 'clear';
export let useCropCodeFromArtwork: boolean = true;
export let withoutBorder: boolean = false;
let imageSettings: ImageSettings;
$: imageSettings = {
forceCropCode,
hasTransparentBackground,
quality,
};
let PROFILES: ArtworkProfileMap<string> | undefined;
let computedProfileAttributes: Profile | undefined;
$: {
const config = ArtworkConfig?.get();
PROFILES = config?.PROFILES;
const defaultProfileAttributes: Profile | undefined =
typeof profile === 'string' ? PROFILES?.get(profile) : profile;
const cropCodeIndex = 2;
if (
useCropCodeFromArtwork &&
artwork?.crop &&
defaultProfileAttributes
) {
computedProfileAttributes = [...defaultProfileAttributes];
computedProfileAttributes[cropCodeIndex] =
artwork?.crop as CropCode;
}
}
$: artworkForComponent = {
...artwork,
backgroundColor: artwork.backgroundColor
? colorAsString(artwork.backgroundColor)
: undefined,
} satisfies ComponentArtworkType;
</script>
<Artwork
artwork={artworkForComponent}
profile={computedProfileAttributes || profile}
{topRoundedSecondary}
{useContainerStyle}
{forceFullWidth}
{imageSettings}
{alt}
{isDecorative}
{lazyLoad}
{disableAutoCenter}
{noShelfChevronAnchor}
{withoutBorder}
/>
<style>
/* When a user enables the "Smart Invert" accessibility setting, images should not be inverted,
so we are re-inverting back to their normal state in this media query, which only currently works for Safari. */
@media (inverted-colors: inverted) {
:global(.artwork-component img) {
filter: invert(1);
}
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts">
import ChevronDown from '~/sf-symbols/chevron.down.svg';
</script>
<details>
<summary>
<slot name="summary" />
<ChevronDown />
</summary>
<slot />
</details>
<style>
details[open] summary {
display: none;
}
summary {
list-style: none;
cursor: pointer;
}
summary::-webkit-details-marker {
display: none;
}
summary :global(svg) {
overflow: visible;
width: 14px;
fill: var(--systemTertiary);
position: relative;
top: 3px;
left: 2px;
}
</style>

View File

@@ -0,0 +1,56 @@
<script lang="ts">
import LaurelIcon from '~/sf-symbols/laurel.left.svg';
import { getI18n } from '~/stores/i18n';
const i18n = getI18n();
</script>
<h4>
<span class="icon-container left" aria-hidden="true">
<LaurelIcon />
</span>
{$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
<span class="icon-container right" aria-hidden="true">
<LaurelIcon />
</span>
</h4>
<style lang="scss">
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use 'ac-sasskit/core/locale' as *;
h4 {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
margin-bottom: 10px;
gap: 10px;
font: var(--font, var(--title-1-emphasized));
color: var(--systemSecondary);
}
.icon-container.right {
transform: rotateY(180deg);
@include rtl {
transform: rotateY(0);
}
}
.icon-container.left {
@include rtl {
transform: rotateY(180deg);
}
}
.icon-container :global(svg) {
overflow: visible;
height: 42px;
transform: translateY(3px);
}
.icon-container :global(svg path) {
fill: var(--systemSecondary);
}
</style>

View File

@@ -0,0 +1,10 @@
<script lang="ts">
import ErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte';
import { getI18n } from '~/stores/i18n';
export let error: Error;
const i18n = getI18n();
</script>
<ErrorPage translateFn={$i18n.t} {error} />

View File

@@ -0,0 +1,23 @@
<script lang="ts">
export let shouldDarken: boolean = true;
</script>
<div class="gradient-overlay" style:--brightness={shouldDarken ? 0.85 : 1} />
<style>
.gradient-overlay {
position: absolute;
z-index: 1;
bottom: 0;
width: 100%;
height: var(--height, 60%);
border-radius: var(--border-radius, var(--global-border-radius-large));
background: linear-gradient(
transparent,
var(--color, var(--systemSecondary-onLight)) var(--height, 100%)
);
backdrop-filter: blur(10px);
filter: saturate(1.5) brightness(var(--brightness));
mask-image: linear-gradient(180deg, transparent 6%, rgb(0, 0, 0.5) 85%);
}
</style>

View File

@@ -0,0 +1,37 @@
<script lang="ts" generics="T">
import { getGridVars } from '@amp/web-app-components/src/components/Shelf/utils/getGridVars';
import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
export let items: T[] = [];
export let gridType: GridType;
$: style = getGridVars(gridType);
</script>
<ul {style} class="grid" data-test-id="grid">
{#each items as item}
<li>
<slot {item} />
</li>
{/each}
</ul>
<style lang="scss">
@mixin grid-styles-for-viewport($viewport: null) {
grid-template-columns: repeat(var(--grid-#{$viewport}), 1fr);
column-gap: var(--grid-column-gap-#{$viewport});
row-gap: var(--grid-row-gap-#{$viewport});
}
.grid {
display: grid;
width: 100%;
padding: 0 var(--bodyGutter);
@each $viewport in ('xsmall', 'small', 'medium', 'large', 'xlarge') {
@media (--range-#{$viewport}-only) {
@include grid-styles-for-viewport($viewport);
}
}
}
</style>

View File

@@ -0,0 +1,54 @@
<script lang="ts">
export let element: keyof HTMLElementTagNameMap = 'article';
export let hasChin: boolean = false;
</script>
<svelte:element this={element} class="hover-wrapper" class:has-chin={hasChin}>
<slot />
</svelte:element>
<style lang="scss">
@use '@amp/web-shared-styles/app/core/mixins/scrim-opacity-controller' as *;
@use 'amp/stylekit/core/mixins/hover-style' as *;
.hover-wrapper {
position: relative;
display: var(--display, flex);
overflow: hidden;
align-items: center;
cursor: pointer;
border-radius: var(--global-border-radius-large);
box-shadow: var(--shadow-small);
@include scrim-opacity-controller;
}
.hover-wrapper.has-chin,
.hover-wrapper.has-chin::after {
// For chins, we cannot use `border-raidus` due a Chrome bug with unequal radii
// (e.g. there is no rounding at the bottom) and mask-image. To get around that,
// we use clip-path to the same effect.
// https://issues.chromium.org/issues/40778541.
border-radius: unset;
clip-path: inset(
0 0 0 0 round var(--global-border-radius-large)
var(--global-border-radius-large) 0 0
);
}
/* stylelint-disable order/order */
.hover-wrapper::after {
mix-blend-mode: soft-light;
@include content-container-hover-style;
// These properties are overriding those provided by `content-container-hover-style`
border-radius: var(--global-border-radius-large);
transition: opacity 210ms ease-out;
}
/* stylelint-enable order/order */
.hover-wrapper:hover::after {
@include scrim-opacity;
}
</style>

View File

@@ -0,0 +1,69 @@
<script lang="ts">
import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
import { getJet } from '~/jet';
import { getI18n } from '~/stores/i18n';
import { launchAppOnMac } from '~/utils/launch-client';
export let url: string;
const i18n = getI18n();
const jet = getJet();
function handleButtonClick(event: MouseEvent) {
// Need to call both event.preventDefault() and event.stopPropagation()
// to prevent navigation to the production page on web
event.preventDefault();
event.stopPropagation();
if (url) {
launchAppOnMac(url);
jet.recordCustomMetricsEvent({
eventType: 'click',
targetId: 'OpenInMacAppStore',
targetType: 'button',
actionType: 'open',
});
}
}
</script>
<button
class="get-button blue"
aria-label={$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.AX')}
on:click={handleButtonClick}
>
<LineClamp clamp={1}>
{$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.Action')}
<span>
{$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.App')}
</span>
</LineClamp>
<ArrowIcon class="external-link-arrow" aria-hidden="true" />
</button>
<style lang="scss">
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use 'ac-sasskit/core/locale' as *;
button {
display: inline-flex;
}
button span {
font-weight: 500;
}
button :global(.external-link-arrow) {
align-self: center;
width: var(--launch-native-button-arrow-size, 9px);
height: var(--launch-native-button-arrow-size, 9px);
padding-top: 1px;
margin-inline-start: 4px;
fill: var(--systemPrimary-onDark);
@include rtl {
transform: rotate(-90deg);
}
}
</style>

View File

@@ -0,0 +1,60 @@
<!--
@component
Wraps a link around the provided slot contents if a valid `FlowAction` or `ExternalUrlAction` is given.
If no valid action is provided, the contents are rendered as-is with no decoration.
💡 For accessibility, this component should ideally wrap the entire visual block (e.g., `div`, `article`) so that
screen readers and keyboard users interpret the entire element as a single link.
@example
```
<LinkWrapper action={item.clickAction}>
<article>
<Artwork artwork={item.artwork} />
{item.title}
</article>
</LinkWrapper>
```
-->
<script lang="ts">
import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
import { type Opt, isSome } from '@jet/environment/types/optional';
import FlowActionComponent from '~/components/jet/action/FlowAction.svelte';
import { isExternalUrlAction } from '~/jet/models';
import ExternalUrlAction from './jet/action/ExternalUrlAction.svelte';
import ShelfBasedPageScrollAction, {
isShelfBasedPageScrollAction,
} from './jet/action/ShelfBasedPageScrollAction.svelte';
export let action: Opt<Action> = null;
export let label: Opt<string> = null;
export let withoutLabel: Opt<boolean> = false;
export let includeExternalLinkArrowIcon: boolean = true;
</script>
{#if isSome(action) && isFlowAction(action) && isSome(action.pageUrl)}
<FlowActionComponent
destination={action}
aria-label={withoutLabel ? null : label || action.title}
>
<slot />
</FlowActionComponent>
{:else if isSome(action) && isExternalUrlAction(action)}
<ExternalUrlAction
destination={action}
aria-label={withoutLabel ? null : label || action.title}
includeArrowIcon={includeExternalLinkArrowIcon}
>
<slot />
</ExternalUrlAction>
{:else if isSome(action) && isShelfBasedPageScrollAction(action)}
<ShelfBasedPageScrollAction
destination={action}
aria-label={withoutLabel ? null : label || action.title}
>
<slot />
</ShelfBasedPageScrollAction>
{:else}
<slot />
{/if}

218
src/components/Menu.svelte Normal file
View File

@@ -0,0 +1,218 @@
<script lang="ts" generics="T">
import { tick } from 'svelte';
import type { Opt } from '@jet/environment/types/optional';
import type { MouseEventHandler } from 'svelte/elements';
import { onDestroy, onMount } from 'svelte';
import { generateUuid } from '@amp/web-apps-utils/src';
import {
computePosition,
autoUpdate,
offset,
flip,
shift,
} from '@floating-ui/dom';
export let options: T[];
// Allows the developer the override the floating-ui calculated offset to a fixed number
export let forcedXPosition: number | null = null;
export let handleShowMenu: () => void = () => {};
let isMenuOpen = false;
/**
* Display the menu
*
* @example
* <script>
* let menu;
*
* function showMenu() {
* menu.show();
* }
* <\/script>
*
* <Menu bind:this={menu} />
*/
export async function show() {
if (!menuEl) return;
isMenuOpen = true;
// Menu position should be updated *only* after the dialog has been shown
updateMenuPosition();
// Focuses the first link in the dropdown after the DOM updates
await tick();
menuEl.querySelector('a')?.focus();
// When the modal is open, track viewport changes and update the menu position
floatingUIAutoUpdatePositionCleanupCallback = autoUpdate(
trigger!,
menuEl!,
updateMenuPosition,
);
}
/**
* Close the menu
*
* @example
* <script>
* let menu;
*
* function closeMenu() {
* menu.close();
* }
* <\/script>
*
* <Menu bind:this={menu} />
*/
export function close() {
if (!menuEl) return;
isMenuOpen = false;
cleanUpFloatingUIAutoPosition();
}
function toggle() {
if (isMenuOpen) {
close();
} else {
show();
handleShowMenu?.();
}
}
const menuId = generateUuid();
let menuEl: HTMLUListElement | undefined;
let trigger: HTMLButtonElement | undefined;
function handleKeyUp(event: KeyboardEvent) {
if (event.key === 'Escape') {
close();
}
}
/**
* Dismiss the dialog when clicking anywhere with the dialog open
*/
const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => {
const clickedElement = event.target as HTMLElement;
// Only close the dialog if the click is "outside" of the trigger
// Otherwise, it will be closed immediately
if (!trigger?.contains(clickedElement)) {
close();
}
};
/// MARK: Menu Positioning through `FloatingUI`
/**
* Update the position of the menu to align it with the trigger
*/
async function updateMenuPosition() {
const { x, y } = await computePosition(trigger!, menuEl!, {
middleware: [
offset({
mainAxis: 10,
}),
flip(),
shift(),
],
placement: 'bottom-end',
});
Object.assign(menuEl!.style, {
left: `${forcedXPosition || x}px`,
top: `${y}px`,
});
}
let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>;
/**
* Cleans up the `FloatingUI` auto-update listener, which should only be "active"
* while the menu is open
*/
function cleanUpFloatingUIAutoPosition() {
floatingUIAutoUpdatePositionCleanupCallback?.();
floatingUIAutoUpdatePositionCleanupCallback = undefined;
}
onMount(() => {
// Ensures menu is hidden initially
if (menuEl) isMenuOpen = false;
});
onDestroy(function () {
cleanUpFloatingUIAutoPosition();
});
</script>
<svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} />
<button
class="menu-trigger"
aria-controls={menuId}
aria-haspopup="menu"
aria-expanded={isMenuOpen}
bind:this={trigger}
on:click={toggle}
>
<slot name="trigger" />
</button>
<ul
id={menuId}
hidden={!isMenuOpen}
tabindex="-1"
class="menu-popover focus-visible"
bind:this={menuEl}
>
{#each options as option}
<li class="menu-item" role="presentation">
<slot name="option" {option} />
</li>
{/each}
</ul>
<style>
:root {
--menu-common-padding: 4px 8px;
}
.menu-trigger {
background-color: var(--menu-trigger-background-color);
border-radius: var(--menu-trigger-border-radius);
font: var(--menu-trigger-font);
padding: var(--menu-trigger-padding, var(--menu-common-padding));
}
.menu-popover {
background-color: var(--menu-popover-background-color, var(--pageBg));
padding: var(--menu-popover-padding, 0);
border: var(--menu-popover-border, none);
border-radius: var(
--menu-popover-border-radius,
var(--global-border-radius-large)
);
box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium));
position: absolute;
inset: auto;
z-index: var(--menu-popover-z-index, 2);
}
.menu-popover::backdrop {
background: var(--menu-popover-backdrop-background, none);
}
.menu-item {
padding: var(--menu-item-padding, var(--menu-common-padding));
margin: var(--menu-item-margin, 0);
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,152 @@
<script lang="ts">
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
import { loggerFor } from '@amp/web-apps-logger';
const logger = loggerFor('components/MotionArtwork');
type HLSError = {
type: string;
message: string;
details: string;
fatal: boolean;
handled: boolean;
};
type MotionArtworkError = {
type: string;
reason: string;
fatal: boolean;
error?: Error;
};
/** HTML `id` attribute for the <video /> element */
export let id: string;
/** Source URL for the video, an HLS playlist ending in .m3u8 */
export let src: string;
/** Poster image to show while the video is loading */
export let poster: string | undefined;
/** If the video should loop from end to start. */
export let loop: boolean = true;
/** If the audio should be muted on the video. */
export let muted: boolean = true;
/** If the video should be paused when initially loaded. */
export let paused: boolean = true;
/** The constructor to use for creating an Hls playback session. */
export let HLS: Window['Hls'] = window.Hls;
/** RTCReportingAgent instance for RTC reporting on video playback. */
export let reportingAgent: any = undefined;
/** HTMLVideoElement used by HLS.js to render the video */
export let videoElement: HTMLVideoElement | null = null;
/** Internal error state for the component */
let errorState: MotionArtworkError | undefined;
let hlsSession: Window['Hls'] | undefined;
/** Dispatcher for errors. */
const dispatch = createEventDispatcher<{ error: MotionArtworkError }>();
function handleError(details: MotionArtworkError) {
logger.error(
`Error playing MotionArtwork with HLS: ${details?.reason}`,
details?.error,
);
errorState = {
type: details.type,
reason: details.reason,
fatal: details.fatal,
error: details?.error,
};
dispatch('error', errorState);
}
const hlsSupported = HLS?.isSupported() ?? false;
onMount(function () {
if (!hlsSupported) {
handleError({
type: 'runtime',
reason: 'unsupported',
fatal: true,
});
return;
}
// Create a new HLS.js playback session
hlsSession = new HLS({
debug: false,
debugLevel: 'error',
enablePerformanceLogging: false,
nativeControlsEnabled: false,
appData: {
reportingAgent: reportingAgent,
serviceName: reportingAgent?.ServiceName,
},
});
hlsSession.on(
HLS.Events.ERROR,
function (_event: string, error: HLSError) {
handleError({
type: 'hls',
reason: error.message,
fatal: error.fatal,
error: error as unknown as Error,
});
},
);
// Direct HLS.js to the VideoElement to use and start loading the video source
hlsSession.attachMedia(videoElement);
hlsSession.loadSource(src, {
/* HLS.js loading options go here */
});
});
onDestroy(() => {
// Stop the video, release resources, and destroy the HLS context
hlsSession?.destroy();
});
</script>
{#if errorState !== undefined}
<slot name="error" error={errorState} {poster} />
{:else}
<!-- svelte-ignore a11y-media-has-caption -->
<video
{id}
{loop}
{poster}
preload="none"
data-loop={true}
playsinline={true}
controls={false}
bind:this={videoElement}
bind:muted
bind:paused
on:play
on:ended
on:loadedmetadata
/>
{/if}
<style>
video {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center center;
aspect-ratio: var(--aspect-ratio);
}
</style>

View File

@@ -0,0 +1,68 @@
<script lang="ts">
import {
type Page,
hasVisionProUrl,
isAppEventDetailPage,
isArticlePage,
isChartsHubPage,
isGenericPage,
isSearchLandingPage,
isShelfBasedProductPage,
isTopChartsPage,
isTodayPage,
isSearchResultsPage,
isStaticMessagePage,
isSeeAllPage,
isErrorPage,
} from '~/jet/models';
import AppEventDetailPage from './pages/AppEventDetailPage.svelte';
import ArticlePage from './pages/ArticlePage.svelte';
import ChartsHubPage from './pages/ChartsHubPage.svelte';
import DefaultPage from './pages/DefaultPage.svelte';
import ErrorPage from './pages/ErrorPage.svelte';
import ProductPage from './pages/ProductPage.svelte';
import VisionProPage from './pages/VisionProPage.svelte';
import StaticMessagePageComponent from './pages/StaticMessagePage.svelte';
import SearchLandingPage from './pages/SearchLandingPage.svelte';
import SearchResultsPage from './pages/SearchResultsPage.svelte';
import TopChartsPage from './pages/TopChartsPage.svelte';
import TodayPage from './pages/TodayPage.svelte';
import SeeAllPage from './pages/SeeAllPage.svelte';
import MetaTags from '~/components/structure/MetaTags.svelte';
import PageModal from '~/components/PageModal.svelte';
export let page: Page;
</script>
<MetaTags {page} />
<PageModal />
{#if isAppEventDetailPage(page)}
<AppEventDetailPage {page} />
{:else if isArticlePage(page)}
<ArticlePage {page} />
{:else if isChartsHubPage(page)}
<ChartsHubPage {page} />
{:else if isSearchLandingPage(page)}
<SearchLandingPage {page} />
{:else if isSearchResultsPage(page)}
<SearchResultsPage {page} />
{:else if isShelfBasedProductPage(page)}
<ProductPage {page} />
{:else if isTopChartsPage(page)}
<TopChartsPage {page} />
{:else if isGenericPage(page) && hasVisionProUrl(page)}
<VisionProPage {page} />
{:else if isTodayPage(page)}
<TodayPage {page} />
{:else if isStaticMessagePage(page)}
<StaticMessagePageComponent {page} />
{:else if isSeeAllPage(page)}
<SeeAllPage {page} />
{:else if isErrorPage(page)}
<ErrorPage {page} />
{:else}
<DefaultPage {page} />
{/if}

View File

@@ -0,0 +1,82 @@
<script lang="ts">
import { onMount, type SvelteComponent } from 'svelte';
import type { GenericPage } from '@jet-app/app-store/api/models';
import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
import { getModalPageStore } from '~/stores/modalPage';
import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
import ContentModal from '~/components/jet/item/ContentModal.svelte';
import { LICENSE_AGREEMENT_MODAL_ID } from '~/utils/metrics';
let modalElement: SvelteComponent;
let modalPage = getModalPageStore();
let page: GenericPage | undefined;
$: page = $modalPage?.page;
$: shelves = page?.shelves ?? [];
$: title = page?.title ?? null;
$: targetId =
$modalPage?.pageDetail === 'licenseAgreement'
? LICENSE_AGREEMENT_MODAL_ID
: undefined;
onMount(() => {
return modalPage.clearPage;
});
$: {
if ($modalPage) {
modalElement?.showModal();
} else {
handleModalClose();
}
}
function handleModalClose() {
modalElement?.close();
modalPage.clearPage();
}
</script>
<Modal
modalTriggerElement={null}
bind:this={modalElement}
on:close={handleModalClose}
>
<div class="modal-content">
{#if page}
<ContentModal
{title}
subtitle={null}
on:close={handleModalClose}
{targetId}
>
<svelte:fragment slot="content">
{#each shelves as shelf}
<ShelfComponent {shelf}>
<slot
name="marker-shelf"
slot="marker-shelf"
let:shelf
{shelf}
/>
</ShelfComponent>
{/each}
</svelte:fragment>
</ContentModal>
{/if}
</div>
</Modal>
<style lang="scss">
.modal-content :global(p) {
user-select: text;
margin-bottom: 15px;
overflow-wrap: break-word;
}
:global(.noscroll) {
overflow: hidden;
touch-action: none;
}
</style>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import type { Page } from '~/jet/models';
import PageComponent from '~/components/Page.svelte';
import ErrorComponent from '~/components/Error.svelte';
import LoadingSpinner from '@amp/web-app-components/src/components/LoadingSpinner/LoadingSpinner.svelte';
export let page: Promise<Page> | Page;
export let isFirstPage: boolean;
</script>
{#await page}
<div data-testid="page-loading">
<!--
Delay showing the spinner on initial page load after app boot.
After that, the FlowAction handler already waits 500ms before
it changes DOM, so we only need to wait 1000ms.
-->
<LoadingSpinner delay={isFirstPage ? 1500 : 1000} />
</div>
{:then page}
<PageComponent {page} />
{:catch error}
<ErrorComponent {error} />
{/await}

View File

@@ -0,0 +1,188 @@
<script lang="ts">
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
import { getI18n } from '~/stores/i18n';
const i18n = getI18n();
</script>
<aside>
<div class="arcade-banner">
<div class="metadata-container">
<div class="logo-container">
<AppleArcadeLogo />
</div>
<h2>
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
<br />
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
</h2>
<a href="https://www.apple.com/apple-arcade/" target="_blank">
<span>
{$i18n.t(
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
)}
</span>
{$i18n.t(
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
)}
</a>
</div>
</div>
</aside>
<style lang="scss">
@use '@amp/web-shared-styles/app/core/globalvars' as *;
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use 'ac-sasskit/core/locale' as *;
@use 'ac-sasskit/modules/viewportcontent/core' as *;
@use 'amp/stylekit/core/viewports' as *;
.logo-container {
width: 62px;
margin-bottom: 10px;
line-height: 0;
@media (--range-xsmall-only) {
width: 48px;
margin-bottom: 8px;
}
}
.logo-container :global(path) {
color: var(--systemPrimary-onDark);
}
.metadata-container {
display: flex;
flex-direction: column;
justify-content: center;
width: 60%;
height: 100%;
padding: 0 20px;
@media (--range-xsmall-only) {
align-items: flex-start;
justify-content: center;
}
}
h2 {
margin-bottom: 10px;
font: var(--title-1-emphasized);
@media (--range-xsmall-only) {
margin-bottom: 8px;
font: var(--title-3-emphasized);
}
}
a {
display: flex;
font: var(--title-3-emphasized);
@media (--range-xsmall-only) {
font: var(--body-emphasized);
}
}
a::after {
content: '↗';
font-weight: normal;
margin-inline-start: 4px;
}
a:hover {
text-decoration: none;
}
a:hover span {
text-decoration: underline;
}
aside {
width: 100%;
max-width: calc(viewport-content-for(xlarge));
height: 152px;
margin: 0 auto 32px;
padding: 0 var(--bodyGutter);
@media (--range-xsmall-only) {
max-width: 100%;
padding: 0;
}
}
.arcade-banner {
width: 100%;
height: 100%;
color: var(--systemPrimary-onDark);
border-radius: var(--global-border-radius-medium);
background: #000;
background-repeat: no-repeat;
background-position: right;
background-size: contain;
@media (prefers-color-scheme: dark) {
border: 1px solid var(--systemQuaternary-onDark);
}
@media (--range-xsmall-only) {
border-radius: 0;
background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
background-size: cover;
@include rtl {
background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
background-position: left;
}
@media (resolution >= 192dpi) {
background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
background-position: left;
}
}
}
@media (--range-small-only) {
background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
background-position: left;
}
@media (resolution >= 192dpi) {
background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
background-position: left;
}
}
}
@media (--range-medium-up) {
background-image: url('/assets/images/arcade/upsell/banner-980@1x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/banner-980@1x_RTL.png');
background-position: left;
}
@media (resolution >= 192dpi) {
background-image: url('/assets/images/arcade/upsell/banner-980@2x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/banner-980@2x_RTL.png');
background-position: left;
}
}
}
}
</style>

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
import { getI18n } from '~/stores/i18n';
const i18n = getI18n();
</script>
<article>
<div class="metadata-container">
<div class="logo-container">
<AppleArcadeLogo />
</div>
<h2>
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
<br />
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
<br />
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
</h2>
<a href="https://www.apple.com/apple-arcade/" target="_blank">
<span>
{$i18n.t(
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
)}
</span>
{$i18n.t(
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
)}
</a>
</div>
</article>
<style lang="scss">
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use 'ac-sasskit/core/locale' as *;
@use 'ac-sasskit/modules/viewportcontent/core' as *;
@use 'amp/stylekit/core/viewports' as *;
.logo-container {
width: 72px;
margin-bottom: 20px;
line-height: 0;
@media (--range-xsmall-only) {
width: 62px;
margin-bottom: 16px;
}
}
.logo-container :global(path) {
color: var(--systemPrimary-onDark);
}
.metadata-container {
display: flex;
flex-direction: column;
justify-content: center;
width: 60%;
height: 100%;
padding: 40px;
@media (--range-xsmall-only) {
align-items: center;
justify-content: end;
width: unset;
text-align: center;
}
}
h2 {
margin-bottom: 20px;
font: var(--header-emphasized);
line-height: 54px;
@media (--range-xsmall-only) {
font: var(--title-1-emphasized);
}
}
a {
display: flex;
font: var(--title-3-emphasized);
}
a::after {
content: '↗';
font-weight: normal;
margin-inline-start: 4px;
}
a:hover {
text-decoration: none;
}
a:hover span {
text-decoration: underline;
}
article {
flex-grow: 1;
width: 100%;
max-width: calc(viewport-content-for(xlarge) - var(--bodyGutter) * 2);
aspect-ratio: 2.55;
margin: 0 auto;
color: var(--systemPrimary-onDark);
background: #000;
background-size: cover;
@media (--range-xsmall-only) {
max-width: 338px;
aspect-ratio: 35/48;
border-radius: var(--global-border-radius-medium);
background-image: url('/assets/images/arcade/upsell/footer-280@1x.png');
@media (resolution >= 192dpi) {
background-image: url('/assets/images/arcade/upsell/footer-280@2x.png');
}
}
@media (--range-small-only) {
aspect-ratio: 173/96;
background-image: url('/assets/images/arcade/upsell/footer-692@1x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/footer-692@1x_RTL.png');
}
@media (resolution >= 192dpi) {
background-image: url('/assets/images/arcade/upsell/footer-692@2x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/footer-692@2x_RTL.png');
}
}
}
@media (--range-medium-up) {
background-image: url('/assets/images/arcade/upsell/footer-980@1x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/footer-980@1x_RTL.png');
}
@media (resolution >= 192dpi) {
background-image: url('/assets/images/arcade/upsell/footer-980@2x_LTR.png');
@include rtl {
background-image: url('/assets/images/arcade/upsell/footer-980@2x_RTL.png');
}
}
}
@media (--range-xlarge-up) {
border-radius: var(--global-border-radius-medium);
}
}
</style>

View File

@@ -0,0 +1,51 @@
<!--
@component
Renders a supported "SF Symbol" from the icons available in `~/sf-symbols`
-->
<script lang="ts" context="module">
import type { ComponentType } from 'svelte';
const iconComponents = import.meta.glob('~/sf-symbols/*.svg', {
eager: true,
import: 'default',
});
const iconNameToComponent: Record<string, ComponentType | undefined> =
Object.fromEntries(
Object.entries(iconComponents).map(
([fullPathToIcon, iconComponent]) => {
const iconName = fullPathToIcon
.replace('/src/sf-symbols/', '')
.replace('.svg', '');
return [iconName, iconComponent as ComponentType];
},
),
);
/**
* The list of all supported icons
*
* This is exposed only for testing/Storybook purposes
*/
export const __iconNames = Object.keys(iconNameToComponent);
export function getIconComponentByName(iconName: string) {
return iconNameToComponent[iconName];
}
</script>
<script lang="ts">
/**
* The name of the SF Symbol to render
*
* Must match the name of an `.svg` file in `~/sf-symbols`. If a file with a matching
* name does not exist, nothing will be rendered
*/
export let name: string;
export let ariaHidden: boolean = false;
$: icon = getIconComponentByName(name);
</script>
<svelte:component this={icon} aria-hidden={ariaHidden ? 'true' : 'false'} />

View File

@@ -0,0 +1,90 @@
<script lang="ts" context="module">
export function isShareSupported() {
return (
typeof navigator !== 'undefined' &&
typeof navigator.share === 'function'
);
}
</script>
<script lang="ts">
import SFSymbol from '~/components/SFSymbol.svelte';
import { getI18n } from '~/stores/i18n';
export let url: string;
export let withLabel: boolean = false;
const i18n = getI18n();
$: isShareSheetOpen = false;
async function handleShareClick() {
isShareSheetOpen = !isShareSheetOpen;
try {
await navigator.share({ url });
isShareSheetOpen = false;
} catch {
isShareSheetOpen = false;
}
}
</script>
<button
on:click={handleShareClick}
aria-label={$i18n.t('ASE.Web.AppStore.Share.Button.AccessibilityValue')}
class:active={isShareSheetOpen}
class:with-label={withLabel}
>
<SFSymbol name="square.and.arrow.up" ariaHidden={true} />
{#if withLabel}
{$i18n.t('ASE.Web.AppStore.Share.Button.Value')}
{/if}
</button>
<style lang="scss">
button {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: var(--share-arrow-size, 32px);
height: var(--share-arrow-size, 32px);
border-radius: var(--share-arrow-size, 32px);
background: var(--systemQuaternary-onDark);
transition: background-color 210ms ease-out;
mix-blend-mode: plus-lighter;
}
button.with-label {
display: flex;
align-items: center;
width: auto;
padding: 0 16px;
gap: 8px;
font: var(--body-emphasized);
:global(svg) {
height: 16px;
width: auto;
top: -2px;
position: relative;
}
}
button.active,
button:hover {
// stylelint-disable color-function-notation
background-color: rgb(from var(--systemTertiary-onDark) r g b/.13);
// stylelint-enable color-function-notation
}
button :global(svg) {
width: 37%;
fill: var(--systemPrimary-onDark);
overflow: visible;
}
</style>

View File

@@ -0,0 +1,112 @@
<!--
@component
Renders the "Title" and "See All action" for a `Shelf`
### Supported CSS Variables
- `--shelf-title-font`: overrides the font used for the "title" element
-->
<script lang="ts">
import { type Opt, isSome } from '@jet/environment/types/optional';
import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
import SFSymbol from '~/components/SFSymbol.svelte';
import LinkWrapper from '../LinkWrapper.svelte';
export let title: string;
export let subtitle: Opt<string> = undefined;
export let seeAllAction: Opt<Action> = undefined;
</script>
<div class="title-action-wrapper" class:with-subtitle={!!subtitle}>
<LinkWrapper action={seeAllAction} label={title}>
<div class="link-contents">
<h2 class="shelf-title" data-test-id="shelf-title">{title}</h2>
{#if isSome(seeAllAction) && isFlowAction(seeAllAction)}
<span
class="chevron-container"
data-test-id="shelf-see-all-chevron"
aria-hidden="true"
>
<SFSymbol name="chevron.forward" />
</span>
{/if}
</div>
</LinkWrapper>
</div>
{#if subtitle}
<p>{subtitle}</p>
{/if}
<style lang="scss">
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use 'ac-sasskit/core/helpers' as *;
@use 'ac-sasskit/core/locale' as *;
.title-action-wrapper {
display: flex;
align-items: end;
justify-content: space-between;
margin: 0 var(--bodyGutter) 13px;
}
.title-action-wrapper.with-subtitle {
margin-bottom: 3px;
}
.title-action-wrapper :global(a:hover) {
text-decoration: none;
}
p {
font: var(--title-3-tall);
color: var(--systemSecondary);
margin: 0 var(--bodyGutter) 13px;
}
h2 {
color: var(--systemPrimary, #000);
font: var(--shelf-title-font, var(--title-2-emphasized));
text-wrap: pretty;
}
.link-contents {
display: flex;
align-items: center;
gap: 6px;
}
.chevron-container {
line-height: 0;
padding: 6px 0 4px;
display: block;
}
.chevron-container :global(svg) {
height: 12px;
display: block;
translate: 0 0;
transition: translate 210ms ease-out;
@include rtl {
transform: rotate(180deg);
}
}
.chevron-container :global(svg path:not([fill='none'])) {
fill: var(--systemGray2);
}
.link-contents:hover .chevron-container :global(svg) {
translate: 1px 0;
@include rtl {
transform: rotate(180deg);
translate: -1px 0;
}
}
</style>

View File

@@ -0,0 +1,81 @@
<script lang="ts">
import type { Shelf } from '@jet-app/app-store/api/models';
import ShelfTitle from '~/components/Shelf/Title.svelte';
export let shelf: Shelf | undefined = undefined;
/**
* Whether or not to automatically display the shelf "centered" at the normal
* page width for the App Store
*
* When `false`, the shelf is not constrained horizontally in any way
*
* The value of this property may be ignored when the shelf's `.presentationHints`
* indicate that it is being rendered in a context where "centering" would not be
* appropriate
*
* @default true
*/
export let centered: boolean = false;
export let withTopBorder: boolean = false;
export let withTopMargin: boolean = false;
export let withPaddingTop: boolean = true;
export let withBottomPadding: boolean = true;
$: seeAllAction =
shelf?.header?.titleAction ??
shelf?.header?.accessoryAction ??
shelf?.seeAllAction;
</script>
<section
id={shelf?.id}
data-test-id="shelf-wrapper"
class="shelf"
class:centered
class:border-top={withTopBorder}
class:margin-top={withTopMargin}
class:padding-top={withPaddingTop}
class:padding-bottom={withBottomPadding}
>
{#if $$slots['title']}
<slot name="title" />
{:else if shelf?.header?.title}
<ShelfTitle
title={shelf.header.title}
subtitle={shelf.header.subtitle}
{seeAllAction}
/>
{:else if shelf?.title}
<ShelfTitle
title={shelf.title}
subtitle={shelf.subtitle}
{seeAllAction}
/>
{/if}
<slot />
</section>
<style>
.padding-top {
padding-top: 13px;
}
.centered {
margin: 0 var(--bodyGutter);
}
.margin-top {
margin-top: 13px;
}
.border-top {
border-top: 1px solid var(--systemGray4);
}
.shelf.padding-bottom {
padding-bottom: 32px;
}
</style>

View File

@@ -0,0 +1,103 @@
<!--
@component
Renders a set of `Shelf` items in either a horizontal shelf
or a grid, depending on the `shelf` configuration
Note: when configuring the `gridType` property, a single value will be used
for both the shelf-based or grid-based item layouts. If two different grid types
are needed instead, `gridTypeForShelf` and `gridTypeForGrid` are needed instead;
these properties cannot be used alongside the general-purpose `gridType`.
-->
<script lang="ts" generics="Item">
import type { Shelf } from '@jet-app/app-store/api/models';
import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
import type { XOR } from '~/utils/types';
import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
import Grid from '~/components/Grid.svelte';
/**
* The sub-set of {@linkcode Shelf} that is necesary to render this component
*/
interface RequiredShelf
extends Pick<Shelf, 'rowsPerColumn' | 'isHorizontal'> {
items: Item[];
}
interface $$Slots {
default: {
item: Item;
};
}
/**
* Represents the `gridType` properties of this component
*
* Either a `gridType` that will be used for both the shelf or grid
* layouts can be provided, OR specific properties for the grid type
* for the shelf and grid respectively; this `XOR` here prevents
* these approachs from being mixed-and-matched.
*/
type GeneralOrIndividualGridType = XOR<
{
gridType: GridType;
},
{
gridTypeForGrid: GridType;
gridTypeForShelf: GridType;
}
>;
type $$Props = GeneralOrIndividualGridType & {
shelf: RequiredShelf;
rowsPerColumnOverride?: number | null;
};
/**
* The shelf to render items for
*/
export let shelf: RequiredShelf;
/**
* An optional override of the shelfs `rowsPerColumn` property
*/
export let rowsPerColumnOverride: number | null = null;
/**
* Determine the grid type configuration for the shelf or grid layouts
* based on the mutually-exclusive properties of {@linkcode GeneralOrIndividualGridType}
*/
function extractGridTypes(props: $$Props) {
if (typeof props.gridType === 'string') {
return {
gridTypeForShelf: props.gridType,
gridTypeForGrid: props.gridType,
};
} else {
return props;
}
}
$: ({ gridTypeForShelf, gridTypeForGrid } = extractGridTypes(
$$props as $$Props,
));
$: isHorizontal = shelf.isHorizontal;
$: gridRows = rowsPerColumnOverride ?? shelf.rowsPerColumn ?? undefined;
</script>
{#if isHorizontal}
<HorizontalShelf
items={shelf.items}
{gridRows}
gridType={gridTypeForShelf}
let:item
>
<slot {item} />
</HorizontalShelf>
{:else}
<Grid items={shelf.items} gridType={gridTypeForGrid} let:item>
<slot {item} />
</Grid>
{/if}

View File

@@ -0,0 +1,80 @@
<script lang="ts" context="module">
export function calculateStarFillPercentages(rating: number) {
return [1, 2, 3, 4, 5].map((position) => {
if (position <= Math.floor(rating)) {
return 100;
}
if (position > Math.ceil(rating)) {
return 0;
}
return Math.round((rating % 1) * 100);
});
}
</script>
<script lang="ts">
import StarFilledIcon from '@amp/web-app-components/assets/icons/star-filled.svg';
import StarHollowIcon from '@amp/web-app-components/assets/icons/star-hollow.svg';
import { getI18n } from '~/stores/i18n';
export let rating: number;
const i18n = getI18n();
$: starFillPercentages = calculateStarFillPercentages(rating);
$: label = $i18n.t('ASE.Web.AppStore.Review.StarsAria', {
count: rating,
});
</script>
<ol class="stars" aria-label={label}>
{#each starFillPercentages as fillPercent}
<li class="star">
{#if fillPercent === 100}
<StarFilledIcon />
{:else if fillPercent === 0}
<StarHollowIcon />
{:else}
<div
class="partial-star"
style:--partial-star-width={`${fillPercent}%`}
>
<StarFilledIcon />
</div>
<StarHollowIcon />
{/if}
</li>
{/each}
</ol>
<style>
.stars {
display: flex;
}
.star {
position: relative;
margin-inline-end: 2px;
line-height: 0;
}
.star :global(svg) {
height: var(--star-size, 10px);
width: var(--star-size, 10px);
fill: var(--fill-color, rgb(127, 127, 127));
}
.partial-star {
position: absolute;
overflow: hidden;
width: var(--partial-star-width);
fill: var(--fill-color, rgb(127, 127, 127));
}
.partial-star :global(path) {
stroke: transparent;
}
</style>

View File

@@ -0,0 +1,52 @@
<!--
@component
Renders an `Artwork` view model that references an SF Symbol through a `systemimage://` or `resource://` template URL
-->
<script lang="ts" context="module">
import type { Artwork } from '@jet-app/app-store/api/models';
const systemImagePrefix = 'systemimage://';
const resourcePrefix = 'resource://';
type SystemImageTemplate = `${typeof systemImagePrefix}${string}`;
type ResourceTemplate = `${typeof resourcePrefix}${string}`;
/**
* An {@linkcode Artwork} that references a system image
*/
interface FullSystemImageArtwork extends Artwork {
template: SystemImageTemplate | ResourceTemplate;
}
/**
* The sub-set of {@linkcode FullSystemImageArtwork} required to render
* the icon
*/
type SystemImageArtwork = Pick<FullSystemImageArtwork, 'template'>;
/**
* Determine if some {@linkcode Artwork} represents a "system image"
*/
export function isSystemImageArtwork(
artwork: Artwork,
): artwork is FullSystemImageArtwork {
return (
artwork.template.startsWith(systemImagePrefix) ||
artwork.template.startsWith(resourcePrefix)
);
}
export function getIconNameFromTemplate(template: string) {
return new URL(template).host;
}
</script>
<script lang="ts">
import SFSymbol from '~/components/SFSymbol.svelte';
export let artwork: SystemImageArtwork;
$: name = getIconNameFromTemplate(artwork.template);
</script>
<SFSymbol {name} />

View File

@@ -0,0 +1,412 @@
<script lang="ts">
import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
import MotionArtwork from '~/components/MotionArtwork.svelte';
import { getJet } from '~/jet';
import { getI18n } from '~/stores/i18n';
import type { Video } from '@jet-app/app-store/api/models';
import {
MetricsActionDetails,
MetricsActionType,
type MetricsActionDetailItem,
type MetricsActionTypeItem,
} from '~/constants/media-metrics';
/** HTML `id` attribute for the <video /> element */
export let id: string;
/** Source URL for the video, an HLS playlist ending in .m3u8 */
export let src: string;
/** Poster image to show while the video is loading */
export let poster: string | undefined;
/** If the video should play automatically when in view */
export let autoplay: boolean = false;
/* The whole-number percentage amount of the video needs to be in view before autoplay kicks in */
export let autoplayVisibilityThreshold: number = 0;
/** If the video should loop from end to start. */
export let loop: boolean = false;
/** If the audio should be muted on the video. */
export let muted: boolean = true;
/** If our controls should be shown in the video player. */
export let useControls: boolean = true;
/** The constructor to use for creating an Hls playback session. */
export let HLS: Window['Hls'] = window.Hls;
/**
* If we should bypass the `poster` attribute on the `video` tag, in favor of having the poster
* image overlaid as it's own DOM element, which covers an HLS playback bug in Safari, wherein
* the video is seeked to the first frame once the metadata is loaded, thus removing the poster.
*/
export let shouldSuperimposePosterImage: boolean = false;
/** an optional metric template provided by jet */
export let metricsTemplate:
| Record<string, unknown>
| Video['templateMediaEvent'] = {};
export function play(isAutoPlay = true) {
videoRef?.play();
recordMediaEvent(
MetricsActionType.PLAY,
isAutoPlay
? MetricsActionDetails.AUTOPLAY
: MetricsActionDetails.PLAY,
);
}
export function pause(isAutoPause = true) {
recordMediaEvent(
MetricsActionType.STOP,
isAutoPause
? MetricsActionDetails.AUTOPAUSE
: MetricsActionDetails.PAUSE,
);
videoRef?.pause();
}
let isPaused: boolean = !autoplay;
let isMuted: boolean = muted;
let shouldShowReplayControl: boolean = false;
let shouldShowPlaybackControls: boolean = true;
let hasPlaybackBeenInitiated: boolean = false;
let videoRef: HTMLVideoElement | null = null;
const i18n = getI18n();
const jet = getJet();
const handleFullScreenButtonClick = () => {
videoRef?.requestFullscreen();
};
const handleReplayButtonClick = () => {
if (videoRef) {
videoRef.currentTime = 0;
videoRef.play();
shouldShowPlaybackControls = true;
}
};
const handlePlayButtonClick = () => {
if (isPaused) {
play(false);
} else {
pause(false);
}
};
const handleMuteButtonClick = () => {
isMuted = !isMuted;
};
const handleVideoEnded = () => {
if (!loop) {
shouldShowPlaybackControls = true;
if (videoRef) {
videoRef.currentTime = 1;
videoRef.pause();
}
recordMediaEvent(
MetricsActionType.STOP,
MetricsActionDetails.COMPLETE,
);
}
};
const handleVideoPlay = () => {
// Display the replay button after the first play
shouldShowReplayControl = true;
hasPlaybackBeenInitiated = true;
};
// metric events that are waiting for loadMetadata from video element
let queuedMetricEvents: Array<() => void> = [];
// flush any metric events once load metadata has been called
const flushMetricEvents = () => {
queuedMetricEvents.forEach((recordFn) => recordFn());
queuedMetricEvents = [];
};
const recordMediaEvent = (
actionType: MetricsActionTypeItem,
actionDetail: MetricsActionDetailItem,
) => {
if (!metricsTemplate?.fields) {
return;
}
const recordEvent = () => {
const duration = Math.floor(videoRef?.duration ?? 0) * 1000;
const position = Math.min(
Math.floor((videoRef?.currentTime ?? 0) * 1000),
duration,
);
jet.recordCustomMetricsEvent({
...(metricsTemplate?.fields ?? {}),
actionType: actionType,
actionDetails: actionDetail,
url: src,
duration,
position,
topic: metricsTemplate?.topic ?? '',
});
};
if (Number.isNaN(videoRef?.duration)) {
queuedMetricEvents.push(() => recordEvent());
} else {
recordEvent();
}
};
const isVideoPlaying = (video: HTMLVideoElement | null) => {
if (!video) {
return false;
}
return !!(
video.currentTime > 0 &&
!video.paused &&
!video.ended &&
video.readyState > 2
);
};
const intersectionObserverConfig = {
threshold: autoplayVisibilityThreshold,
callback: (isIntersectingViewport: boolean) => {
if (isIntersectingViewport) {
play();
} else if (isVideoPlaying(videoRef)) {
pause();
}
},
};
</script>
<div
class="video-container"
use:intersectionObserver={autoplay ? intersectionObserverConfig : undefined}
>
<div class="video">
<MotionArtwork
{id}
{HLS}
{src}
{loop}
poster={!shouldSuperimposePosterImage ? poster : undefined}
bind:muted={isMuted}
bind:paused={isPaused}
bind:videoElement={videoRef}
on:play={handleVideoPlay}
on:ended={handleVideoEnded}
on:loadedmetadata={flushMetricEvents}
/>
</div>
{#if shouldSuperimposePosterImage && !hasPlaybackBeenInitiated}
<img
src={poster}
class="fake-poster"
aria-hidden="true"
loading="lazy"
alt=""
/>
{/if}
{#if useControls}
<div class="video-control">
{#if shouldShowReplayControl}
<button
class="video-control-replay"
aria-label={$i18n.t(
'ASE.Web.AppStore.VideoPlayer.AX.Replay',
)}
on:click={handleReplayButtonClick}
>
<img
class="btn-img"
src="/assets/images/video-control/video-control-replay.png"
alt={$i18n.t('ASE.Web.AppStore.VideoPlayer.AX.Replay')}
aria-hidden="true"
/>
</button>
{/if}
{#if shouldShowPlaybackControls}
<div class="video-control-playback">
<button
class="video-control-play"
aria-label={$i18n.t(
isPaused
? 'ASE.Web.AppStore.VideoPlayer.AX.Play'
: 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
)}
on:click={handlePlayButtonClick}
>
{#if isPaused}
<img
class="btn-img"
src="/assets/images/video-control/video-control-play.png"
alt={$i18n.t(
'ASE.Web.AppStore.VideoPlayer.AX.Play',
)}
aria-hidden="true"
/>
{:else}
<img
class="btn-img"
src="/assets/images/video-control/video-control-pause.png"
alt={$i18n.t(
'ASE.Web.AppStore.VideoPlayer.AX.Pause',
)}
aria-hidden="true"
/>
{/if}
</button>
<button
class="video-control-unmute"
aria-label={$i18n.t(
isMuted
? 'ASE.Web.AppStore.VideoPlayer.AX.Unmute'
: 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
)}
on:click={handleMuteButtonClick}
>
{#if isMuted}
<img
class="btn-img"
src="/assets/images/video-control/video-control-volume-muted.png"
alt={$i18n.t(
'ASE.Web.AppStore.VideoPlayer.AX.Mute',
)}
aria-hidden="true"
/>
{:else}
<img
class="btn-img"
src="/assets/images/video-control/video-control-volume.png"
alt={$i18n.t(
'ASE.Web.AppStore.VideoPlayer.AX.Unmute',
)}
aria-hidden="true"
/>
{/if}
</button>
<button
class="video-control-fullscreen"
aria-label={$i18n.t(
'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
)}
on:click={handleFullScreenButtonClick}
>
<img
class="btn-img"
src="/assets/images/video-control/video-control-fullscreen.png"
alt={$i18n.t(
'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
)}
aria-hidden="true"
/>
</button>
</div>
{/if}
</div>
{/if}
</div>
<style>
.video-container {
--button-size: 32px;
display: grid;
position: relative;
container-type: inline-size;
container-name: video-container;
width: 100%;
height: 100%;
background-color: var(--systemQuaternary);
}
.video {
width: 100%;
height: 100%;
grid-column: 1;
grid-row: 1;
line-height: 0;
}
.video-control {
grid-column: 1;
grid-row: 1;
display: inline-flex;
justify-content: space-between;
z-index: 1;
align-self: end;
color: white;
margin: 0 12px 12px;
}
.video-control::after {
position: absolute;
content: '';
z-index: -1;
bottom: 0;
left: 0;
display: block;
box-sizing: border-box;
width: 100%;
height: calc(var(--button-size) * 2);
background: linear-gradient(
0deg,
rgb(0, 0, 0, 0.68),
rgb(0, 0, 0, 0.2),
transparent
);
mask-image: linear-gradient(360deg, #000 47%, transparent);
}
.video-control-playback {
display: inline-flex;
margin-inline-start: auto;
gap: 6px;
}
.btn-img {
height: var(--button-size);
width: var(--button-size);
border-radius: 50%;
border: 1px solid var(--systemQuaternary-onDark);
background: rgba(0, 0, 0, 0.11);
backdrop-filter: blur(20px);
object-fit: cover;
transition: background 105ms ease-out;
}
.btn-img:hover {
background: rgba(0, 0, 0, 0.05);
}
@container video-container (max-width: 500px) {
.btn-img {
--button-size: 24px;
}
}
.fake-poster {
width: 100%;
position: absolute;
top: 0;
left: 0;
}
</style>

View File

@@ -0,0 +1,67 @@
<script lang="ts" context="module">
// This store is used to keep track of in-flight requests, ensuring that we don't attempt
// to load the same src (which is stored in the Map key) multiple times.
const inFlightRequests = new Map<string, Promise<void>>();
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { generateHLSJSURL } from '~/config/hlsjs';
import { generateRTCJSURL } from '~/config/rtcjs';
export let version: string | undefined = undefined;
let hlsjsSourceURL = generateHLSJSURL(version).toString();
let rtcjsSourceURL = generateRTCJSURL(version).toString();
function loadScript(src: string): Promise<void> {
// If we have an in-flight request for this `src`, return it.
const inFlightRequest = inFlightRequests.get(src);
if (inFlightRequest) {
return inFlightRequest;
}
const promise = new Promise<void>(function (resolve, reject) {
const scriptElement = document.createElement('script');
scriptElement.src = src;
scriptElement.onload = () => resolve();
scriptElement.onerror = () => {
// If a script fails to load due to a network blip, we remove it from the store,
// so that the next attempt in an `onMount` will try to load the `src` again.
inFlightRequests.delete(src);
reject();
};
document.head.appendChild(scriptElement);
});
// Add the given `src` to the store so we can keep track of in-flight requests.
inFlightRequests.set(src, promise);
return promise;
}
let loading: Promise<[void, void]> | undefined;
onMount(() => {
loading = Promise.all([
window.Hls ?? loadScript(hlsjsSourceURL),
window.rtc ?? loadScript(rtcjsSourceURL),
]);
});
</script>
{#if loading}
{#await loading}
<slot name="loading-component" />
{:then}
<slot HLS={window.Hls} RTC={window.rtc} />
{:catch}
<div>
<p>
Failed to load HLS.js {version} from
<a href={hlsjsSourceURL}>{hlsjsSourceURL}</a>
</p>
</div>
{/await}
{/if}

View File

@@ -0,0 +1,109 @@
<!--
@component
Component for rendering App information into the `details` slot
of the `Hero.svelte` component
-->
<script lang="ts">
import type { Lockup } from '@jet-app/app-store/api/models';
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
import { getI18n } from '~/stores/i18n';
import AppIcon from '~/components/AppIcon.svelte';
const i18n = getI18n();
export let lockup: Lockup;
export let isOnDarkBackground: boolean = true;
</script>
<div class="lockup-container">
{#if lockup.icon}
<div class="app-icon-container">
<AppIcon icon={lockup.icon} profile="app-icon-small" />
</div>
{/if}
<div class="text-container">
{#if lockup.heading}
<LineClamp clamp={1}>
<h4>{lockup.heading}</h4>
</LineClamp>
{/if}
{#if lockup.title}
<LineClamp clamp={2}>
<h3>{lockup.title}</h3>
</LineClamp>
{/if}
{#if lockup.subtitle}
<LineClamp clamp={1}>
<p>{lockup.subtitle}</p>
</LineClamp>
{/if}
</div>
<div class="button-container">
<span
class="get-button"
class:transparent={isOnDarkBackground}
class:dark-gray={!isOnDarkBackground}
>
{$i18n.t('ASE.Web.AppStore.View')}
</span>
</div>
</div>
<style lang="scss">
.lockup-container {
display: flex;
align-items: center;
width: 100%;
max-width: 350px;
margin-top: 20px;
padding-top: 20px;
color: var(--hero-primary-color, var(--systemPrimary-onDark));
border-top: 1px solid
var(--hero-divider-color, var(--systemQuaternary-onDark));
@media (--range-xsmall-down) {
text-align: left;
padding: 20px 0 10px;
max-width: unset;
}
}
.app-icon-container {
flex-shrink: 0;
width: 64px;
margin-inline-end: 16px;
}
.text-container {
width: 100%;
margin-inline-end: 16px;
}
h3 {
font: var(--title-3-emphasized);
text-wrap: pretty;
}
h4 {
color: var(--hero-secondary-color, var(--systemSecondary-onDark));
font: var(--subhead-emphasized);
text-transform: uppercase;
mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
}
p {
mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
}
.button-container {
--get-button-font: var(--title-3-bold);
position: relative;
z-index: 1;
}
</style>

View File

@@ -0,0 +1,132 @@
<!--
@component
Component for rendering a carousel of `Hero.svelte` components in a way taht's decoupled from
any particular data model
-->
<script lang="ts" generics="Item">
import type { Opt } from '@jet/environment/types/optional';
import type { Artwork, Shelf } from '@jet-app/app-store/api/models';
import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
import mediaQueries from '~/utils/media-queries';
import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
import HeroCarouselBackgroundPortal, {
id as portalId,
} from '~/components/hero/CarouselBackgroundPortal.svelte';
import AmbientBackgroundArtwork from '~/components/AmbientBackgroundArtwork.svelte';
import portal from '~/utils/portal';
import { carouselMediaStyle } from '~/stores/carousel-media-style';
interface $$Slots {
default: {
/**
* The `Item` to render as a `Hero` in the carousel
*/
item: Item;
};
}
/**
* The shelf being rendered
*
* Used to derrive any shelf-specific presentation
*/
export let shelf: Shelf;
/**
* The items to render in the hero carousel
*
* This is decoupled from `shelf` to avoid assuming that `shelf.items` is, itself,
* the set of items that we need to present; some shelves model their items as chilren
* of the first shelf item.
*/
export let items: Item[];
/**
* Callback that determines the "background artwork" to use behind the
* active `Hero` for the given `Item`
*/
export let deriveBackgroundArtworkFromItem: (item: Item) => Opt<Artwork>;
$: gridRows = shelf.rowsPerColumn ?? undefined;
$: isXSmallViewport = $mediaQueries === 'xsmall';
let activeIndex: number | undefined = 0;
function createIntersectionObserverCallback(index: number) {
return (isIntersectingViewport: boolean) => {
if (isIntersectingViewport) {
// Many different types of `item`s can be rendered in this Carousel, and all those
// different items have different ways of determining whether or not the background
// is dark or light, so we are running through all the options here.
const { style, mediaOverlayStyle, isMediaDark } = items[
index
] as any;
const fallbackStyle = 'dark';
let derivedStyle;
if (typeof isMediaDark !== 'undefined') {
derivedStyle = isMediaDark ? 'dark' : 'light';
}
carouselMediaStyle.set(
style || mediaOverlayStyle || derivedStyle || fallbackStyle,
);
activeIndex = index;
}
};
}
</script>
<HeroCarouselBackgroundPortal />
<ShelfWrapper {shelf} --shelfGridGutterWidth="0">
<HorizontalShelf
{gridRows}
{items}
--shelfScrollPaddingInline="0"
--grid-max-content-xsmall={!$sidebarIsHidden
? 'calc(100% + 50px)'
: '100vw'}
gridType="Spotlight"
let:item
let:index
>
{#if isXSmallViewport}
<div
use:intersectionObserver={{
callback: createIntersectionObserverCallback(index),
threshold: 0.5,
}}
>
<slot {item} />
</div>
{:else}
<div
use:intersectionObserver={{
callback: createIntersectionObserverCallback(index),
threshold: 0,
}}
>
{#if !import.meta.env.SSR}
{@const backgroundArtwork =
deriveBackgroundArtworkFromItem(item)}
{#if backgroundArtwork}
<div use:portal={portalId}>
<AmbientBackgroundArtwork
artwork={backgroundArtwork}
active={activeIndex === index}
/>
</div>
{/if}
{/if}
<slot {item} />
</div>
{/if}
</HorizontalShelf>
</ShelfWrapper>

View File

@@ -0,0 +1,17 @@
<script lang="ts" context="module">
export const id = 'hero-carousel-shelf-background-portal';
</script>
<div {id} />
<style>
#hero-carousel-shelf-background-portal {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow-x: hidden;
z-index: -1;
}
</style>

View File

@@ -0,0 +1,536 @@
<!--
@component
Component for rendering an item in a "Hero Carousel" without coupling to any specific data model
-->
<script lang="ts">
import type { Opt } from '@jet/environment/types/optional';
import type {
Action,
Artwork as ArtworkModel,
Color,
Video as VideoModel,
} from '@jet-app/app-store/api/models';
import mediaQueries from '~/utils/media-queries';
import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion';
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
import AppIcon from '~/components/AppIcon.svelte';
import Artwork from '~/components/Artwork.svelte';
import LinkWrapper from '~/components/LinkWrapper.svelte';
import Video from '~/components/jet/Video.svelte';
import type { NamedProfile } from '~/config/components/artwork';
import {
colorAsString,
getBackgroundGradientCSSVarsFromArtworks,
getLuminanceForRGB,
} from '~/utils/color';
import { isRtl } from '~/utils/locale';
/**
* The main text for the carousel item
*/
export let title: Opt<string> = undefined;
/**
* Additional text above the title.
* Note: If a slot is defined with the name `eyebrow`, the slot takes precedence.
*/
export let eyebrow: Opt<string> = undefined;
/**
* Additional text below the title
*/
export let subtitle: Opt<string> = undefined;
/**
* Primary accent color for the carousel item
*/
export let backgroundColor: Opt<Color> = undefined;
/**
* Static artwork to display in the carousel item
*/
export let artwork: Opt<ArtworkModel> = undefined;
/**
* Video to display in the carousel item
*
* Takes precedence over `artwork`
*/
export let video: Opt<VideoModel> = undefined;
/**
* Action to perform when clicking on the carousel item
*/
export let action: Opt<Action> = undefined;
/**
* Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container
*/
export let pinArtworkToHorizontalEnd: boolean = false;
/**
* Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default)
*/
export let pinArtworkToVerticalMiddle: boolean = false;
/**
* Whether the text (e.g. title, description, etc) should be pinned to the top of the container
*/
export let pinTextToVerticalStart: boolean = false;
/**
* Allows for the absolute overriding of the profile used for the Hero artwork
*/
export let profileOverride: Opt<NamedProfile> = null;
export let isMediaDark: boolean = true;
export let collectionIcons: ArtworkModel[] | undefined = undefined;
let isPortraitLayout: boolean;
let profile: NamedProfile;
let collectionIconsBackgroundGradientCssVars: string | undefined =
undefined;
$: isPortraitLayout = $mediaQueries === 'xsmall';
$: {
if (profileOverride) {
profile = profileOverride;
} else if (isPortraitLayout) {
profile = 'large-hero-portrait';
} else if (pinArtworkToHorizontalEnd && isRtl()) {
profile = 'large-hero-east';
} else if (pinArtworkToHorizontalEnd) {
profile = 'large-hero-west';
} else {
profile = 'large-hero';
}
}
const color: string = backgroundColor
? colorAsString(backgroundColor)
: '#000';
if (collectionIcons && collectionIcons.length > 1) {
// If there are multiple app icons, we build a string of CSS variables from the icons
// background colors to fill as many of the lockups quadrants as possible.
collectionIconsBackgroundGradientCssVars =
getBackgroundGradientCSSVarsFromArtworks(collectionIcons, {
// sorts from darkest to lightest
sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
shouldRemoveGreys: true,
});
}
</script>
<LinkWrapper {action} includeExternalLinkArrowIcon={false}>
<article
data-test-id="hero"
class:with-dark-media={isMediaDark}
class:with-collection-icons={!artwork && !video && collectionIcons}
class:text-pinned-to-vertical-start={pinTextToVerticalStart}
>
{#if video || artwork}
<div
class={`image-container ${profile}`}
class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd}
class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle}
style:--color={color}
>
{#if video && !$prefersReducedMotion}
<Video
loop
autoplay
useControls={false}
{video}
{profile}
/>
{:else if artwork}
<Artwork
{artwork}
{profile}
noShelfChevronAnchor={true}
useCropCodeFromArtwork={false}
withoutBorder={true}
/>
{/if}
</div>
{:else if collectionIcons}
<ul class="app-icons">
{#each collectionIcons?.slice(0, 5) as collectionIcon}
<li class="app-icon-container">
<AppIcon
icon={collectionIcon}
profile="app-icon-large"
fixedWidth={false}
/>
</li>
{/each}
</ul>
<div
class="collection-icons-background-gradient"
style={collectionIconsBackgroundGradientCssVars}
/>
{/if}
<div class="gradient" style="--color: {color};" />
<slot name="badge" {isPortraitLayout} />
<div class="metadata-container">
{#if $$slots.eyebrow}
<h3><slot name="eyebrow" /></h3>
{:else if eyebrow}
<h3>{eyebrow}</h3>
{/if}
{#if title}
<h2>{@html sanitizeHtml(title)}</h2>
{/if}
{#if subtitle}
<p class="subtitle">{@html sanitizeHtml(subtitle)}</p>
{/if}
<slot name="details" {isPortraitLayout} />
</div>
</article>
</LinkWrapper>
<style lang="scss">
@use '@amp/web-shared-styles/app/core/globalvars' as *;
article {
--hero-primary-color: var(--systemPrimary-onLight);
--hero-secondary-color: var(--systemSecondary-onLight);
--hero-text-blend-mode: normal;
--hero-divider-color: var(--systemQuaternary-onLight);
position: relative;
display: flex;
overflow: hidden;
align-items: end;
aspect-ratio: 3 / 4;
container-name: hero-container;
container-type: size;
@media (--range-small-up) {
aspect-ratio: 16 / 9;
width: 100%;
height: auto;
min-height: 360px;
max-height: min(60vh, 770px);
border-radius: var(--global-border-radius-large);
border: 1px solid var(--systemQuaternary);
}
}
article.with-dark-media,
article.with-collection-icons {
--hero-primary-color: var(--systemPrimary-onDark);
--hero-secondary-color: var(--systemSecondary-onDark);
--hero-divider-color: var(--systemQuaternary-onDark);
--hero-text-blend-mode: plus-lighter;
}
.image-container {
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
background-color: var(--color);
}
.image-container.pinned-to-vertical-middle {
display: flex;
align-items: center;
}
.image-container.pinned-to-vertical-middle :global(.video-container),
.image-container.pinned-to-vertical-middle :global(.artwork-component) {
width: 100%;
height: auto;
}
.image-container.pinned-to-horizontal-end :global(.artwork-component) {
height: 100%;
display: flex;
}
.image-container.pinned-to-horizontal-end :global(.artwork-component img) {
height: 100%;
width: auto;
position: absolute;
inset-inline-end: 0;
@container hero-container (aspect-ratio >= 279/100) {
width: 100%;
height: auto;
}
}
.image-container.pinned-to-horizontal-end.large-hero-story-card-rtl
:global(.artwork-component img) {
inset-inline-start: 0;
}
// This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of
// 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the
// artwork full-width rather than full-height. This should eventually be fixed when Editorial
// can prescribe us only 16x9 (1.77:1) hero images.
.image-container.pinned-to-horizontal-end.large-hero-story-card,
.image-container.pinned-to-horizontal-end.large-hero-story-card-rtl {
@container hero-container (aspect-ratio >= 225/100) {
:global(.artwork-component img) {
width: 100%;
height: auto;
}
}
}
.metadata-container {
position: absolute;
width: 40%;
padding-bottom: 40px;
padding-inline-start: 40px;
text-wrap: pretty;
color: var(--hero-primary-color);
@media (--range-small-only) {
width: 50%;
padding: 0 20px 20px;
}
@media (--range-xsmall-down) {
width: 100%;
padding: 0 20px 20px;
text-align: center;
}
}
.text-pinned-to-vertical-start .metadata-container {
@media (--range-small-only) {
top: 20px;
}
@media (--range-medium-up) {
top: 40px;
}
}
h2 {
position: relative;
z-index: 1;
text-wrap: balance;
font: var(--header-emphasized);
@media (--range-xsmall-down) {
font: var(--title-1-emphasized);
}
}
@container hero-container (height < 420px) {
h2 {
font: var(--large-title-emphasized);
}
}
h3 {
margin-bottom: 8px;
position: relative;
z-index: 1;
color: var(--hero-secondary-color);
font: var(--callout-emphasized-tall);
mix-blend-mode: var(--hero-text-blend-mode);
@media (--range-xsmall-down) {
margin-bottom: 4px;
}
}
p {
mix-blend-mode: var(--hero-text-blend-mode);
}
.subtitle {
margin-top: 8px;
position: relative;
z-index: 1;
font: var(--body-tall);
color: var(--hero-secondary-color);
}
.gradient {
--rotation: 55deg;
&:dir(rtl) {
--rotation: -55deg;
mask-image: radial-gradient(
ellipse 127% 130% at 95% 100%,
rgb(0, 0, 0) 18%,
rgb(0, 0, 0.33) 24%,
rgba(0, 0, 0, 0.66) 32%,
transparent 40%
),
linear-gradient(
-129deg,
rgb(0, 0, 0) 0%,
rgba(255, 255, 255, 0) 55%
);
}
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
// stylelint-disable color-function-notation
background: linear-gradient(
var(--rotation),
rgb(from var(--color) r g b / 0.25) 0%,
transparent 50%
);
// stylelint-enable color-function-notation
filter: saturate(1.5) brightness(0.9);
backdrop-filter: blur(40px);
mask-image: radial-gradient(
ellipse 127% 130% at 5% 100%,
rgb(0, 0, 0) 18%,
rgb(0, 0, 0.33) 24%,
rgba(0, 0, 0, 0.66) 32%,
transparent 40%
),
linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%);
@media (--range-xsmall-down) {
--rotation: 0deg;
mask-image: linear-gradient(
var(--rotation),
rgb(0, 0, 0) 28%,
rgba(0, 0, 0, 0) 56%
);
}
}
// When the text is pinned to the top of the lockup, we use a different gradient for legibility
article.text-pinned-to-vertical-start .gradient {
--rotation: -170deg;
mask-image: radial-gradient(
ellipse 118% 121% at 100% 0%,
rgb(0, 0, 0) 18%,
rgb(0, 0, 0.33) 22%,
rgba(0, 0, 0, 0.66) 33%,
transparent 43%
);
}
.app-icons {
display: grid;
align-self: center;
width: 90%;
grid-template-rows: auto auto;
grid-auto-flow: column;
gap: 24px;
margin-inline-start: -4%;
position: absolute;
inset-inline-end: 24px;
@media (--range-small-up) {
width: 44%;
}
}
.app-icons li:nth-child(even) {
inset-inline-start: 44%;
}
.app-icon-container {
position: relative;
flex-shrink: 0;
max-width: 200px;
}
@property --top-left-stop {
syntax: '<percentage>';
inherits: false;
initial-value: 20%;
}
@property --bottom-left-stop {
syntax: '<percentage>';
inherits: false;
initial-value: 40%;
}
@property --top-right-stop {
syntax: '<percentage>';
inherits: false;
initial-value: 55%;
}
@property --bottom-right-stop {
syntax: '<percentage>';
inherits: false;
initial-value: 50%;
}
.collection-icons-background-gradient {
width: 100%;
height: 100%;
position: absolute;
background: radial-gradient(
circle at 3% -50%,
var(--top-left, #000) var(--top-left-stop),
transparent 70%
),
radial-gradient(
circle at -50% 120%,
var(--bottom-left, #000) var(--bottom-left-stop),
transparent 80%
),
radial-gradient(
circle at 66% -175%,
var(--top-right, #000) var(--top-right-stop),
transparent 80%
),
radial-gradient(
circle at 62% 100%,
var(--bottom-right, #000) var(--bottom-right-stop),
transparent 100%
);
animation: collection-icons-background-gradient-shift 16s infinite
alternate-reverse;
animation-play-state: paused;
@media (--range-small-up) {
animation-play-state: running;
}
}
@keyframes collection-icons-background-gradient-shift {
0% {
--top-left-stop: 20%;
--bottom-left-stop: 40%;
--top-right-stop: 55%;
--bottom-right-stop: 50%;
background-size: 100% 100%;
}
50% {
--top-left-stop: 25%;
--bottom-left-stop: 15%;
--top-right-stop: 70%;
--bottom-right-stop: 30%;
background-size: 130% 130%;
}
100% {
--top-left-stop: 15%;
--bottom-left-stop: 20%;
--top-right-stop: 55%;
--bottom-right-stop: 20%;
background-size: 110% 110%;
}
}
</style>

View File

@@ -0,0 +1 @@
export default "__VITE_ASSET__PaJpmjhr__"

View File

@@ -0,0 +1 @@
export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20384%2080'%20preserveAspectRatio='xMinYMin%20meet'%20%3e%3cpath%20fill='currentColor'%20d='M43.873%2012.699C46.606%209.28%2048.461%204.69%2047.972%200c-4.001.199-8.883%202.64-11.71%206.06-2.538%202.93-4.784%207.712-4.198%2012.206%204.49.39%208.978-2.245%2011.81-5.567M47.92%2019.144c-6.521-.389-12.067%203.701-15.182%203.701-3.116%200-7.885-3.506-13.044-3.411-6.714.098-12.945%203.895-16.352%209.933-7.008%2012.079-1.85%2029.996%204.966%2039.833%203.31%204.867%207.298%2010.226%2012.553%2010.034%204.966-.195%206.912-3.216%2012.948-3.216%206.032%200%207.785%203.216%2013.041%203.118%205.451-.097%208.859-4.869%2012.168-9.74%203.797-5.549%205.351-10.906%205.449-11.2-.098-.097-10.511-4.092-10.608-16.07-.098-10.03%208.176-14.801%208.565-15.097-4.672-6.91-11.972-7.69-14.503-7.885'%20/%3e%3cpath%20fill='currentColor'%20d='M115.598%2058.881H87.752L81.07%2078.627H69.273L95.651%205.569h12.252l26.377%2073.058h-12l-6.682-19.746zm-24.96-9.113h22.074L101.827%2017.72h-.304L90.638%2049.768zM140.503%2025.365h10.43v9.062h.253c1.773-6.226%206.531-9.923%2012.81-9.923%201.569%200%202.936.253%203.746.406v10.175c-.86-.354-2.784-.607-4.911-.607-7.038%200-11.391%204.71-11.391%2012.252v31.897h-10.937V25.365zM207.744%2043.693c-1.114-5.671-5.367-10.177-12.505-10.177-8.455%200-14.025%207.037-14.025%2018.48%200%2011.695%205.62%2018.48%2014.126%2018.48%206.734%200%2011.138-3.696%2012.404-9.873h10.53c-1.164%2011.34-10.227%2019.036-23.035%2019.036-15.24%200-25.162-10.43-25.162-27.643%200-16.91%209.923-27.593%2025.06-27.593%2013.72%200%2022.074%208.81%2023.036%2019.29h-10.43zM223.9%2063.489c0-9.317%207.14-15.037%2019.797-15.746l14.58-.86v-4.101c0-5.924-4-9.468-10.682-9.468-6.329%200-10.278%203.037-11.24%207.797h-10.328c.607-9.62%208.81-16.708%2021.973-16.708%2012.91%200%2021.163%206.835%2021.163%2017.517v36.707h-10.48v-8.76h-.254c-3.088%205.925-9.821%209.67-16.808%209.67-10.43%200-17.72-6.48-17.72-16.048zm34.378-4.81v-4.202l-13.113.81c-6.532.456-10.227%203.341-10.227%207.898%200%204.657%203.848%207.695%209.72%207.695%207.645%200%2013.62-5.265%2013.62-12.2zM276.853%2051.996c0-16.809%208.91-27.492%2022.276-27.492%207.645%200%2013.721%203.848%2016.707%209.721h.204V5.57h10.986v73.058h-10.632v-9.063h-.203c-3.139%206.075-9.214%209.974-16.96%209.974-13.468%200-22.378-10.734-22.378-27.542zm11.189%200c0%2011.239%205.417%2018.277%2014.075%2018.277%208.404%200%2014.023-7.139%2014.023-18.277%200-11.037-5.619-18.277-14.023-18.277-8.658%200-14.075%207.088-14.075%2018.277zM382.956%2062.982c-1.519%209.72-10.734%2016.657-22.935%2016.657-15.644%200-25.111-10.58-25.111-27.39%200-16.707%209.619-27.846%2024.656-27.846%2014.783%200%2023.997%2010.43%2023.997%2026.58v3.747h-37.616v.658c0%209.265%205.568%2015.39%2014.327%2015.39%206.228%200%2010.835-3.138%2012.303-7.796h10.379zm-36.96-15.897h26.631c-.252-8.15-5.417-13.873-13.061-13.873-7.646%200-13.012%205.823-13.57%2013.873z'%20/%3e%3c/svg%3e"

View File

@@ -0,0 +1,66 @@
<script lang="ts">
import type { Video } from '@jet-app/app-store/api/models';
import VideoPlayer from '../VideoPlayer.svelte';
import HlsJsDecorator from '../decorators/HlsJSDecorator.svelte';
import { buildPoster } from '~/utils/video-poster';
import { generateUuid } from '@amp/web-apps-utils/src';
import type { NamedProfile } from 'src/config/components/artwork';
import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
import mediaQueries from '~/utils/media-queries';
import { colorAsString } from '~/utils/color';
export let video: Video;
export let autoplay: boolean = false;
export let loop: boolean = false;
export let muted: boolean = true;
export let useControls: boolean = true;
export let profile: NamedProfile | Profile;
export let autoplayVisibilityThreshold: number = 0;
export let videoPlayerRef: InstanceType<typeof VideoPlayer> | null = null;
export let shouldSuperimposePosterImage: boolean = false;
$: poster =
video.preview && buildPoster(video.preview, profile, $mediaQueries);
$: backgroundColor = video.preview.backgroundColor
? colorAsString(video.preview.backgroundColor)
: '#f1f1f1';
$: metricsTemplate = video?.templateMediaEvent ?? {};
const uuid = generateUuid();
</script>
<HlsJsDecorator let:HLS>
<VideoPlayer
{HLS}
{loop}
{muted}
{autoplay}
{useControls}
{autoplayVisibilityThreshold}
{metricsTemplate}
{shouldSuperimposePosterImage}
id={uuid}
src={video.videoUrl}
poster={poster ?? undefined}
--aspect-ratio={video.preview.width / video.preview.height}
bind:this={videoPlayerRef}
/>
<div
class="loader"
slot="loading-component"
style:--aspect-ratio={video.preview.width / video.preview.height}
style:--background-image={`url(${poster})`}
style:--background-color={backgroundColor}
/>
</HlsJsDecorator>
<style>
.loader {
aspect-ratio: var(--aspect-ratio);
width: 100%;
background-image: var(--background-image);
background-color: var(--background-color);
background-size: cover;
}
</style>

View File

@@ -0,0 +1,52 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements';
import type { ExternalUrlAction } from '@jet-app/app-store/api/models';
import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
import { getJetPerform } from '~/jet';
type AllowedAnchorAttributes = Omit<
HTMLAnchorAttributes,
// The `href` attribute is not allowed because it will be provided
// by the `ExternalUrlAction`
'href'
>;
interface $$Props extends AllowedAnchorAttributes {
destination: ExternalUrlAction;
includeArrowIcon?: boolean;
}
const perform = getJetPerform();
export let destination: ExternalUrlAction;
export let includeArrowIcon: boolean = true;
function handleClickAction() {
perform(destination);
}
</script>
<a
{...$$restProps}
data-test-id="external-link"
href={destination.url}
target="_blank"
rel="nofollow noopener noreferrer"
on:click={handleClickAction}
>
<slot />
{#if includeArrowIcon}
<ArrowIcon class="external-link-arrow" aria-hidden="true" />
{/if}
</a>
<style lang="scss">
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
@use 'ac-sasskit/core/locale' as *;
a :global(.external-link-arrow) {
@include rtl {
transform: rotate(-90deg);
}
}
</style>

View File

@@ -0,0 +1,41 @@
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements';
import type { FlowAction } from '@jet-app/app-store/api/models';
import { getJetPerform } from '~/jet';
type AllowedAnchorAttributes = Omit<
HTMLAnchorAttributes,
// The `href` attribute is not allowed because it will be provided
// by the `FlowAction`
'href'
>;
interface $$Props extends AllowedAnchorAttributes {
destination: FlowAction;
}
const perform = getJetPerform();
export let destination: FlowAction;
// Web cannot support internal protocols, so this guard prevents
// them from showing up in anchor tags.
$: pageUrl = destination.pageUrl?.includes('x-as3-internal:')
? '#'
: destination?.pageUrl;
function onClick(event: MouseEvent) {
event.preventDefault();
perform(destination);
}
</script>
<a
{...$$restProps}
href={pageUrl}
data-test-id="internal-link"
on:click={onClick}
>
<slot />
</a>

View File

@@ -0,0 +1,51 @@
<script lang="ts" context="module">
import type {
Action,
ShelfBasedPageScrollAction,
} from '@jet-app/app-store/api/models';
export function isShelfBasedPageScrollAction(
action: Action,
): action is ShelfBasedPageScrollAction {
return (
action.$kind === 'ShelfBasedPageScrollAction' && 'shelfId' in action
);
}
</script>
<script lang="ts">
import type { HTMLAnchorAttributes } from 'svelte/elements';
interface $$Props extends HTMLAnchorAttributes {
destination: ShelfBasedPageScrollAction;
}
export let destination: ShelfBasedPageScrollAction;
function handleLinkClick(e: Event) {
const anchorElement = e.currentTarget as HTMLAnchorElement;
const hash = anchorElement.hash;
const elementToScrollTo = document.querySelector(hash);
if (!elementToScrollTo) {
return;
}
elementToScrollTo.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
history.replaceState(null, '', hash);
}
</script>
{#if destination.shelfId}
<a
{...$$restProps}
data-test-id="scroll-link"
href={`#${destination.shelfId}`}
on:click|preventDefault|stopPropagation={handleLinkClick}
>
<slot />
</a>
{:else}
<slot />
{/if}

View File

@@ -0,0 +1,61 @@
<script lang="ts" context="module">
import type { Badge, BadgeType } from '@jet-app/app-store/api/models';
const ARTWORK_TYPE: BadgeType = 'artwork';
const CONTENT_RATING_TYPE: BadgeType = 'contentRating';
const CONTENT_RATING_KEY = 'contentRating';
interface ContentRatingBadge extends Badge {
type: typeof CONTENT_RATING_TYPE;
}
export function isContentRatingBadge(
badge: Badge,
): badge is ContentRatingBadge {
return (
badge.type === CONTENT_RATING_TYPE ||
(badge.key === CONTENT_RATING_KEY && badge.type === ARTWORK_TYPE)
);
}
</script>
<script lang="ts">
import SystemImage, {
isSystemImageArtwork,
} from '~/components/SystemImage.svelte';
export let badge: ContentRatingBadge;
$: ({ artwork, accessibilityTitle } = badge);
</script>
{#if artwork && isSystemImageArtwork(artwork)}
<div class="pictogram-container" aria-label={accessibilityTitle}>
<SystemImage {artwork} />
</div>
{:else}
<span>
{badge.content.contentRating}
</span>
{/if}
<style>
span {
height: 25px;
margin: 4px 0 2px;
font: var(--title-1-emphasized);
color: var(--color);
}
.pictogram-container {
height: 25px;
padding: 2px;
aspect-ratio: 1/1;
margin: 4px 0 2px;
}
.pictogram-container :global(svg) {
width: 21px;
height: 21px;
}
</style>

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>

View File

@@ -0,0 +1,463 @@
<script lang="ts" context="module">
import type {
AppPlatform,
ShelfBasedProductPage,
} from '@jet-app/app-store/api/models';
/**
* The parts of {@linkcode ShelfBasedProductPage} that are required to render
* the `MarkerShelf` component
*/
export type MarkerShelfPageRequirements = Pick<
ShelfBasedProductPage,
| 'badges'
| 'banner'
| 'developerAction'
| 'lockup'
| 'shelfMapping'
| 'titleOfferDisplayProperties'
| 'canonicalURL'
| 'appPlatforms'
>;
</script>
<script lang="ts">
import { onMount } from 'svelte';
import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
import { platform } from '@amp/web-apps-utils';
import AppIcon, {
doesAppIconNeedBorder,
} from '~/components/AppIcon.svelte';
import Banner from '~/components/jet/item/BannerItem.svelte';
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
import ProductPageArcadeBanner from '~/components/ProductPageArcadeBanner.svelte';
import { getI18n } from '~/stores/i18n';
import { colorAsString, isNamedColor, isRGBColor } from '~/utils/color';
import { concatWithMiddot, isString } from '~/utils/string-formatting';
import {
isPlatformExclusivelySupported,
isPlatformSupported,
PlatformToExclusivityText,
} from '~/utils/app-platforms';
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
import ShareArrowButton, {
isShareSupported,
} from '~/components/ShareArrowButton.svelte';
import LaunchNativeButton from '~/components/LaunchNativeButton.svelte';
export let page: MarkerShelfPageRequirements;
$: banner = page.banner;
$: lockup = page.lockup;
$: appPlatforms = page.appPlatforms;
$: offerDisplayProperties = lockup.offerDisplayProperties || {};
$: ({ expectedReleaseDate } = offerDisplayProperties?.subtitles || {});
const i18n = getI18n();
// TODO: replace with `supportsArcade` from Jet
// rdar://143706610 (Support `supportsArcade` attribute)
$: supportsArcade = offerDisplayProperties.offerType === 'arcadeApp';
$: backgroundColor = isRGBColor(lockup.icon?.backgroundColor)
? colorAsString(lockup.icon.backgroundColor)
: '#fff';
$: backgroundImage = lockup.icon
? buildSrc(
lockup.icon.template,
{
crop: 'bb',
width: 400,
height: 400,
fileType: 'webp',
},
{},
)
: undefined;
$: attributes = concatWithMiddot(
[
expectedReleaseDate && $i18n.t('ASE.Web.AppStore.App.ComingSoon'),
expectedReleaseDate && expectedReleaseDate,
// Attributes that are not relevant for Arcade Apps:
...(!supportsArcade
? [
page.titleOfferDisplayProperties?.isFree &&
$i18n.t('ASE.Web.AppStore.Free'),
offerDisplayProperties.priceFormatted,
offerDisplayProperties.subtitles?.standard,
lockup.tertiaryTitle,
]
: []),
].filter(isString),
$i18n,
);
$: exclusivePlatform = (
Object.keys(PlatformToExclusivityText) as AppPlatform[]
).find((platform: AppPlatform) =>
isPlatformExclusivelySupported(platform, appPlatforms),
);
$: exclusivityText = exclusivePlatform
? PlatformToExclusivityText[exclusivePlatform]
: null;
$: shouldShowLaunchNativeButton =
platform.ismacOS() &&
(lockup.isIOSBinaryMacOSCompatible ||
isPlatformSupported('mac', appPlatforms));
let shouldShowShareButton: boolean = true;
onMount(() => {
shouldShowShareButton = isShareSupported();
});
</script>
<ShelfWrapper withBottomPadding={false} withPaddingTop={false}>
<div
class="container"
style:--background-color={backgroundColor}
style:--background-image={`url(${backgroundImage})`}
>
<div class="rotate" />
<div class="blur" />
<div class="content-container">
{#if lockup.icon}
<div
class="app-icon-contianer"
class:without-border={!doesAppIconNeedBorder(lockup.icon)}
aria-hidden="true"
>
<AppIcon
icon={lockup.icon}
profile="app-icon-large"
fixedWidth={false}
/>
<div class="glow">
<AppIcon
icon={lockup.icon}
profile="app-icon-large"
fixedWidth={false}
/>
</div>
</div>
{/if}
<section>
{#if supportsArcade}
<span
class="arcade-logo"
aria-label={$i18n.t(
'ASE.Web.AppStore.ArcadeLogo.AccessibilityValue',
)}
>
<AppleArcadeLogo />
</span>
{:else if lockup.editorialTagline}
<h3>{lockup.editorialTagline}</h3>
{/if}
<h1>
{lockup.title}
</h1>
<h2 class="subtitle">
{lockup.subtitle}
</h2>
{#if exclusivityText}
<p class="attributes">
{$i18n.t(exclusivityText)}
</p>
{/if}
{#if attributes.length > 0}
<p class="attributes">
{attributes}
</p>
{/if}
{#if page.canonicalURL && (shouldShowLaunchNativeButton || shouldShowShareButton)}
<div class="buttons-container">
{#if shouldShowLaunchNativeButton}
<span class="launch-native-button-container">
<LaunchNativeButton url={page.canonicalURL} />
</span>
{/if}
{#if shouldShowShareButton}
<!--
If there is no launch native button, then we show a label for
the share button, which helps to visually fill out the space.
-->
<ShareArrowButton
url={page.canonicalURL}
withLabel={!shouldShowLaunchNativeButton}
/>
{/if}
</div>
{/if}
</section>
</div>
</div>
</ShelfWrapper>
{#if banner}
<ShelfWrapper withBottomPadding={false} withTopMargin={false}>
<Banner item={banner} />
</ShelfWrapper>
{/if}
{#if supportsArcade}
<ShelfWrapper
withBottomPadding={false}
withTopMargin={true}
centered={false}
>
<ProductPageArcadeBanner />
</ShelfWrapper>
{/if}
<style>
.container {
--blend-mode: plus-lighter;
position: relative;
display: flex;
overflow: hidden;
align-items: center;
height: 200px;
color: var(--systemPrimary-onDark);
border-bottom: 1px solid var(--systemQuaternary-vibrant);
border-bottom-right-radius: 2px;
border-bottom-left-radius: 2px;
background: linear-gradient(
to bottom,
transparent 20%,
rgba(0, 0, 0, 0.8) 100%
),
var(--background-image), var(--background-color, #000);
background-size: cover;
background-position: center;
transition: border-bottom-left-radius 210ms ease-out,
border-bottom-right-radius 210ms ease-out;
transform: translate(0);
@media (--range-small-up) {
height: 286px;
}
@media (--range-xlarge-up) {
border: 1px solid var(--systemQuaternary-vibrant);
border-top: none;
border-bottom-right-radius: 30px;
border-bottom-left-radius: 30px;
}
}
.glow {
position: absolute;
z-index: -1;
top: 0;
width: 100%;
transform: scale(1.5);
filter: blur(60px);
}
.blur {
position: absolute;
z-index: 1;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(100px) saturate(1.5);
}
.rotate {
position: absolute;
z-index: 2;
top: 0;
left: 0;
width: 100%;
filter: brightness(1.3) saturate(0) blur(50px);
mix-blend-mode: overlay;
height: 500%;
background-image: var(--background-image);
background-repeat: repeat;
opacity: 0;
transform-origin: top center;
animation: shift-background 60s infinite linear 10s;
}
.content-container {
display: flex;
flex-direction: row;
max-width: 840px;
gap: 1em;
margin: 0 var(--bodyGutter);
@media (--range-small-up) {
gap: 1.5em;
}
@media (--range-medium-up) {
gap: 2em;
}
}
.app-icon-contianer {
position: relative;
z-index: 2;
display: flex;
align-items: center;
width: 128px;
flex-shrink: 0;
@media (--range-small-up) {
width: 194px;
}
}
.app-icon-contianer:not(.without-border) :global(> .app-icon) {
box-shadow: 0 0 30px rgba(0, 0, 0, 0.33);
border: 2px solid var(--systemQuaternary-onDark);
}
section {
display: flex;
flex-direction: column;
justify-content: center;
}
.subtitle,
.attributes {
position: relative;
z-index: 2;
margin-bottom: 4px;
font: var(--body);
color: rgba(245.973, 245.973, 245.973, 0.6);
text-wrap: pretty;
mix-blend-mode: var(--blend-mode);
@media (--range-small-up) {
margin-bottom: 8px;
font: var(--title-2-emphasized);
}
}
.attributes {
margin-bottom: 0;
font: var(--body);
}
.buttons-container {
--share-arrow-size: 27px;
--launch-native-button-arrow-size: 7px;
--get-button-font: var(--footnote-bold);
margin-top: 10px;
display: flex;
align-items: center;
gap: 10px;
@media (--range-small-up) {
--share-arrow-size: unset;
--launch-native-button-arrow-size: unset;
--get-button-font: unset;
}
}
h1 {
position: relative;
z-index: 2;
display: flex;
align-items: center;
margin-bottom: 2px;
font: var(--title-2-emphasized);
color: white;
text-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
text-wrap: pretty;
@media (--sidebar-visible) {
font: var(--title-1-emphasized);
}
@media (--range-small-up) {
font: var(--header-emphasized);
letter-spacing: -0.02em;
}
}
h3 {
margin-bottom: 0;
position: relative;
z-index: 2;
mix-blend-mode: plus-lighter;
font: var(--body-emphasized);
@media (--range-small-up) {
font: var(--title-3-emphasized);
}
}
.arcade-logo {
display: flex;
height: 10px;
margin-bottom: 4px;
position: relative;
z-index: 2;
mix-blend-mode: plus-lighter;
@media (--range-small-up) {
height: 14px;
}
}
.launch-native-button-container {
position: relative;
z-index: 2;
}
@keyframes shift-background {
0% {
background-position: 50% 50%;
background-size: 100%;
transform: rotate(0deg);
opacity: 0;
}
10% {
opacity: 0.5;
}
20% {
background-position: 65% 25%;
background-size: 160%;
transform: rotate(45deg);
}
45% {
background-position: 90% 60%;
background-size: 250%;
transform: rotate(160deg);
opacity: 0.5;
}
70% {
background-position: 70% 40%;
background-size: 200%;
transform: rotate(250deg);
opacity: 0.5;
}
100% {
background-position: 50% 50%;
background-size: 100%;
transform: rotate(360deg);
opacity: 0;
}
}
</style>

View File

@@ -0,0 +1,36 @@
<script lang="ts" context="module">
import type {
AccessibilityParagraph,
Shelf,
} from '@jet-app/app-store/api/models';
interface AccessibilityDeveloperLinkShelf extends Shelf {
items: [AccessibilityParagraph];
}
export function isAccessibilityDeveloperLinkShelf(
shelf: Shelf,
): shelf is AccessibilityDeveloperLinkShelf {
let { contentType, items, title } = shelf;
return (
contentType === 'accessibilityParagraph' &&
!title &&
Array.isArray(items)
);
}
</script>
<script lang="ts">
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
import AccessibilityParagraphItem from '../item/AccessibilityParagraphItem.svelte';
import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
export let shelf: AccessibilityDeveloperLinkShelf;
$: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
</script>
<ShelfWrapper {shelf} centered {withBottomPadding}>
<AccessibilityParagraphItem item={shelf.items[0]} />
</ShelfWrapper>

View File

@@ -0,0 +1,35 @@
<script lang="ts" context="module">
import type {
AccessibilityFeatures,
Shelf,
} from '@jet-app/app-store/api/models';
export interface AccessibilityFeaturesShelf extends Shelf {
items: AccessibilityFeatures[];
}
export function isAccessibilityFeaturesShelf(
shelf: Shelf,
): shelf is AccessibilityFeaturesShelf {
let { contentType, items } = shelf;
return contentType === 'accessibilityFeatures' && Array.isArray(items);
}
</script>
<script lang="ts">
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte';
import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
export let shelf: AccessibilityFeaturesShelf;
$: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
</script>
<ShelfWrapper {shelf} {withBottomPadding}>
<ShelfItemLayout {shelf} gridType="B" let:item>
<AccessibilityFeaturesItem {item} />
</ShelfItemLayout>
</ShelfWrapper>

Some files were not shown because too many files have changed in this diff Show More