diff options
Diffstat (limited to 'src/components')
190 files changed, 17579 insertions, 0 deletions
diff --git a/src/components/AmbientBackgroundArtwork.svelte b/src/components/AmbientBackgroundArtwork.svelte new file mode 100644 index 0000000..bc9563c --- /dev/null +++ b/src/components/AmbientBackgroundArtwork.svelte @@ -0,0 +1,202 @@ +<script lang="ts"> + import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models'; + import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import ResizeDetector from '@amp/web-app-components/src/components/helpers/ResizeDetector.svelte'; + import { colorAsString } from '~/utils/color'; + + export let artwork: JetArtworkType; + export let active: boolean = false; + + $: isBackgroundImageLoaded = false; + $: backgroundImage = artwork + ? buildSrc( + artwork.template, + { + crop: 'sr', + width: 400, + height: Math.floor(400 / 1.6667), + fileType: 'webp', + }, + {}, + ) + : undefined; + + $: if (backgroundImage) { + const img = new Image(); + img.onload = () => (isBackgroundImageLoaded = true); + img.src = backgroundImage; + } + + let resizing = false; + const handleResizeUpdate = (e: CustomEvent<{ isResizing: boolean }>) => + (resizing = e.detail.isResizing); + + let isOutOfView = true; + const handleIntersectionOberserverUpdate = ( + isIntersectingViewport: boolean, + ) => (isOutOfView = !isIntersectingViewport); +</script> + +{#if backgroundImage} + <ResizeDetector on:resizeUpdate={handleResizeUpdate} /> + + <div + class="container" + class:active + class:resizing + class:loaded={isBackgroundImageLoaded} + class:out-of-view={isOutOfView} + style:--background-image={`url(${backgroundImage})`} + style:--background-color={artwork.backgroundColor && + colorAsString(artwork.backgroundColor)} + use:intersectionObserver={{ + callback: handleIntersectionOberserverUpdate, + threshold: 0, + }} + > + <div class="overlay" /> + </div> +{/if} + +<style> + .container { + --veil: rgb(240, 240, 240, 0.65); + --speed: 0.66s; + --aspect-ratio: 16/9; + --scale: 1.2; + position: absolute; + top: 0; + left: 0; + width: 100%; + aspect-ratio: var(--aspect-ratio); + max-height: 900px; + opacity: 0; + + /* + This stack of background images represents the following three layers, listed front-to-back: + + 1) A gradient from transparent to white that acts as a mask for the entire container. + `mask-image` caused too much thrashing and CPU usage when animating and resizing, + so we are mimicking its functionality with this top-layer background image. + 2) A semi-transparent veil to evenly fade out the bg. Note that this is not technically + a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be + used in `background-image`. + 3) The joe color of the background image that will eventualy be loaded. + */ + background-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 50%, + var(--pageBg) 80% + ), + linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%), + linear-gradient( + 0deg, + var(--background-color) 0%, + var(--background-color) 80% + ); + background-position: center; + background-size: 120%; + + /* + Blurring via the CSS filter does not extend edge-to-edge of the contents width, but we + can mitigate that by ever-so-slightly bumping up the `scale` of content so it bleeds off + the page cleanly. + */ + filter: blur(20px) saturate(1.3); + transform: scale(var(--scale)); + transition: opacity calc(var(--speed) * 2) ease-out, + background-size var(--speed) ease-in; + + @media (prefers-color-scheme: dark) { + --veil: rgba(0, 0, 0, 0.5); + } + } + + .container.loaded { + /* + This stack of background images represents the following three layers, listed front-to-back: + + 1) A gradient from transparent to white that acts as a mask for the entire container. + `mask-image` caused too much thrashing and CPU usage when animating and resizing, + so we are mimicking its functionality with this top-layer background image. + 2) A semi-transparent veil to evenly fade out the image. Note that this is not technically + a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be + used in `background-image`. + 3) The actual background image. + */ + background-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 50%, + var(--pageBg) 80% + ), + linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%), + var(--background-image); + } + + .container.active { + opacity: 1; + transition: opacity calc(var(--speed) / 2) ease-in; + background-size: 100%; + } + + .overlay { + position: absolute; + z-index: 2; + top: 0; + left: 0; + width: 100%; + aspect-ratio: var(--aspect-ratio); + max-height: 900px; + opacity: 0; + background-image: var(--background-image); + background-position: 100% 100%; + background-size: 250%; + filter: brightness(1.3) saturate(0); + mix-blend-mode: overlay; + will-change: opacity, background-position; + animation: shift-background 60s infinite linear alternate; + animation-play-state: paused; + transition: opacity var(--speed) ease-in; + } + + .active .overlay { + opacity: 0.3; + animation-play-state: running; + transition: opacity calc(var(--speed) * 2) ease-in + calc(var(--speed) * 2); + } + + .active.out-of-view .overlay, + .active.resizing .overlay { + animation-play-state: paused; + opacity: 0; + } + + @keyframes shift-background { + 0% { + background-position: 0% 50%; + background-size: 250%; + } + + 25% { + background-position: 60% 20%; + background-size: 300%; + } + + 50% { + background-position: 100% 50%; + background-size: 320%; + } + + 75% { + background-position: 40% 100%; + background-size: 220%; + } + + 100% { + background-position: 20% 50%; + background-size: 300%; + } + } +</style> diff --git a/src/components/AppEventDate.svelte b/src/components/AppEventDate.svelte new file mode 100644 index 0000000..41ee248 --- /dev/null +++ b/src/components/AppEventDate.svelte @@ -0,0 +1,72 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { fade } from 'svelte/transition'; + import type { Optional } from '@jet/environment/types/optional'; + import type { AppEvent } from '@jet-app/app-store/api/models'; + import { getJet } from '~/jet'; + import { + chooseAppEventDate, + renderDate, + computeAppEventFormattedDates, + type RequiredAppEventFormattedDate, + } from '~/jet/utils/app-event-formatted-date'; + + const jet = getJet(); + + /** + * New pattern (*prefered*): accept appEvent object and compute formattedDates on client-side. + * This avoids timezone differences in SSR server (UTC) which cause incorrect event date and time. + * By computing dates in the browser, we ensure the user sees dates in their local timezone. + */ + export let appEvent: + | Pick<AppEvent, 'appEventBadgeKind' | 'startDate' | 'endDate'> + | undefined = undefined; + + // Legacy pattern: accept pre-computed formattedDates from Jet + export let formattedDates: RequiredAppEventFormattedDate[] | undefined = + undefined; + + let appEventDate: Optional<RequiredAppEventFormattedDate>; + + onMount(() => { + const dates = appEvent + ? computeAppEventFormattedDates( + jet.objectGraph, + appEvent.appEventBadgeKind, + appEvent.startDate, + appEvent.endDate, + ) + : formattedDates; + + if (dates) { + appEventDate = chooseAppEventDate(dates); + } + }); + + /** + * `Date` instances in the view-model will have been serialized to `string` + * instances by ServerKit when delivered to the client; we need to normalize + * this so that we have a `string` both client- and server-side. + */ + function normalizeDate(date: Date | string): string { + return typeof date === 'string' ? date : date.toISOString(); + } +</script> + +{#if appEventDate} + <time + transition:fade={{ duration: 210 }} + datetime={appEventDate.displayFromDate && + normalizeDate(appEventDate.displayFromDate)} + > + {renderDate(jet.objectGraph.loc, appEventDate)} + </time> +{:else} + <span aria-hidden="true">…</span> +{/if} + +<style> + span { + color: transparent; + } +</style> diff --git a/src/components/AppIcon.svelte b/src/components/AppIcon.svelte new file mode 100644 index 0000000..4cb0262 --- /dev/null +++ b/src/components/AppIcon.svelte @@ -0,0 +1,131 @@ +<script lang="ts" context="module"> + import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models'; + import type { NamedProfile } from '~/config/components/artwork'; + + export type AppIconProfile = Extract< + NamedProfile, + | 'app-icon' + | 'app-icon-large' + | 'app-icon-medium' + | 'app-icon-small' + | 'app-icon-xlarge' + | 'app-icon-river' + | 'brick-app-icon' + >; + + export function doesAppIconNeedBorder(icon: JetArtworkType): boolean { + const doesIconHaveTransparentBackground = + icon.backgroundColor && + isNamedColor(icon.backgroundColor) && + icon.backgroundColor.name === 'clear'; + const isIconPrerendered = + icon.style === 'roundedRectPrerendered' || + icon.style === 'roundPrerendered'; + const isIconUnadorned = icon.style === 'unadorned'; + + return ( + !doesIconHaveTransparentBackground && + !isIconPrerendered && + !isIconUnadorned + ); + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork'; + import { isNamedColor } from '~/utils/color'; + + export let icon: JetArtworkType; + export let profile: AppIconProfile = 'app-icon'; + export let fixedWidth: boolean = true; + export let disableAutoCenter: boolean = false; + export let withBorder: boolean = false; + + const profiles = ArtworkConfig.get().PROFILES; + + $: computedProfile = ( + icon.style === 'pill' + ? `${profile}-pill` + : icon.style === 'tvRect' + ? `${profile}-tv-rect` + : profile + ) as NamedProfile; + $: widthFromProfile = profiles?.get(computedProfile)?.[0] ?? 0; + $: hasTransparentBackground = + !!icon.backgroundColor && + isNamedColor(icon.backgroundColor) && + icon.backgroundColor.name === 'clear'; + $: needsBorder = withBorder || doesAppIconNeedBorder(icon); + + // These prerendered "Solarium" icons need to use higher than normal quality due to how their + // rendering pipeline downscales/transforms sources. + $: quality = + icon.style && + ['roundedRectPrerendered', 'roundPrerendered'].includes(icon.style) + ? 75 + : undefined; +</script> + +<div + class="app-icon" + class:pill={icon.style === 'pill'} + class:round={icon.style === 'round'} + class:rounded-rect={icon.style === 'roundedRect'} + class:tv-rect={icon.style === 'tvRect'} + class:rounded-rect-prerendered={icon.style === 'roundedRectPrerendered'} + class:round-prerendered={icon.style === 'roundPrerendered'} + class:with-border={needsBorder} + style={fixedWidth ? `--profileWidth: ${widthFromProfile}px` : ''} +> + <Artwork + {disableAutoCenter} + {hasTransparentBackground} + {quality} + artwork={icon} + profile={computedProfile} + noShelfChevronAnchor={true} + /> +</div> + +<style> + .app-icon { + aspect-ratio: 1 / 1; + min-width: var(--profileWidth, auto); + } + + .app-icon.pill { + aspect-ratio: 4 / 3; + + /* + Creates elliptical corners with horizontal radii at 50% of the width and vertical radii + at 65% of the height, for a rounded, squished, pill-like effect + */ + border-radius: 50% 50% 50% 50% / 65% 65% 65% 65%; + } + + .app-icon.round { + border-radius: 50%; + } + + .app-icon.rounded-rect { + border-radius: 23%; + } + + .app-icon.tv-rect { + aspect-ratio: 16/9; + border-radius: 9% / 16%; + } + + .app-icon.rounded-rect-prerendered { + border-radius: 25%; + } + + .app-icon.round-prerendered { + border-radius: 50%; + } + + .app-icon.with-border { + box-shadow: 0 0 0 1px var(--systemQuaternary); + } +</style> diff --git a/src/components/AppIconRiver.svelte b/src/components/AppIconRiver.svelte new file mode 100644 index 0000000..b673dd0 --- /dev/null +++ b/src/components/AppIconRiver.svelte @@ -0,0 +1,92 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import type { Artwork } from '@jet-app/app-store/api/models'; + import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte'; + + export let icons: Artwork[]; + export let profile: AppIconProfile = 'app-icon-river'; + + $: aspectRatio = icons[0].width / icons[0].height; + + let mounted = false; + const numberOfIcons = icons.length; + + // We shift the order of the bottom row of icons to ensure that the same icons aren't shown + // next to each other. Note that this is different from purely shuffling the icons, as that + // could still lead to the same icons being next to one another, due to how small the set is. + // The input and output here is as such: + // in = [1, 2, 3, 4, 5, 6, 7] + // out = [4, 5, 6, 7, 1, 2, 3] + const iconsInShiftedOrder = [ + ...icons.slice(numberOfIcons / 2), + ...icons.slice(0, numberOfIcons / 2), + ]; + + // We are quadrupling the icons we render so the flow is seamless and stretches across the + // full width of the container. + const topRow = Array(4).fill(icons).flat(); + const bottomRow = Array(4).fill(iconsInShiftedOrder).flat(); + + // We use this `mounted` flag to defer the rendering of the `AppIconRiver`, since it's markup heavy + // and has no semantic meaning for SEO. This deferring saves about 190kb of initial HTML per instance. + onMount(() => (mounted = true)); +</script> + +{#if mounted} + {#each [topRow, bottomRow] as iconRow} + <ul class="app-icons"> + {#each iconRow as icon} + <li + class="app-icon-container" + style:--aspect-ratio={aspectRatio} + > + <AppIcon {icon} {profile} fixedWidth={false} /> + </li> + {/each} + </ul> + {/each} +{/if} + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .app-icons { + --icon-width: var(--app-icon-river-icon-width, 128px); + --speed: var(--app-icon-river-speed, 240s); + --direction: -50%; + + @include rtl { + --direction: 50%; + } + display: flex; + width: fit-content; + z-index: 2; + animation: scroll var(--speed) linear infinite; + } + + .app-icons:last-of-type { + margin-bottom: 20px; + } + + .app-icon-container { + width: var(--icon-width); + aspect-ratio: var(--aspect-ratio); + margin: 8px; + } + + .app-icons:last-of-type .app-icon-container { + position: relative; + right: calc((var(--icon-width) / 2) + 8px); + } + + @keyframes scroll { + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(var(--direction)); + } + } +</style> diff --git a/src/components/Artwork.svelte b/src/components/Artwork.svelte new file mode 100644 index 0000000..04de1d4 --- /dev/null +++ b/src/components/Artwork.svelte @@ -0,0 +1,118 @@ +<script lang="ts" context="module"> + import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models'; + import type { + Artwork as ComponentArtworkType, + Profile as ArtworkProfile, + CropCode, + ImageSizes, + } from '@amp/web-app-components/src/components/Artwork/types'; + + import type { NamedProfile } from '~/config/components/artwork'; + + /** + * Creates a {@linkcode Profile} on-the-fly based on the properties of + * the {@linkcode artwork} + */ + export function getNaturalProfile( + artwork: JetArtworkType, + imageSizes: ImageSizes = [artwork.width], + ): ArtworkProfile { + const aspectRatio = artwork.width / artwork.height; + + return [imageSizes, aspectRatio, artwork.crop as CropCode]; + } + + export type Profile = NamedProfile | ArtworkProfile; +</script> + +<script lang="ts"> + import type { ImageSettings } from '@amp/web-app-components/src/components/Artwork/types'; + import Artwork from '@amp/web-app-components/src/components/Artwork/Artwork.svelte'; + import { colorAsString, isNamedColor } from '~/utils/color'; + + import { + ArtworkConfig, + type ArtworkProfileMap, + } from '@amp/web-app-components/config/components/artwork'; + + export let artwork: JetArtworkType; + export let profile: Profile; + export let alt: string = ''; + export let topRoundedSecondary: boolean = false; + export let useContainerStyle: boolean = false; + export let forceFullWidth: boolean = true; + export let isDecorative: boolean = true; + export let lazyLoad: boolean = true; + export let disableAutoCenter: boolean = false; + export let noShelfChevronAnchor: boolean = false; + export let forceCropCode: boolean = false; + export let quality: number | undefined = undefined; + export let hasTransparentBackground: boolean = + !!artwork.backgroundColor && + isNamedColor(artwork.backgroundColor) && + artwork.backgroundColor.name === 'clear'; + export let useCropCodeFromArtwork: boolean = true; + export let withoutBorder: boolean = false; + + let imageSettings: ImageSettings; + $: imageSettings = { + forceCropCode, + hasTransparentBackground, + quality, + }; + + let PROFILES: ArtworkProfileMap<string> | undefined; + let computedProfileAttributes: Profile | undefined; + + $: { + const config = ArtworkConfig?.get(); + PROFILES = config?.PROFILES; + + const defaultProfileAttributes: Profile | undefined = + typeof profile === 'string' ? PROFILES?.get(profile) : profile; + + const cropCodeIndex = 2; + + if ( + useCropCodeFromArtwork && + artwork?.crop && + defaultProfileAttributes + ) { + computedProfileAttributes = [...defaultProfileAttributes]; + computedProfileAttributes[cropCodeIndex] = + artwork?.crop as CropCode; + } + } + + $: artworkForComponent = { + ...artwork, + backgroundColor: artwork.backgroundColor + ? colorAsString(artwork.backgroundColor) + : undefined, + } satisfies ComponentArtworkType; +</script> + +<Artwork + artwork={artworkForComponent} + profile={computedProfileAttributes || profile} + {topRoundedSecondary} + {useContainerStyle} + {forceFullWidth} + {imageSettings} + {alt} + {isDecorative} + {lazyLoad} + {disableAutoCenter} + {noShelfChevronAnchor} + {withoutBorder} +/> + +<style> + /* When a user enables the "Smart Invert" accessibility setting, images should not be inverted, + so we are re-inverting back to their normal state in this media query, which only currently works for Safari. */ + @media (inverted-colors: inverted) { + :global(.artwork-component img) { + filter: invert(1); + } + } +</style> diff --git a/src/components/CollapsableContent.svelte b/src/components/CollapsableContent.svelte new file mode 100644 index 0000000..e75fbf1 --- /dev/null +++ b/src/components/CollapsableContent.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import ChevronDown from '~/sf-symbols/chevron.down.svg'; +</script> + +<details> + <summary> + <slot name="summary" /> + <ChevronDown /> + </summary> + + <slot /> +</details> + +<style> + details[open] summary { + display: none; + } + + summary { + list-style: none; + cursor: pointer; + } + + summary::-webkit-details-marker { + display: none; + } + + summary :global(svg) { + overflow: visible; + width: 14px; + fill: var(--systemTertiary); + position: relative; + top: 3px; + left: 2px; + } +</style> diff --git a/src/components/EditorsChoiceBadge.svelte b/src/components/EditorsChoiceBadge.svelte new file mode 100644 index 0000000..2c4efe1 --- /dev/null +++ b/src/components/EditorsChoiceBadge.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import LaurelIcon from '~/sf-symbols/laurel.left.svg'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); +</script> + +<h4> + <span class="icon-container left" aria-hidden="true"> + <LaurelIcon /> + </span> + {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')} + <span class="icon-container right" aria-hidden="true"> + <LaurelIcon /> + </span> +</h4> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + h4 { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-bottom: 10px; + gap: 10px; + font: var(--font, var(--title-1-emphasized)); + color: var(--systemSecondary); + } + + .icon-container.right { + transform: rotateY(180deg); + + @include rtl { + transform: rotateY(0); + } + } + + .icon-container.left { + @include rtl { + transform: rotateY(180deg); + } + } + + .icon-container :global(svg) { + overflow: visible; + height: 42px; + transform: translateY(3px); + } + + .icon-container :global(svg path) { + fill: var(--systemSecondary); + } +</style> diff --git a/src/components/Error.svelte b/src/components/Error.svelte new file mode 100644 index 0000000..a0aeba1 --- /dev/null +++ b/src/components/Error.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import ErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let error: Error; + + const i18n = getI18n(); +</script> + +<ErrorPage translateFn={$i18n.t} {error} /> diff --git a/src/components/GradientOverlay.svelte b/src/components/GradientOverlay.svelte new file mode 100644 index 0000000..5827a2c --- /dev/null +++ b/src/components/GradientOverlay.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + export let shouldDarken: boolean = true; +</script> + +<div class="gradient-overlay" style:--brightness={shouldDarken ? 0.85 : 1} /> + +<style> + .gradient-overlay { + position: absolute; + z-index: 1; + bottom: 0; + width: 100%; + height: var(--height, 60%); + border-radius: var(--border-radius, var(--global-border-radius-large)); + background: linear-gradient( + transparent, + var(--color, var(--systemSecondary-onLight)) var(--height, 100%) + ); + backdrop-filter: blur(10px); + filter: saturate(1.5) brightness(var(--brightness)); + mask-image: linear-gradient(180deg, transparent 6%, rgb(0, 0, 0.5) 85%); + } +</style> diff --git a/src/components/Grid.svelte b/src/components/Grid.svelte new file mode 100644 index 0000000..df2ca74 --- /dev/null +++ b/src/components/Grid.svelte @@ -0,0 +1,37 @@ +<script lang="ts" generics="T"> + import { getGridVars } from '@amp/web-app-components/src/components/Shelf/utils/getGridVars'; + import type { GridType } from '@amp/web-app-components/src/components/Shelf/types'; + + export let items: T[] = []; + export let gridType: GridType; + + $: style = getGridVars(gridType); +</script> + +<ul {style} class="grid" data-test-id="grid"> + {#each items as item} + <li> + <slot {item} /> + </li> + {/each} +</ul> + +<style lang="scss"> + @mixin grid-styles-for-viewport($viewport: null) { + grid-template-columns: repeat(var(--grid-#{$viewport}), 1fr); + column-gap: var(--grid-column-gap-#{$viewport}); + row-gap: var(--grid-row-gap-#{$viewport}); + } + + .grid { + display: grid; + width: 100%; + padding: 0 var(--bodyGutter); + + @each $viewport in ('xsmall', 'small', 'medium', 'large', 'xlarge') { + @media (--range-#{$viewport}-only) { + @include grid-styles-for-viewport($viewport); + } + } + } +</style> diff --git a/src/components/HoverWrapper.svelte b/src/components/HoverWrapper.svelte new file mode 100644 index 0000000..2d2742f --- /dev/null +++ b/src/components/HoverWrapper.svelte @@ -0,0 +1,54 @@ +<script lang="ts"> + export let element: keyof HTMLElementTagNameMap = 'article'; + export let hasChin: boolean = false; +</script> + +<svelte:element this={element} class="hover-wrapper" class:has-chin={hasChin}> + <slot /> +</svelte:element> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/mixins/scrim-opacity-controller' as *; + @use 'amp/stylekit/core/mixins/hover-style' as *; + + .hover-wrapper { + position: relative; + display: var(--display, flex); + overflow: hidden; + align-items: center; + cursor: pointer; + border-radius: var(--global-border-radius-large); + box-shadow: var(--shadow-small); + + @include scrim-opacity-controller; + } + + .hover-wrapper.has-chin, + .hover-wrapper.has-chin::after { + // For chins, we cannot use `border-raidus` due a Chrome bug with unequal radii + // (e.g. there is no rounding at the bottom) and mask-image. To get around that, + // we use clip-path to the same effect. + // https://issues.chromium.org/issues/40778541. + border-radius: unset; + clip-path: inset( + 0 0 0 0 round var(--global-border-radius-large) + var(--global-border-radius-large) 0 0 + ); + } + + /* stylelint-disable order/order */ + .hover-wrapper::after { + mix-blend-mode: soft-light; + + @include content-container-hover-style; + + // These properties are overriding those provided by `content-container-hover-style` + border-radius: var(--global-border-radius-large); + transition: opacity 210ms ease-out; + } + /* stylelint-enable order/order */ + + .hover-wrapper:hover::after { + @include scrim-opacity; + } +</style> diff --git a/src/components/LaunchNativeButton.svelte b/src/components/LaunchNativeButton.svelte new file mode 100644 index 0000000..eb7942b --- /dev/null +++ b/src/components/LaunchNativeButton.svelte @@ -0,0 +1,69 @@ +<script lang="ts"> + import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { getJet } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + import { launchAppOnMac } from '~/utils/launch-client'; + + export let url: string; + + const i18n = getI18n(); + const jet = getJet(); + + function handleButtonClick(event: MouseEvent) { + // Need to call both event.preventDefault() and event.stopPropagation() + // to prevent navigation to the production page on web + event.preventDefault(); + event.stopPropagation(); + + if (url) { + launchAppOnMac(url); + jet.recordCustomMetricsEvent({ + eventType: 'click', + targetId: 'OpenInMacAppStore', + targetType: 'button', + actionType: 'open', + }); + } + } +</script> + +<button + class="get-button blue" + aria-label={$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.AX')} + on:click={handleButtonClick} +> + <LineClamp clamp={1}> + {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.Action')} + <span> + {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.App')} + </span> + </LineClamp> + <ArrowIcon class="external-link-arrow" aria-hidden="true" /> +</button> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + button { + display: inline-flex; + } + + button span { + font-weight: 500; + } + + button :global(.external-link-arrow) { + align-self: center; + width: var(--launch-native-button-arrow-size, 9px); + height: var(--launch-native-button-arrow-size, 9px); + padding-top: 1px; + margin-inline-start: 4px; + fill: var(--systemPrimary-onDark); + + @include rtl { + transform: rotate(-90deg); + } + } +</style> diff --git a/src/components/LinkWrapper.svelte b/src/components/LinkWrapper.svelte new file mode 100644 index 0000000..0e5025d --- /dev/null +++ b/src/components/LinkWrapper.svelte @@ -0,0 +1,60 @@ +<!-- +@component +Wraps a link around the provided slot contents if a valid `FlowAction` or `ExternalUrlAction` is given. +If no valid action is provided, the contents are rendered as-is with no decoration. + +💡 For accessibility, this component should ideally wrap the entire visual block (e.g., `div`, `article`) so that +screen readers and keyboard users interpret the entire element as a single link. + +@example +``` + <LinkWrapper action={item.clickAction}> + <article> + <Artwork artwork={item.artwork} /> + {item.title} + </article> + </LinkWrapper> +``` +--> +<script lang="ts"> + import { type Action, isFlowAction } from '@jet-app/app-store/api/models'; + import { type Opt, isSome } from '@jet/environment/types/optional'; + + import FlowActionComponent from '~/components/jet/action/FlowAction.svelte'; + import { isExternalUrlAction } from '~/jet/models'; + import ExternalUrlAction from './jet/action/ExternalUrlAction.svelte'; + import ShelfBasedPageScrollAction, { + isShelfBasedPageScrollAction, + } from './jet/action/ShelfBasedPageScrollAction.svelte'; + + export let action: Opt<Action> = null; + export let label: Opt<string> = null; + export let withoutLabel: Opt<boolean> = false; + export let includeExternalLinkArrowIcon: boolean = true; +</script> + +{#if isSome(action) && isFlowAction(action) && isSome(action.pageUrl)} + <FlowActionComponent + destination={action} + aria-label={withoutLabel ? null : label || action.title} + > + <slot /> + </FlowActionComponent> +{:else if isSome(action) && isExternalUrlAction(action)} + <ExternalUrlAction + destination={action} + aria-label={withoutLabel ? null : label || action.title} + includeArrowIcon={includeExternalLinkArrowIcon} + > + <slot /> + </ExternalUrlAction> +{:else if isSome(action) && isShelfBasedPageScrollAction(action)} + <ShelfBasedPageScrollAction + destination={action} + aria-label={withoutLabel ? null : label || action.title} + > + <slot /> + </ShelfBasedPageScrollAction> +{:else} + <slot /> +{/if} diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte new file mode 100644 index 0000000..8221c79 --- /dev/null +++ b/src/components/Menu.svelte @@ -0,0 +1,218 @@ +<script lang="ts" generics="T"> + import { tick } from 'svelte'; + import type { Opt } from '@jet/environment/types/optional'; + import type { MouseEventHandler } from 'svelte/elements'; + import { onDestroy, onMount } from 'svelte'; + import { generateUuid } from '@amp/web-apps-utils/src'; + import { + computePosition, + autoUpdate, + offset, + flip, + shift, + } from '@floating-ui/dom'; + + export let options: T[]; + // Allows the developer the override the floating-ui calculated offset to a fixed number + export let forcedXPosition: number | null = null; + + export let handleShowMenu: () => void = () => {}; + + let isMenuOpen = false; + + /** + * Display the menu + * + * @example + * <script> + * let menu; + * + * function showMenu() { + * menu.show(); + * } + * <\/script> + * + * <Menu bind:this={menu} /> + */ + export async function show() { + if (!menuEl) return; + + isMenuOpen = true; + + // Menu position should be updated *only* after the dialog has been shown + updateMenuPosition(); + + // Focuses the first link in the dropdown after the DOM updates + await tick(); + menuEl.querySelector('a')?.focus(); + + // When the modal is open, track viewport changes and update the menu position + floatingUIAutoUpdatePositionCleanupCallback = autoUpdate( + trigger!, + menuEl!, + updateMenuPosition, + ); + } + + /** + * Close the menu + * + * @example + * <script> + * let menu; + * + * function closeMenu() { + * menu.close(); + * } + * <\/script> + * + * <Menu bind:this={menu} /> + */ + export function close() { + if (!menuEl) return; + + isMenuOpen = false; + cleanUpFloatingUIAutoPosition(); + } + + function toggle() { + if (isMenuOpen) { + close(); + } else { + show(); + handleShowMenu?.(); + } + } + + const menuId = generateUuid(); + + let menuEl: HTMLUListElement | undefined; + let trigger: HTMLButtonElement | undefined; + + function handleKeyUp(event: KeyboardEvent) { + if (event.key === 'Escape') { + close(); + } + } + + /** + * Dismiss the dialog when clicking anywhere with the dialog open + */ + const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => { + const clickedElement = event.target as HTMLElement; + + // Only close the dialog if the click is "outside" of the trigger + // Otherwise, it will be closed immediately + if (!trigger?.contains(clickedElement)) { + close(); + } + }; + + /// MARK: Menu Positioning through `FloatingUI` + + /** + * Update the position of the menu to align it with the trigger + */ + async function updateMenuPosition() { + const { x, y } = await computePosition(trigger!, menuEl!, { + middleware: [ + offset({ + mainAxis: 10, + }), + + flip(), + shift(), + ], + placement: 'bottom-end', + }); + + Object.assign(menuEl!.style, { + left: `${forcedXPosition || x}px`, + top: `${y}px`, + }); + } + + let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>; + + /** + * Cleans up the `FloatingUI` auto-update listener, which should only be "active" + * while the menu is open + */ + function cleanUpFloatingUIAutoPosition() { + floatingUIAutoUpdatePositionCleanupCallback?.(); + floatingUIAutoUpdatePositionCleanupCallback = undefined; + } + + onMount(() => { + // Ensures menu is hidden initially + if (menuEl) isMenuOpen = false; + }); + + onDestroy(function () { + cleanUpFloatingUIAutoPosition(); + }); +</script> + +<svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} /> + +<button + class="menu-trigger" + aria-controls={menuId} + aria-haspopup="menu" + aria-expanded={isMenuOpen} + bind:this={trigger} + on:click={toggle} +> + <slot name="trigger" /> +</button> + +<ul + id={menuId} + hidden={!isMenuOpen} + tabindex="-1" + class="menu-popover focus-visible" + bind:this={menuEl} +> + {#each options as option} + <li class="menu-item" role="presentation"> + <slot name="option" {option} /> + </li> + {/each} +</ul> + +<style> + :root { + --menu-common-padding: 4px 8px; + } + + .menu-trigger { + background-color: var(--menu-trigger-background-color); + border-radius: var(--menu-trigger-border-radius); + font: var(--menu-trigger-font); + padding: var(--menu-trigger-padding, var(--menu-common-padding)); + } + + .menu-popover { + background-color: var(--menu-popover-background-color, var(--pageBg)); + padding: var(--menu-popover-padding, 0); + border: var(--menu-popover-border, none); + border-radius: var( + --menu-popover-border-radius, + var(--global-border-radius-large) + ); + box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium)); + position: absolute; + inset: auto; + z-index: var(--menu-popover-z-index, 2); + } + + .menu-popover::backdrop { + background: var(--menu-popover-backdrop-background, none); + } + + .menu-item { + padding: var(--menu-item-padding, var(--menu-common-padding)); + margin: var(--menu-item-margin, 0); + white-space: nowrap; + } +</style> diff --git a/src/components/MotionArtwork.svelte b/src/components/MotionArtwork.svelte new file mode 100644 index 0000000..646df26 --- /dev/null +++ b/src/components/MotionArtwork.svelte @@ -0,0 +1,152 @@ +<script lang="ts"> + import { createEventDispatcher, onMount, onDestroy } from 'svelte'; + import { loggerFor } from '@amp/web-apps-logger'; + + const logger = loggerFor('components/MotionArtwork'); + + type HLSError = { + type: string; + message: string; + details: string; + fatal: boolean; + handled: boolean; + }; + + type MotionArtworkError = { + type: string; + reason: string; + fatal: boolean; + error?: Error; + }; + + /** HTML `id` attribute for the <video /> element */ + export let id: string; + + /** Source URL for the video, an HLS playlist ending in .m3u8 */ + export let src: string; + + /** Poster image to show while the video is loading */ + export let poster: string | undefined; + + /** If the video should loop from end to start. */ + export let loop: boolean = true; + + /** If the audio should be muted on the video. */ + export let muted: boolean = true; + + /** If the video should be paused when initially loaded. */ + export let paused: boolean = true; + + /** The constructor to use for creating an Hls playback session. */ + export let HLS: Window['Hls'] = window.Hls; + + /** RTCReportingAgent instance for RTC reporting on video playback. */ + export let reportingAgent: any = undefined; + + /** HTMLVideoElement used by HLS.js to render the video */ + export let videoElement: HTMLVideoElement | null = null; + + /** Internal error state for the component */ + let errorState: MotionArtworkError | undefined; + + let hlsSession: Window['Hls'] | undefined; + + /** Dispatcher for errors. */ + const dispatch = createEventDispatcher<{ error: MotionArtworkError }>(); + + function handleError(details: MotionArtworkError) { + logger.error( + `Error playing MotionArtwork with HLS: ${details?.reason}`, + details?.error, + ); + + errorState = { + type: details.type, + reason: details.reason, + fatal: details.fatal, + error: details?.error, + }; + + dispatch('error', errorState); + } + + const hlsSupported = HLS?.isSupported() ?? false; + + onMount(function () { + if (!hlsSupported) { + handleError({ + type: 'runtime', + reason: 'unsupported', + fatal: true, + }); + return; + } + + // Create a new HLS.js playback session + hlsSession = new HLS({ + debug: false, + debugLevel: 'error', + enablePerformanceLogging: false, + nativeControlsEnabled: false, + + appData: { + reportingAgent: reportingAgent, + serviceName: reportingAgent?.ServiceName, + }, + }); + + hlsSession.on( + HLS.Events.ERROR, + function (_event: string, error: HLSError) { + handleError({ + type: 'hls', + reason: error.message, + fatal: error.fatal, + error: error as unknown as Error, + }); + }, + ); + + // Direct HLS.js to the VideoElement to use and start loading the video source + hlsSession.attachMedia(videoElement); + hlsSession.loadSource(src, { + /* HLS.js loading options go here */ + }); + }); + + onDestroy(() => { + // Stop the video, release resources, and destroy the HLS context + hlsSession?.destroy(); + }); +</script> + +{#if errorState !== undefined} + <slot name="error" error={errorState} {poster} /> +{:else} + <!-- svelte-ignore a11y-media-has-caption --> + <video + {id} + {loop} + {poster} + preload="none" + data-loop={true} + playsinline={true} + controls={false} + bind:this={videoElement} + bind:muted + bind:paused + on:play + on:ended + on:loadedmetadata + /> +{/if} + +<style> + video { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; + aspect-ratio: var(--aspect-ratio); + } +</style> diff --git a/src/components/Page.svelte b/src/components/Page.svelte new file mode 100644 index 0000000..5b44c06 --- /dev/null +++ b/src/components/Page.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import { + type Page, + hasVisionProUrl, + isAppEventDetailPage, + isArticlePage, + isChartsHubPage, + isGenericPage, + isSearchLandingPage, + isShelfBasedProductPage, + isTopChartsPage, + isTodayPage, + isSearchResultsPage, + isStaticMessagePage, + isSeeAllPage, + isErrorPage, + } from '~/jet/models'; + + import AppEventDetailPage from './pages/AppEventDetailPage.svelte'; + import ArticlePage from './pages/ArticlePage.svelte'; + import ChartsHubPage from './pages/ChartsHubPage.svelte'; + import DefaultPage from './pages/DefaultPage.svelte'; + import ErrorPage from './pages/ErrorPage.svelte'; + import ProductPage from './pages/ProductPage.svelte'; + import VisionProPage from './pages/VisionProPage.svelte'; + import StaticMessagePageComponent from './pages/StaticMessagePage.svelte'; + import SearchLandingPage from './pages/SearchLandingPage.svelte'; + import SearchResultsPage from './pages/SearchResultsPage.svelte'; + import TopChartsPage from './pages/TopChartsPage.svelte'; + import TodayPage from './pages/TodayPage.svelte'; + import SeeAllPage from './pages/SeeAllPage.svelte'; + import MetaTags from '~/components/structure/MetaTags.svelte'; + import PageModal from '~/components/PageModal.svelte'; + + export let page: Page; +</script> + +<MetaTags {page} /> + +<PageModal /> + +{#if isAppEventDetailPage(page)} + <AppEventDetailPage {page} /> +{:else if isArticlePage(page)} + <ArticlePage {page} /> +{:else if isChartsHubPage(page)} + <ChartsHubPage {page} /> +{:else if isSearchLandingPage(page)} + <SearchLandingPage {page} /> +{:else if isSearchResultsPage(page)} + <SearchResultsPage {page} /> +{:else if isShelfBasedProductPage(page)} + <ProductPage {page} /> +{:else if isTopChartsPage(page)} + <TopChartsPage {page} /> +{:else if isGenericPage(page) && hasVisionProUrl(page)} + <VisionProPage {page} /> +{:else if isTodayPage(page)} + <TodayPage {page} /> +{:else if isStaticMessagePage(page)} + <StaticMessagePageComponent {page} /> +{:else if isSeeAllPage(page)} + <SeeAllPage {page} /> +{:else if isErrorPage(page)} + <ErrorPage {page} /> +{:else} + <DefaultPage {page} /> +{/if} diff --git a/src/components/PageModal.svelte b/src/components/PageModal.svelte new file mode 100644 index 0000000..9e5ee50 --- /dev/null +++ b/src/components/PageModal.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { onMount, type SvelteComponent } from 'svelte'; + import type { GenericPage } from '@jet-app/app-store/api/models'; + + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import { getModalPageStore } from '~/stores/modalPage'; + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import { LICENSE_AGREEMENT_MODAL_ID } from '~/utils/metrics'; + + let modalElement: SvelteComponent; + let modalPage = getModalPageStore(); + let page: GenericPage | undefined; + + $: page = $modalPage?.page; + $: shelves = page?.shelves ?? []; + $: title = page?.title ?? null; + $: targetId = + $modalPage?.pageDetail === 'licenseAgreement' + ? LICENSE_AGREEMENT_MODAL_ID + : undefined; + + onMount(() => { + return modalPage.clearPage; + }); + + $: { + if ($modalPage) { + modalElement?.showModal(); + } else { + handleModalClose(); + } + } + + function handleModalClose() { + modalElement?.close(); + modalPage.clearPage(); + } +</script> + +<Modal + modalTriggerElement={null} + bind:this={modalElement} + on:close={handleModalClose} +> + <div class="modal-content"> + {#if page} + <ContentModal + {title} + subtitle={null} + on:close={handleModalClose} + {targetId} + > + <svelte:fragment slot="content"> + {#each shelves as shelf} + <ShelfComponent {shelf}> + <slot + name="marker-shelf" + slot="marker-shelf" + let:shelf + {shelf} + /> + </ShelfComponent> + {/each} + </svelte:fragment> + </ContentModal> + {/if} + </div> +</Modal> + +<style lang="scss"> + .modal-content :global(p) { + user-select: text; + margin-bottom: 15px; + overflow-wrap: break-word; + } + + :global(.noscroll) { + overflow: hidden; + touch-action: none; + } +</style> diff --git a/src/components/PageResolver.svelte b/src/components/PageResolver.svelte new file mode 100644 index 0000000..9f482aa --- /dev/null +++ b/src/components/PageResolver.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import type { Page } from '~/jet/models'; + + import PageComponent from '~/components/Page.svelte'; + import ErrorComponent from '~/components/Error.svelte'; + import LoadingSpinner from '@amp/web-app-components/src/components/LoadingSpinner/LoadingSpinner.svelte'; + + export let page: Promise<Page> | Page; + export let isFirstPage: boolean; +</script> + +{#await page} + <div data-testid="page-loading"> + <!-- + Delay showing the spinner on initial page load after app boot. + After that, the FlowAction handler already waits 500ms before + it changes DOM, so we only need to wait 1000ms. + --> + <LoadingSpinner delay={isFirstPage ? 1500 : 1000} /> + </div> +{:then page} + <PageComponent {page} /> +{:catch error} + <ErrorComponent {error} /> +{/await} diff --git a/src/components/ProductPageArcadeBanner.svelte b/src/components/ProductPageArcadeBanner.svelte new file mode 100644 index 0000000..154c115 --- /dev/null +++ b/src/components/ProductPageArcadeBanner.svelte @@ -0,0 +1,188 @@ +<script lang="ts"> + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); +</script> + +<aside> + <div class="arcade-banner"> + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <h2> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')} + <br /> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')} + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')} + </h2> + + <a href="https://www.apple.com/apple-arcade/" target="_blank"> + <span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText', + )} + </span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark', + )} + </a> + </div> + </div> +</aside> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .logo-container { + width: 62px; + margin-bottom: 10px; + line-height: 0; + + @media (--range-xsmall-only) { + width: 48px; + margin-bottom: 8px; + } + } + + .logo-container :global(path) { + color: var(--systemPrimary-onDark); + } + + .metadata-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 60%; + height: 100%; + padding: 0 20px; + + @media (--range-xsmall-only) { + align-items: flex-start; + justify-content: center; + } + } + + h2 { + margin-bottom: 10px; + font: var(--title-1-emphasized); + + @media (--range-xsmall-only) { + margin-bottom: 8px; + font: var(--title-3-emphasized); + } + } + + a { + display: flex; + font: var(--title-3-emphasized); + + @media (--range-xsmall-only) { + font: var(--body-emphasized); + } + } + + a::after { + content: '↗'; + font-weight: normal; + margin-inline-start: 4px; + } + + a:hover { + text-decoration: none; + } + + a:hover span { + text-decoration: underline; + } + + aside { + width: 100%; + max-width: calc(viewport-content-for(xlarge)); + height: 152px; + margin: 0 auto 32px; + padding: 0 var(--bodyGutter); + + @media (--range-xsmall-only) { + max-width: 100%; + padding: 0; + } + } + + .arcade-banner { + width: 100%; + height: 100%; + color: var(--systemPrimary-onDark); + border-radius: var(--global-border-radius-medium); + background: #000; + background-repeat: no-repeat; + background-position: right; + background-size: contain; + + @media (prefers-color-scheme: dark) { + border: 1px solid var(--systemQuaternary-onDark); + } + + @media (--range-xsmall-only) { + border-radius: 0; + background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png'); + background-size: cover; + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png'); + background-position: left; + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png'); + background-position: left; + } + } + } + + @media (--range-small-only) { + background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png'); + background-position: left; + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png'); + background-position: left; + } + } + } + + @media (--range-medium-up) { + background-image: url('/assets/images/arcade/upsell/banner-980@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-980@1x_RTL.png'); + background-position: left; + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/banner-980@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-980@2x_RTL.png'); + background-position: left; + } + } + } + } +</style> diff --git a/src/components/ProductPageArcadeFooter.svelte b/src/components/ProductPageArcadeFooter.svelte new file mode 100644 index 0000000..0cd9b65 --- /dev/null +++ b/src/components/ProductPageArcadeFooter.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); +</script> + +<article> + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <h2> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')} + <br /> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')} + <br /> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')} + </h2> + + <a href="https://www.apple.com/apple-arcade/" target="_blank"> + <span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText', + )} + </span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark', + )} + </a> + </div> +</article> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .logo-container { + width: 72px; + margin-bottom: 20px; + line-height: 0; + + @media (--range-xsmall-only) { + width: 62px; + margin-bottom: 16px; + } + } + + .logo-container :global(path) { + color: var(--systemPrimary-onDark); + } + + .metadata-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 60%; + height: 100%; + padding: 40px; + + @media (--range-xsmall-only) { + align-items: center; + justify-content: end; + width: unset; + text-align: center; + } + } + + h2 { + margin-bottom: 20px; + font: var(--header-emphasized); + line-height: 54px; + + @media (--range-xsmall-only) { + font: var(--title-1-emphasized); + } + } + + a { + display: flex; + font: var(--title-3-emphasized); + } + + a::after { + content: '↗'; + font-weight: normal; + margin-inline-start: 4px; + } + + a:hover { + text-decoration: none; + } + + a:hover span { + text-decoration: underline; + } + + article { + flex-grow: 1; + width: 100%; + max-width: calc(viewport-content-for(xlarge) - var(--bodyGutter) * 2); + aspect-ratio: 2.55; + margin: 0 auto; + color: var(--systemPrimary-onDark); + background: #000; + background-size: cover; + + @media (--range-xsmall-only) { + max-width: 338px; + aspect-ratio: 35/48; + border-radius: var(--global-border-radius-medium); + background-image: url('/assets/images/arcade/upsell/footer-280@1x.png'); + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/footer-280@2x.png'); + } + } + + @media (--range-small-only) { + aspect-ratio: 173/96; + background-image: url('/assets/images/arcade/upsell/footer-692@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-692@1x_RTL.png'); + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/footer-692@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-692@2x_RTL.png'); + } + } + } + + @media (--range-medium-up) { + background-image: url('/assets/images/arcade/upsell/footer-980@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-980@1x_RTL.png'); + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/footer-980@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-980@2x_RTL.png'); + } + } + } + + @media (--range-xlarge-up) { + border-radius: var(--global-border-radius-medium); + } + } +</style> diff --git a/src/components/SFSymbol.svelte b/src/components/SFSymbol.svelte new file mode 100644 index 0000000..998ab06 --- /dev/null +++ b/src/components/SFSymbol.svelte @@ -0,0 +1,51 @@ +<!-- +@component +Renders a supported "SF Symbol" from the icons available in `~/sf-symbols` +--> +<script lang="ts" context="module"> + import type { ComponentType } from 'svelte'; + + const iconComponents = import.meta.glob('~/sf-symbols/*.svg', { + eager: true, + import: 'default', + }); + + const iconNameToComponent: Record<string, ComponentType | undefined> = + Object.fromEntries( + Object.entries(iconComponents).map( + ([fullPathToIcon, iconComponent]) => { + const iconName = fullPathToIcon + .replace('/src/sf-symbols/', '') + .replace('.svg', ''); + + return [iconName, iconComponent as ComponentType]; + }, + ), + ); + + /** + * The list of all supported icons + * + * This is exposed only for testing/Storybook purposes + */ + export const __iconNames = Object.keys(iconNameToComponent); + + export function getIconComponentByName(iconName: string) { + return iconNameToComponent[iconName]; + } +</script> + +<script lang="ts"> + /** + * The name of the SF Symbol to render + * + * Must match the name of an `.svg` file in `~/sf-symbols`. If a file with a matching + * name does not exist, nothing will be rendered + */ + export let name: string; + export let ariaHidden: boolean = false; + + $: icon = getIconComponentByName(name); +</script> + +<svelte:component this={icon} aria-hidden={ariaHidden ? 'true' : 'false'} /> diff --git a/src/components/ShareArrowButton.svelte b/src/components/ShareArrowButton.svelte new file mode 100644 index 0000000..7b822fc --- /dev/null +++ b/src/components/ShareArrowButton.svelte @@ -0,0 +1,90 @@ +<script lang="ts" context="module"> + export function isShareSupported() { + return ( + typeof navigator !== 'undefined' && + typeof navigator.share === 'function' + ); + } +</script> + +<script lang="ts"> + import SFSymbol from '~/components/SFSymbol.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let url: string; + export let withLabel: boolean = false; + + const i18n = getI18n(); + + $: isShareSheetOpen = false; + + async function handleShareClick() { + isShareSheetOpen = !isShareSheetOpen; + + try { + await navigator.share({ url }); + isShareSheetOpen = false; + } catch { + isShareSheetOpen = false; + } + } +</script> + +<button + on:click={handleShareClick} + aria-label={$i18n.t('ASE.Web.AppStore.Share.Button.AccessibilityValue')} + class:active={isShareSheetOpen} + class:with-label={withLabel} +> + <SFSymbol name="square.and.arrow.up" ariaHidden={true} /> + + {#if withLabel} + {$i18n.t('ASE.Web.AppStore.Share.Button.Value')} + {/if} +</button> + +<style lang="scss"> + button { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--share-arrow-size, 32px); + height: var(--share-arrow-size, 32px); + border-radius: var(--share-arrow-size, 32px); + background: var(--systemQuaternary-onDark); + transition: background-color 210ms ease-out; + mix-blend-mode: plus-lighter; + } + + button.with-label { + display: flex; + align-items: center; + width: auto; + padding: 0 16px; + gap: 8px; + font: var(--body-emphasized); + + :global(svg) { + height: 16px; + width: auto; + top: -2px; + position: relative; + } + } + + button.active, + button:hover { + // stylelint-disable color-function-notation + background-color: rgb(from var(--systemTertiary-onDark) r g b/.13); + // stylelint-enable color-function-notation + } + + button :global(svg) { + width: 37%; + fill: var(--systemPrimary-onDark); + overflow: visible; + } +</style> diff --git a/src/components/Shelf/Title.svelte b/src/components/Shelf/Title.svelte new file mode 100644 index 0000000..e68f4b1 --- /dev/null +++ b/src/components/Shelf/Title.svelte @@ -0,0 +1,112 @@ +<!-- +@component + +Renders the "Title" and "See All action" for a `Shelf` + +### Supported CSS Variables + +- `--shelf-title-font`: overrides the font used for the "title" element + +--> +<script lang="ts"> + import { type Opt, isSome } from '@jet/environment/types/optional'; + import { type Action, isFlowAction } from '@jet-app/app-store/api/models'; + + import SFSymbol from '~/components/SFSymbol.svelte'; + import LinkWrapper from '../LinkWrapper.svelte'; + + export let title: string; + export let subtitle: Opt<string> = undefined; + export let seeAllAction: Opt<Action> = undefined; +</script> + +<div class="title-action-wrapper" class:with-subtitle={!!subtitle}> + <LinkWrapper action={seeAllAction} label={title}> + <div class="link-contents"> + <h2 class="shelf-title" data-test-id="shelf-title">{title}</h2> + + {#if isSome(seeAllAction) && isFlowAction(seeAllAction)} + <span + class="chevron-container" + data-test-id="shelf-see-all-chevron" + aria-hidden="true" + > + <SFSymbol name="chevron.forward" /> + </span> + {/if} + </div> + </LinkWrapper> +</div> + +{#if subtitle} + <p>{subtitle}</p> +{/if} + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .title-action-wrapper { + display: flex; + align-items: end; + justify-content: space-between; + margin: 0 var(--bodyGutter) 13px; + } + + .title-action-wrapper.with-subtitle { + margin-bottom: 3px; + } + + .title-action-wrapper :global(a:hover) { + text-decoration: none; + } + + p { + font: var(--title-3-tall); + color: var(--systemSecondary); + margin: 0 var(--bodyGutter) 13px; + } + + h2 { + color: var(--systemPrimary, #000); + font: var(--shelf-title-font, var(--title-2-emphasized)); + text-wrap: pretty; + } + + .link-contents { + display: flex; + align-items: center; + gap: 6px; + } + + .chevron-container { + line-height: 0; + padding: 6px 0 4px; + display: block; + } + + .chevron-container :global(svg) { + height: 12px; + display: block; + translate: 0 0; + transition: translate 210ms ease-out; + + @include rtl { + transform: rotate(180deg); + } + } + + .chevron-container :global(svg path:not([fill='none'])) { + fill: var(--systemGray2); + } + + .link-contents:hover .chevron-container :global(svg) { + translate: 1px 0; + + @include rtl { + transform: rotate(180deg); + translate: -1px 0; + } + } +</style> diff --git a/src/components/Shelf/Wrapper.svelte b/src/components/Shelf/Wrapper.svelte new file mode 100644 index 0000000..850b0d0 --- /dev/null +++ b/src/components/Shelf/Wrapper.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import type { Shelf } from '@jet-app/app-store/api/models'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + + export let shelf: Shelf | undefined = undefined; + + /** + * Whether or not to automatically display the shelf "centered" at the normal + * page width for the App Store + * + * When `false`, the shelf is not constrained horizontally in any way + * + * The value of this property may be ignored when the shelf's `.presentationHints` + * indicate that it is being rendered in a context where "centering" would not be + * appropriate + * + * @default true + */ + export let centered: boolean = false; + + export let withTopBorder: boolean = false; + export let withTopMargin: boolean = false; + export let withPaddingTop: boolean = true; + export let withBottomPadding: boolean = true; + + $: seeAllAction = + shelf?.header?.titleAction ?? + shelf?.header?.accessoryAction ?? + shelf?.seeAllAction; +</script> + +<section + id={shelf?.id} + data-test-id="shelf-wrapper" + class="shelf" + class:centered + class:border-top={withTopBorder} + class:margin-top={withTopMargin} + class:padding-top={withPaddingTop} + class:padding-bottom={withBottomPadding} +> + {#if $$slots['title']} + <slot name="title" /> + {:else if shelf?.header?.title} + <ShelfTitle + title={shelf.header.title} + subtitle={shelf.header.subtitle} + {seeAllAction} + /> + {:else if shelf?.title} + <ShelfTitle + title={shelf.title} + subtitle={shelf.subtitle} + {seeAllAction} + /> + {/if} + + <slot /> +</section> + +<style> + .padding-top { + padding-top: 13px; + } + + .centered { + margin: 0 var(--bodyGutter); + } + + .margin-top { + margin-top: 13px; + } + + .border-top { + border-top: 1px solid var(--systemGray4); + } + + .shelf.padding-bottom { + padding-bottom: 32px; + } +</style> diff --git a/src/components/ShelfItemLayout.svelte b/src/components/ShelfItemLayout.svelte new file mode 100644 index 0000000..ef1d07c --- /dev/null +++ b/src/components/ShelfItemLayout.svelte @@ -0,0 +1,103 @@ +<!-- +@component +Renders a set of `Shelf` items in either a horizontal shelf +or a grid, depending on the `shelf` configuration + +Note: when configuring the `gridType` property, a single value will be used +for both the shelf-based or grid-based item layouts. If two different grid types +are needed instead, `gridTypeForShelf` and `gridTypeForGrid` are needed instead; +these properties cannot be used alongside the general-purpose `gridType`. +--> +<script lang="ts" generics="Item"> + import type { Shelf } from '@jet-app/app-store/api/models'; + + import type { GridType } from '@amp/web-app-components/src/components/Shelf/types'; + + import type { XOR } from '~/utils/types'; + import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte'; + import Grid from '~/components/Grid.svelte'; + + /** + * The sub-set of {@linkcode Shelf} that is necesary to render this component + */ + interface RequiredShelf + extends Pick<Shelf, 'rowsPerColumn' | 'isHorizontal'> { + items: Item[]; + } + + interface $$Slots { + default: { + item: Item; + }; + } + + /** + * Represents the `gridType` properties of this component + * + * Either a `gridType` that will be used for both the shelf or grid + * layouts can be provided, OR specific properties for the grid type + * for the shelf and grid respectively; this `XOR` here prevents + * these approachs from being mixed-and-matched. + */ + type GeneralOrIndividualGridType = XOR< + { + gridType: GridType; + }, + { + gridTypeForGrid: GridType; + gridTypeForShelf: GridType; + } + >; + + type $$Props = GeneralOrIndividualGridType & { + shelf: RequiredShelf; + rowsPerColumnOverride?: number | null; + }; + + /** + * The shelf to render items for + */ + export let shelf: RequiredShelf; + + /** + * An optional override of the shelfs `rowsPerColumn` property + */ + export let rowsPerColumnOverride: number | null = null; + + /** + * Determine the grid type configuration for the shelf or grid layouts + * based on the mutually-exclusive properties of {@linkcode GeneralOrIndividualGridType} + */ + function extractGridTypes(props: $$Props) { + if (typeof props.gridType === 'string') { + return { + gridTypeForShelf: props.gridType, + gridTypeForGrid: props.gridType, + }; + } else { + return props; + } + } + + $: ({ gridTypeForShelf, gridTypeForGrid } = extractGridTypes( + $$props as $$Props, + )); + + $: isHorizontal = shelf.isHorizontal; + $: gridRows = rowsPerColumnOverride ?? shelf.rowsPerColumn ?? undefined; +</script> + +{#if isHorizontal} + <HorizontalShelf + items={shelf.items} + {gridRows} + gridType={gridTypeForShelf} + let:item + > + <slot {item} /> + </HorizontalShelf> +{:else} + <Grid items={shelf.items} gridType={gridTypeForGrid} let:item> + <slot {item} /> + </Grid> +{/if} diff --git a/src/components/StarRating.svelte b/src/components/StarRating.svelte new file mode 100644 index 0000000..84da44b --- /dev/null +++ b/src/components/StarRating.svelte @@ -0,0 +1,80 @@ +<script lang="ts" context="module"> + export function calculateStarFillPercentages(rating: number) { + return [1, 2, 3, 4, 5].map((position) => { + if (position <= Math.floor(rating)) { + return 100; + } + + if (position > Math.ceil(rating)) { + return 0; + } + + return Math.round((rating % 1) * 100); + }); + } +</script> + +<script lang="ts"> + import StarFilledIcon from '@amp/web-app-components/assets/icons/star-filled.svg'; + import StarHollowIcon from '@amp/web-app-components/assets/icons/star-hollow.svg'; + import { getI18n } from '~/stores/i18n'; + + export let rating: number; + + const i18n = getI18n(); + + $: starFillPercentages = calculateStarFillPercentages(rating); + $: label = $i18n.t('ASE.Web.AppStore.Review.StarsAria', { + count: rating, + }); +</script> + +<ol class="stars" aria-label={label}> + {#each starFillPercentages as fillPercent} + <li class="star"> + {#if fillPercent === 100} + <StarFilledIcon /> + {:else if fillPercent === 0} + <StarHollowIcon /> + {:else} + <div + class="partial-star" + style:--partial-star-width={`${fillPercent}%`} + > + <StarFilledIcon /> + </div> + + <StarHollowIcon /> + {/if} + </li> + {/each} +</ol> + +<style> + .stars { + display: flex; + } + + .star { + position: relative; + margin-inline-end: 2px; + line-height: 0; + } + + .star :global(svg) { + height: var(--star-size, 10px); + width: var(--star-size, 10px); + fill: var(--fill-color, rgb(127, 127, 127)); + } + + .partial-star { + position: absolute; + overflow: hidden; + width: var(--partial-star-width); + fill: var(--fill-color, rgb(127, 127, 127)); + } + + .partial-star :global(path) { + stroke: transparent; + } +</style> diff --git a/src/components/SystemImage.svelte b/src/components/SystemImage.svelte new file mode 100644 index 0000000..40723dd --- /dev/null +++ b/src/components/SystemImage.svelte @@ -0,0 +1,52 @@ +<!-- +@component +Renders an `Artwork` view model that references an SF Symbol through a `systemimage://` or `resource://` template URL +--> +<script lang="ts" context="module"> + import type { Artwork } from '@jet-app/app-store/api/models'; + + const systemImagePrefix = 'systemimage://'; + const resourcePrefix = 'resource://'; + + type SystemImageTemplate = `${typeof systemImagePrefix}${string}`; + type ResourceTemplate = `${typeof resourcePrefix}${string}`; + + /** + * An {@linkcode Artwork} that references a system image + */ + interface FullSystemImageArtwork extends Artwork { + template: SystemImageTemplate | ResourceTemplate; + } + + /** + * The sub-set of {@linkcode FullSystemImageArtwork} required to render + * the icon + */ + type SystemImageArtwork = Pick<FullSystemImageArtwork, 'template'>; + + /** + * Determine if some {@linkcode Artwork} represents a "system image" + */ + export function isSystemImageArtwork( + artwork: Artwork, + ): artwork is FullSystemImageArtwork { + return ( + artwork.template.startsWith(systemImagePrefix) || + artwork.template.startsWith(resourcePrefix) + ); + } + + export function getIconNameFromTemplate(template: string) { + return new URL(template).host; + } +</script> + +<script lang="ts"> + import SFSymbol from '~/components/SFSymbol.svelte'; + + export let artwork: SystemImageArtwork; + + $: name = getIconNameFromTemplate(artwork.template); +</script> + +<SFSymbol {name} /> diff --git a/src/components/VideoPlayer.svelte b/src/components/VideoPlayer.svelte new file mode 100644 index 0000000..8012b9f --- /dev/null +++ b/src/components/VideoPlayer.svelte @@ -0,0 +1,412 @@ +<script lang="ts"> + import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer'; + import MotionArtwork from '~/components/MotionArtwork.svelte'; + import { getJet } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + import type { Video } from '@jet-app/app-store/api/models'; + import { + MetricsActionDetails, + MetricsActionType, + type MetricsActionDetailItem, + type MetricsActionTypeItem, + } from '~/constants/media-metrics'; + + /** HTML `id` attribute for the <video /> element */ + export let id: string; + + /** Source URL for the video, an HLS playlist ending in .m3u8 */ + export let src: string; + + /** Poster image to show while the video is loading */ + export let poster: string | undefined; + + /** If the video should play automatically when in view */ + export let autoplay: boolean = false; + + /* The whole-number percentage amount of the video needs to be in view before autoplay kicks in */ + export let autoplayVisibilityThreshold: number = 0; + + /** If the video should loop from end to start. */ + export let loop: boolean = false; + + /** If the audio should be muted on the video. */ + export let muted: boolean = true; + + /** If our controls should be shown in the video player. */ + export let useControls: boolean = true; + + /** The constructor to use for creating an Hls playback session. */ + export let HLS: Window['Hls'] = window.Hls; + + /** + * If we should bypass the `poster` attribute on the `video` tag, in favor of having the poster + * image overlaid as it's own DOM element, which covers an HLS playback bug in Safari, wherein + * the video is seeked to the first frame once the metadata is loaded, thus removing the poster. + */ + export let shouldSuperimposePosterImage: boolean = false; + + /** an optional metric template provided by jet */ + export let metricsTemplate: + | Record<string, unknown> + | Video['templateMediaEvent'] = {}; + + export function play(isAutoPlay = true) { + videoRef?.play(); + recordMediaEvent( + MetricsActionType.PLAY, + isAutoPlay + ? MetricsActionDetails.AUTOPLAY + : MetricsActionDetails.PLAY, + ); + } + + export function pause(isAutoPause = true) { + recordMediaEvent( + MetricsActionType.STOP, + isAutoPause + ? MetricsActionDetails.AUTOPAUSE + : MetricsActionDetails.PAUSE, + ); + + videoRef?.pause(); + } + + let isPaused: boolean = !autoplay; + let isMuted: boolean = muted; + let shouldShowReplayControl: boolean = false; + let shouldShowPlaybackControls: boolean = true; + let hasPlaybackBeenInitiated: boolean = false; + let videoRef: HTMLVideoElement | null = null; + + const i18n = getI18n(); + const jet = getJet(); + + const handleFullScreenButtonClick = () => { + videoRef?.requestFullscreen(); + }; + + const handleReplayButtonClick = () => { + if (videoRef) { + videoRef.currentTime = 0; + videoRef.play(); + shouldShowPlaybackControls = true; + } + }; + + const handlePlayButtonClick = () => { + if (isPaused) { + play(false); + } else { + pause(false); + } + }; + + const handleMuteButtonClick = () => { + isMuted = !isMuted; + }; + + const handleVideoEnded = () => { + if (!loop) { + shouldShowPlaybackControls = true; + + if (videoRef) { + videoRef.currentTime = 1; + videoRef.pause(); + } + + recordMediaEvent( + MetricsActionType.STOP, + MetricsActionDetails.COMPLETE, + ); + } + }; + + const handleVideoPlay = () => { + // Display the replay button after the first play + shouldShowReplayControl = true; + hasPlaybackBeenInitiated = true; + }; + + // metric events that are waiting for loadMetadata from video element + let queuedMetricEvents: Array<() => void> = []; + + // flush any metric events once load metadata has been called + const flushMetricEvents = () => { + queuedMetricEvents.forEach((recordFn) => recordFn()); + + queuedMetricEvents = []; + }; + + const recordMediaEvent = ( + actionType: MetricsActionTypeItem, + actionDetail: MetricsActionDetailItem, + ) => { + if (!metricsTemplate?.fields) { + return; + } + + const recordEvent = () => { + const duration = Math.floor(videoRef?.duration ?? 0) * 1000; + const position = Math.min( + Math.floor((videoRef?.currentTime ?? 0) * 1000), + duration, + ); + jet.recordCustomMetricsEvent({ + ...(metricsTemplate?.fields ?? {}), + actionType: actionType, + actionDetails: actionDetail, + url: src, + duration, + position, + topic: metricsTemplate?.topic ?? '', + }); + }; + + if (Number.isNaN(videoRef?.duration)) { + queuedMetricEvents.push(() => recordEvent()); + } else { + recordEvent(); + } + }; + + const isVideoPlaying = (video: HTMLVideoElement | null) => { + if (!video) { + return false; + } + return !!( + video.currentTime > 0 && + !video.paused && + !video.ended && + video.readyState > 2 + ); + }; + + const intersectionObserverConfig = { + threshold: autoplayVisibilityThreshold, + callback: (isIntersectingViewport: boolean) => { + if (isIntersectingViewport) { + play(); + } else if (isVideoPlaying(videoRef)) { + pause(); + } + }, + }; +</script> + +<div + class="video-container" + use:intersectionObserver={autoplay ? intersectionObserverConfig : undefined} +> + <div class="video"> + <MotionArtwork + {id} + {HLS} + {src} + {loop} + poster={!shouldSuperimposePosterImage ? poster : undefined} + bind:muted={isMuted} + bind:paused={isPaused} + bind:videoElement={videoRef} + on:play={handleVideoPlay} + on:ended={handleVideoEnded} + on:loadedmetadata={flushMetricEvents} + /> + </div> + + {#if shouldSuperimposePosterImage && !hasPlaybackBeenInitiated} + <img + src={poster} + class="fake-poster" + aria-hidden="true" + loading="lazy" + alt="" + /> + {/if} + + {#if useControls} + <div class="video-control"> + {#if shouldShowReplayControl} + <button + class="video-control-replay" + aria-label={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Replay', + )} + on:click={handleReplayButtonClick} + > + <img + class="btn-img" + src="/assets/images/video-control/video-control-replay.png" + alt={$i18n.t('ASE.Web.AppStore.VideoPlayer.AX.Replay')} + aria-hidden="true" + /> + </button> + {/if} + + {#if shouldShowPlaybackControls} + <div class="video-control-playback"> + <button + class="video-control-play" + aria-label={$i18n.t( + isPaused + ? 'ASE.Web.AppStore.VideoPlayer.AX.Play' + : 'ASE.Web.AppStore.VideoPlayer.AX.Pause', + )} + on:click={handlePlayButtonClick} + > + {#if isPaused} + <img + class="btn-img" + src="/assets/images/video-control/video-control-play.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Play', + )} + aria-hidden="true" + /> + {:else} + <img + class="btn-img" + src="/assets/images/video-control/video-control-pause.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Pause', + )} + aria-hidden="true" + /> + {/if} + </button> + + <button + class="video-control-unmute" + aria-label={$i18n.t( + isMuted + ? 'ASE.Web.AppStore.VideoPlayer.AX.Unmute' + : 'ASE.Web.AppStore.VideoPlayer.AX.Mute', + )} + on:click={handleMuteButtonClick} + > + {#if isMuted} + <img + class="btn-img" + src="/assets/images/video-control/video-control-volume-muted.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Mute', + )} + aria-hidden="true" + /> + {:else} + <img + class="btn-img" + src="/assets/images/video-control/video-control-volume.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Unmute', + )} + aria-hidden="true" + /> + {/if} + </button> + + <button + class="video-control-fullscreen" + aria-label={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen', + )} + on:click={handleFullScreenButtonClick} + > + <img + class="btn-img" + src="/assets/images/video-control/video-control-fullscreen.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen', + )} + aria-hidden="true" + /> + </button> + </div> + {/if} + </div> + {/if} +</div> + +<style> + .video-container { + --button-size: 32px; + display: grid; + position: relative; + container-type: inline-size; + container-name: video-container; + width: 100%; + height: 100%; + background-color: var(--systemQuaternary); + } + + .video { + width: 100%; + height: 100%; + grid-column: 1; + grid-row: 1; + line-height: 0; + } + + .video-control { + grid-column: 1; + grid-row: 1; + display: inline-flex; + justify-content: space-between; + z-index: 1; + align-self: end; + color: white; + margin: 0 12px 12px; + } + + .video-control::after { + position: absolute; + content: ''; + z-index: -1; + bottom: 0; + left: 0; + display: block; + box-sizing: border-box; + width: 100%; + height: calc(var(--button-size) * 2); + background: linear-gradient( + 0deg, + rgb(0, 0, 0, 0.68), + rgb(0, 0, 0, 0.2), + transparent + ); + mask-image: linear-gradient(360deg, #000 47%, transparent); + } + + .video-control-playback { + display: inline-flex; + margin-inline-start: auto; + gap: 6px; + } + + .btn-img { + height: var(--button-size); + width: var(--button-size); + border-radius: 50%; + border: 1px solid var(--systemQuaternary-onDark); + background: rgba(0, 0, 0, 0.11); + backdrop-filter: blur(20px); + object-fit: cover; + transition: background 105ms ease-out; + } + + .btn-img:hover { + background: rgba(0, 0, 0, 0.05); + } + + @container video-container (max-width: 500px) { + .btn-img { + --button-size: 24px; + } + } + + .fake-poster { + width: 100%; + position: absolute; + top: 0; + left: 0; + } +</style> diff --git a/src/components/decorators/HlsJSDecorator.svelte b/src/components/decorators/HlsJSDecorator.svelte new file mode 100644 index 0000000..591cb0d --- /dev/null +++ b/src/components/decorators/HlsJSDecorator.svelte @@ -0,0 +1,67 @@ +<script lang="ts" context="module"> + // This store is used to keep track of in-flight requests, ensuring that we don't attempt + // to load the same src (which is stored in the Map key) multiple times. + const inFlightRequests = new Map<string, Promise<void>>(); +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import { generateHLSJSURL } from '~/config/hlsjs'; + import { generateRTCJSURL } from '~/config/rtcjs'; + + export let version: string | undefined = undefined; + + let hlsjsSourceURL = generateHLSJSURL(version).toString(); + let rtcjsSourceURL = generateRTCJSURL(version).toString(); + + function loadScript(src: string): Promise<void> { + // If we have an in-flight request for this `src`, return it. + const inFlightRequest = inFlightRequests.get(src); + if (inFlightRequest) { + return inFlightRequest; + } + + const promise = new Promise<void>(function (resolve, reject) { + const scriptElement = document.createElement('script'); + scriptElement.src = src; + scriptElement.onload = () => resolve(); + scriptElement.onerror = () => { + // If a script fails to load due to a network blip, we remove it from the store, + // so that the next attempt in an `onMount` will try to load the `src` again. + inFlightRequests.delete(src); + reject(); + }; + + document.head.appendChild(scriptElement); + }); + + // Add the given `src` to the store so we can keep track of in-flight requests. + inFlightRequests.set(src, promise); + + return promise; + } + + let loading: Promise<[void, void]> | undefined; + + onMount(() => { + loading = Promise.all([ + window.Hls ?? loadScript(hlsjsSourceURL), + window.rtc ?? loadScript(rtcjsSourceURL), + ]); + }); +</script> + +{#if loading} + {#await loading} + <slot name="loading-component" /> + {:then} + <slot HLS={window.Hls} RTC={window.rtc} /> + {:catch} + <div> + <p> + Failed to load HLS.js {version} from + <a href={hlsjsSourceURL}>{hlsjsSourceURL}</a> + </p> + </div> + {/await} +{/if} diff --git a/src/components/hero/AppLockupDetail.svelte b/src/components/hero/AppLockupDetail.svelte new file mode 100644 index 0000000..e4abe47 --- /dev/null +++ b/src/components/hero/AppLockupDetail.svelte @@ -0,0 +1,109 @@ +<!-- +@component +Component for rendering App information into the `details` slot +of the `Hero.svelte` component +--> +<script lang="ts"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + + import { getI18n } from '~/stores/i18n'; + import AppIcon from '~/components/AppIcon.svelte'; + + const i18n = getI18n(); + + export let lockup: Lockup; + export let isOnDarkBackground: boolean = true; +</script> + +<div class="lockup-container"> + {#if lockup.icon} + <div class="app-icon-container"> + <AppIcon icon={lockup.icon} profile="app-icon-small" /> + </div> + {/if} + + <div class="text-container"> + {#if lockup.heading} + <LineClamp clamp={1}> + <h4>{lockup.heading}</h4> + </LineClamp> + {/if} + + {#if lockup.title} + <LineClamp clamp={2}> + <h3>{lockup.title}</h3> + </LineClamp> + {/if} + + {#if lockup.subtitle} + <LineClamp clamp={1}> + <p>{lockup.subtitle}</p> + </LineClamp> + {/if} + </div> + + <div class="button-container"> + <span + class="get-button" + class:transparent={isOnDarkBackground} + class:dark-gray={!isOnDarkBackground} + > + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> +</div> + +<style lang="scss"> + .lockup-container { + display: flex; + align-items: center; + width: 100%; + max-width: 350px; + margin-top: 20px; + padding-top: 20px; + color: var(--hero-primary-color, var(--systemPrimary-onDark)); + border-top: 1px solid + var(--hero-divider-color, var(--systemQuaternary-onDark)); + + @media (--range-xsmall-down) { + text-align: left; + padding: 20px 0 10px; + max-width: unset; + } + } + + .app-icon-container { + flex-shrink: 0; + width: 64px; + margin-inline-end: 16px; + } + + .text-container { + width: 100%; + margin-inline-end: 16px; + } + + h3 { + font: var(--title-3-emphasized); + text-wrap: pretty; + } + + h4 { + color: var(--hero-secondary-color, var(--systemSecondary-onDark)); + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: var(--hero-text-blend-mode, plus-lighter); + } + + p { + mix-blend-mode: var(--hero-text-blend-mode, plus-lighter); + } + + .button-container { + --get-button-font: var(--title-3-bold); + position: relative; + z-index: 1; + } +</style> diff --git a/src/components/hero/Carousel.svelte b/src/components/hero/Carousel.svelte new file mode 100644 index 0000000..218813b --- /dev/null +++ b/src/components/hero/Carousel.svelte @@ -0,0 +1,132 @@ +<!-- +@component +Component for rendering a carousel of `Hero.svelte` components in a way taht's decoupled from +any particular data model +--> +<script lang="ts" generics="Item"> + import type { Opt } from '@jet/environment/types/optional'; + import type { Artwork, Shelf } from '@jet-app/app-store/api/models'; + + import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer'; + import mediaQueries from '~/utils/media-queries'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + import HeroCarouselBackgroundPortal, { + id as portalId, + } from '~/components/hero/CarouselBackgroundPortal.svelte'; + import AmbientBackgroundArtwork from '~/components/AmbientBackgroundArtwork.svelte'; + import portal from '~/utils/portal'; + import { carouselMediaStyle } from '~/stores/carousel-media-style'; + + interface $$Slots { + default: { + /** + * The `Item` to render as a `Hero` in the carousel + */ + item: Item; + }; + } + + /** + * The shelf being rendered + * + * Used to derrive any shelf-specific presentation + */ + export let shelf: Shelf; + + /** + * The items to render in the hero carousel + * + * This is decoupled from `shelf` to avoid assuming that `shelf.items` is, itself, + * the set of items that we need to present; some shelves model their items as chilren + * of the first shelf item. + */ + export let items: Item[]; + + /** + * Callback that determines the "background artwork" to use behind the + * active `Hero` for the given `Item` + */ + export let deriveBackgroundArtworkFromItem: (item: Item) => Opt<Artwork>; + + $: gridRows = shelf.rowsPerColumn ?? undefined; + $: isXSmallViewport = $mediaQueries === 'xsmall'; + + let activeIndex: number | undefined = 0; + + function createIntersectionObserverCallback(index: number) { + return (isIntersectingViewport: boolean) => { + if (isIntersectingViewport) { + // Many different types of `item`s can be rendered in this Carousel, and all those + // different items have different ways of determining whether or not the background + // is dark or light, so we are running through all the options here. + const { style, mediaOverlayStyle, isMediaDark } = items[ + index + ] as any; + const fallbackStyle = 'dark'; + let derivedStyle; + + if (typeof isMediaDark !== 'undefined') { + derivedStyle = isMediaDark ? 'dark' : 'light'; + } + + carouselMediaStyle.set( + style || mediaOverlayStyle || derivedStyle || fallbackStyle, + ); + + activeIndex = index; + } + }; + } +</script> + +<HeroCarouselBackgroundPortal /> + +<ShelfWrapper {shelf} --shelfGridGutterWidth="0"> + <HorizontalShelf + {gridRows} + {items} + --shelfScrollPaddingInline="0" + --grid-max-content-xsmall={!$sidebarIsHidden + ? 'calc(100% + 50px)' + : '100vw'} + gridType="Spotlight" + let:item + let:index + > + {#if isXSmallViewport} + <div + use:intersectionObserver={{ + callback: createIntersectionObserverCallback(index), + threshold: 0.5, + }} + > + <slot {item} /> + </div> + {:else} + <div + use:intersectionObserver={{ + callback: createIntersectionObserverCallback(index), + threshold: 0, + }} + > + {#if !import.meta.env.SSR} + {@const backgroundArtwork = + deriveBackgroundArtworkFromItem(item)} + + {#if backgroundArtwork} + <div use:portal={portalId}> + <AmbientBackgroundArtwork + artwork={backgroundArtwork} + active={activeIndex === index} + /> + </div> + {/if} + {/if} + + <slot {item} /> + </div> + {/if} + </HorizontalShelf> +</ShelfWrapper> diff --git a/src/components/hero/CarouselBackgroundPortal.svelte b/src/components/hero/CarouselBackgroundPortal.svelte new file mode 100644 index 0000000..4580ce0 --- /dev/null +++ b/src/components/hero/CarouselBackgroundPortal.svelte @@ -0,0 +1,17 @@ +<script lang="ts" context="module"> + export const id = 'hero-carousel-shelf-background-portal'; +</script> + +<div {id} /> + +<style> + #hero-carousel-shelf-background-portal { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow-x: hidden; + z-index: -1; + } +</style> diff --git a/src/components/hero/Hero.svelte b/src/components/hero/Hero.svelte new file mode 100644 index 0000000..f643ffa --- /dev/null +++ b/src/components/hero/Hero.svelte @@ -0,0 +1,536 @@ +<!-- +@component +Component for rendering an item in a "Hero Carousel" without coupling to any specific data model +--> +<script lang="ts"> + import type { Opt } from '@jet/environment/types/optional'; + import type { + Action, + Artwork as ArtworkModel, + Color, + Video as VideoModel, + } from '@jet-app/app-store/api/models'; + + import mediaQueries from '~/utils/media-queries'; + import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + import { + colorAsString, + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + + /** + * The main text for the carousel item + */ + export let title: Opt<string> = undefined; + + /** + * Additional text above the title. + * Note: If a slot is defined with the name `eyebrow`, the slot takes precedence. + */ + export let eyebrow: Opt<string> = undefined; + + /** + * Additional text below the title + */ + export let subtitle: Opt<string> = undefined; + + /** + * Primary accent color for the carousel item + */ + export let backgroundColor: Opt<Color> = undefined; + + /** + * Static artwork to display in the carousel item + */ + export let artwork: Opt<ArtworkModel> = undefined; + + /** + * Video to display in the carousel item + * + * Takes precedence over `artwork` + */ + export let video: Opt<VideoModel> = undefined; + + /** + * Action to perform when clicking on the carousel item + */ + export let action: Opt<Action> = undefined; + + /** + * Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container + */ + export let pinArtworkToHorizontalEnd: boolean = false; + + /** + * Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default) + */ + export let pinArtworkToVerticalMiddle: boolean = false; + + /** + * Whether the text (e.g. title, description, etc) should be pinned to the top of the container + */ + export let pinTextToVerticalStart: boolean = false; + + /** + * Allows for the absolute overriding of the profile used for the Hero artwork + */ + export let profileOverride: Opt<NamedProfile> = null; + + export let isMediaDark: boolean = true; + + export let collectionIcons: ArtworkModel[] | undefined = undefined; + + let isPortraitLayout: boolean; + let profile: NamedProfile; + let collectionIconsBackgroundGradientCssVars: string | undefined = + undefined; + + $: isPortraitLayout = $mediaQueries === 'xsmall'; + + $: { + if (profileOverride) { + profile = profileOverride; + } else if (isPortraitLayout) { + profile = 'large-hero-portrait'; + } else if (pinArtworkToHorizontalEnd && isRtl()) { + profile = 'large-hero-east'; + } else if (pinArtworkToHorizontalEnd) { + profile = 'large-hero-west'; + } else { + profile = 'large-hero'; + } + } + + const color: string = backgroundColor + ? colorAsString(backgroundColor) + : '#000'; + + if (collectionIcons && collectionIcons.length > 1) { + // If there are multiple app icons, we build a string of CSS variables from the icons + // background colors to fill as many of the lockups quadrants as possible. + collectionIconsBackgroundGradientCssVars = + getBackgroundGradientCSSVarsFromArtworks(collectionIcons, { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + shouldRemoveGreys: true, + }); + } +</script> + +<LinkWrapper {action} includeExternalLinkArrowIcon={false}> + <article + data-test-id="hero" + class:with-dark-media={isMediaDark} + class:with-collection-icons={!artwork && !video && collectionIcons} + class:text-pinned-to-vertical-start={pinTextToVerticalStart} + > + {#if video || artwork} + <div + class={`image-container ${profile}`} + class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd} + class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle} + style:--color={color} + > + {#if video && !$prefersReducedMotion} + <Video + loop + autoplay + useControls={false} + {video} + {profile} + /> + {:else if artwork} + <Artwork + {artwork} + {profile} + noShelfChevronAnchor={true} + useCropCodeFromArtwork={false} + withoutBorder={true} + /> + {/if} + </div> + {:else if collectionIcons} + <ul class="app-icons"> + {#each collectionIcons?.slice(0, 5) as collectionIcon} + <li class="app-icon-container"> + <AppIcon + icon={collectionIcon} + profile="app-icon-large" + fixedWidth={false} + /> + </li> + {/each} + </ul> + + <div + class="collection-icons-background-gradient" + style={collectionIconsBackgroundGradientCssVars} + /> + {/if} + + <div class="gradient" style="--color: {color};" /> + + <slot name="badge" {isPortraitLayout} /> + + <div class="metadata-container"> + {#if $$slots.eyebrow} + <h3><slot name="eyebrow" /></h3> + {:else if eyebrow} + <h3>{eyebrow}</h3> + {/if} + + {#if title} + <h2>{@html sanitizeHtml(title)}</h2> + {/if} + + {#if subtitle} + <p class="subtitle">{@html sanitizeHtml(subtitle)}</p> + {/if} + + <slot name="details" {isPortraitLayout} /> + </div> + </article> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + --hero-primary-color: var(--systemPrimary-onLight); + --hero-secondary-color: var(--systemSecondary-onLight); + --hero-text-blend-mode: normal; + --hero-divider-color: var(--systemQuaternary-onLight); + position: relative; + display: flex; + overflow: hidden; + align-items: end; + aspect-ratio: 3 / 4; + container-name: hero-container; + container-type: size; + + @media (--range-small-up) { + aspect-ratio: 16 / 9; + width: 100%; + height: auto; + min-height: 360px; + max-height: min(60vh, 770px); + border-radius: var(--global-border-radius-large); + border: 1px solid var(--systemQuaternary); + } + } + + article.with-dark-media, + article.with-collection-icons { + --hero-primary-color: var(--systemPrimary-onDark); + --hero-secondary-color: var(--systemSecondary-onDark); + --hero-divider-color: var(--systemQuaternary-onDark); + --hero-text-blend-mode: plus-lighter; + } + + .image-container { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-color: var(--color); + } + + .image-container.pinned-to-vertical-middle { + display: flex; + align-items: center; + } + + .image-container.pinned-to-vertical-middle :global(.video-container), + .image-container.pinned-to-vertical-middle :global(.artwork-component) { + width: 100%; + height: auto; + } + + .image-container.pinned-to-horizontal-end :global(.artwork-component) { + height: 100%; + display: flex; + } + + .image-container.pinned-to-horizontal-end :global(.artwork-component img) { + height: 100%; + width: auto; + position: absolute; + inset-inline-end: 0; + + @container hero-container (aspect-ratio >= 279/100) { + width: 100%; + height: auto; + } + } + + .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl + :global(.artwork-component img) { + inset-inline-start: 0; + } + + // This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of + // 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the + // artwork full-width rather than full-height. This should eventually be fixed when Editorial + // can prescribe us only 16x9 (1.77:1) hero images. + .image-container.pinned-to-horizontal-end.large-hero-story-card, + .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl { + @container hero-container (aspect-ratio >= 225/100) { + :global(.artwork-component img) { + width: 100%; + height: auto; + } + } + } + + .metadata-container { + position: absolute; + width: 40%; + padding-bottom: 40px; + padding-inline-start: 40px; + text-wrap: pretty; + color: var(--hero-primary-color); + + @media (--range-small-only) { + width: 50%; + padding: 0 20px 20px; + } + + @media (--range-xsmall-down) { + width: 100%; + padding: 0 20px 20px; + text-align: center; + } + } + + .text-pinned-to-vertical-start .metadata-container { + @media (--range-small-only) { + top: 20px; + } + + @media (--range-medium-up) { + top: 40px; + } + } + + h2 { + position: relative; + z-index: 1; + text-wrap: balance; + font: var(--header-emphasized); + + @media (--range-xsmall-down) { + font: var(--title-1-emphasized); + } + } + + @container hero-container (height < 420px) { + h2 { + font: var(--large-title-emphasized); + } + } + + h3 { + margin-bottom: 8px; + position: relative; + z-index: 1; + color: var(--hero-secondary-color); + font: var(--callout-emphasized-tall); + mix-blend-mode: var(--hero-text-blend-mode); + + @media (--range-xsmall-down) { + margin-bottom: 4px; + } + } + + p { + mix-blend-mode: var(--hero-text-blend-mode); + } + + .subtitle { + margin-top: 8px; + position: relative; + z-index: 1; + font: var(--body-tall); + color: var(--hero-secondary-color); + } + + .gradient { + --rotation: 55deg; + + &:dir(rtl) { + --rotation: -55deg; + mask-image: radial-gradient( + ellipse 127% 130% at 95% 100%, + rgb(0, 0, 0) 18%, + rgb(0, 0, 0.33) 24%, + rgba(0, 0, 0, 0.66) 32%, + transparent 40% + ), + linear-gradient( + -129deg, + rgb(0, 0, 0) 0%, + rgba(255, 255, 255, 0) 55% + ); + } + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + // stylelint-disable color-function-notation + background: linear-gradient( + var(--rotation), + rgb(from var(--color) r g b / 0.25) 0%, + transparent 50% + ); + // stylelint-enable color-function-notation + filter: saturate(1.5) brightness(0.9); + backdrop-filter: blur(40px); + mask-image: radial-gradient( + ellipse 127% 130% at 5% 100%, + rgb(0, 0, 0) 18%, + rgb(0, 0, 0.33) 24%, + rgba(0, 0, 0, 0.66) 32%, + transparent 40% + ), + linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%); + + @media (--range-xsmall-down) { + --rotation: 0deg; + mask-image: linear-gradient( + var(--rotation), + rgb(0, 0, 0) 28%, + rgba(0, 0, 0, 0) 56% + ); + } + } + + // When the text is pinned to the top of the lockup, we use a different gradient for legibility + article.text-pinned-to-vertical-start .gradient { + --rotation: -170deg; + mask-image: radial-gradient( + ellipse 118% 121% at 100% 0%, + rgb(0, 0, 0) 18%, + rgb(0, 0, 0.33) 22%, + rgba(0, 0, 0, 0.66) 33%, + transparent 43% + ); + } + + .app-icons { + display: grid; + align-self: center; + width: 90%; + grid-template-rows: auto auto; + grid-auto-flow: column; + gap: 24px; + margin-inline-start: -4%; + position: absolute; + inset-inline-end: 24px; + + @media (--range-small-up) { + width: 44%; + } + } + + .app-icons li:nth-child(even) { + inset-inline-start: 44%; + } + + .app-icon-container { + position: relative; + flex-shrink: 0; + max-width: 200px; + } + + @property --top-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 20%; + } + + @property --bottom-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 40%; + } + + @property --top-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 55%; + } + + @property --bottom-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 50%; + } + + .collection-icons-background-gradient { + width: 100%; + height: 100%; + position: absolute; + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) var(--top-left-stop), + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) var(--bottom-left-stop), + transparent 80% + ), + radial-gradient( + circle at 66% -175%, + var(--top-right, #000) var(--top-right-stop), + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) var(--bottom-right-stop), + transparent 100% + ); + animation: collection-icons-background-gradient-shift 16s infinite + alternate-reverse; + animation-play-state: paused; + + @media (--range-small-up) { + animation-play-state: running; + } + } + + @keyframes collection-icons-background-gradient-shift { + 0% { + --top-left-stop: 20%; + --bottom-left-stop: 40%; + --top-right-stop: 55%; + --bottom-right-stop: 50%; + background-size: 100% 100%; + } + + 50% { + --top-left-stop: 25%; + --bottom-left-stop: 15%; + --top-right-stop: 70%; + --bottom-right-stop: 30%; + background-size: 130% 130%; + } + + 100% { + --top-left-stop: 15%; + --bottom-left-stop: 20%; + --top-right-stop: 55%; + --bottom-right-stop: 20%; + background-size: 110% 110%; + } + } +</style> diff --git a/src/components/icons/AppStoreLogo.svg b/src/components/icons/AppStoreLogo.svg new file mode 100644 index 0000000..185032f --- /dev/null +++ b/src/components/icons/AppStoreLogo.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__PaJpmjhr__"
\ No newline at end of file diff --git a/src/components/icons/AppleArcadeLogo.svg b/src/components/icons/AppleArcadeLogo.svg new file mode 100644 index 0000000..52902b2 --- /dev/null +++ b/src/components/icons/AppleArcadeLogo.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20384%2080'%20preserveAspectRatio='xMinYMin%20meet'%20%3e%3cpath%20fill='currentColor'%20d='M43.873%2012.699C46.606%209.28%2048.461%204.69%2047.972%200c-4.001.199-8.883%202.64-11.71%206.06-2.538%202.93-4.784%207.712-4.198%2012.206%204.49.39%208.978-2.245%2011.81-5.567M47.92%2019.144c-6.521-.389-12.067%203.701-15.182%203.701-3.116%200-7.885-3.506-13.044-3.411-6.714.098-12.945%203.895-16.352%209.933-7.008%2012.079-1.85%2029.996%204.966%2039.833%203.31%204.867%207.298%2010.226%2012.553%2010.034%204.966-.195%206.912-3.216%2012.948-3.216%206.032%200%207.785%203.216%2013.041%203.118%205.451-.097%208.859-4.869%2012.168-9.74%203.797-5.549%205.351-10.906%205.449-11.2-.098-.097-10.511-4.092-10.608-16.07-.098-10.03%208.176-14.801%208.565-15.097-4.672-6.91-11.972-7.69-14.503-7.885'%20/%3e%3cpath%20fill='currentColor'%20d='M115.598%2058.881H87.752L81.07%2078.627H69.273L95.651%205.569h12.252l26.377%2073.058h-12l-6.682-19.746zm-24.96-9.113h22.074L101.827%2017.72h-.304L90.638%2049.768zM140.503%2025.365h10.43v9.062h.253c1.773-6.226%206.531-9.923%2012.81-9.923%201.569%200%202.936.253%203.746.406v10.175c-.86-.354-2.784-.607-4.911-.607-7.038%200-11.391%204.71-11.391%2012.252v31.897h-10.937V25.365zM207.744%2043.693c-1.114-5.671-5.367-10.177-12.505-10.177-8.455%200-14.025%207.037-14.025%2018.48%200%2011.695%205.62%2018.48%2014.126%2018.48%206.734%200%2011.138-3.696%2012.404-9.873h10.53c-1.164%2011.34-10.227%2019.036-23.035%2019.036-15.24%200-25.162-10.43-25.162-27.643%200-16.91%209.923-27.593%2025.06-27.593%2013.72%200%2022.074%208.81%2023.036%2019.29h-10.43zM223.9%2063.489c0-9.317%207.14-15.037%2019.797-15.746l14.58-.86v-4.101c0-5.924-4-9.468-10.682-9.468-6.329%200-10.278%203.037-11.24%207.797h-10.328c.607-9.62%208.81-16.708%2021.973-16.708%2012.91%200%2021.163%206.835%2021.163%2017.517v36.707h-10.48v-8.76h-.254c-3.088%205.925-9.821%209.67-16.808%209.67-10.43%200-17.72-6.48-17.72-16.048zm34.378-4.81v-4.202l-13.113.81c-6.532.456-10.227%203.341-10.227%207.898%200%204.657%203.848%207.695%209.72%207.695%207.645%200%2013.62-5.265%2013.62-12.2zM276.853%2051.996c0-16.809%208.91-27.492%2022.276-27.492%207.645%200%2013.721%203.848%2016.707%209.721h.204V5.57h10.986v73.058h-10.632v-9.063h-.203c-3.139%206.075-9.214%209.974-16.96%209.974-13.468%200-22.378-10.734-22.378-27.542zm11.189%200c0%2011.239%205.417%2018.277%2014.075%2018.277%208.404%200%2014.023-7.139%2014.023-18.277%200-11.037-5.619-18.277-14.023-18.277-8.658%200-14.075%207.088-14.075%2018.277zM382.956%2062.982c-1.519%209.72-10.734%2016.657-22.935%2016.657-15.644%200-25.111-10.58-25.111-27.39%200-16.707%209.619-27.846%2024.656-27.846%2014.783%200%2023.997%2010.43%2023.997%2026.58v3.747h-37.616v.658c0%209.265%205.568%2015.39%2014.327%2015.39%206.228%200%2010.835-3.138%2012.303-7.796h10.379zm-36.96-15.897h26.631c-.252-8.15-5.417-13.873-13.061-13.873-7.646%200-13.012%205.823-13.57%2013.873z'%20/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/components/jet/Video.svelte b/src/components/jet/Video.svelte new file mode 100644 index 0000000..8d2e4f3 --- /dev/null +++ b/src/components/jet/Video.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + import type { Video } from '@jet-app/app-store/api/models'; + import VideoPlayer from '../VideoPlayer.svelte'; + import HlsJsDecorator from '../decorators/HlsJSDecorator.svelte'; + import { buildPoster } from '~/utils/video-poster'; + import { generateUuid } from '@amp/web-apps-utils/src'; + import type { NamedProfile } from 'src/config/components/artwork'; + import type { Profile } from '@amp/web-app-components/src/components/Artwork/types'; + import mediaQueries from '~/utils/media-queries'; + import { colorAsString } from '~/utils/color'; + + export let video: Video; + export let autoplay: boolean = false; + export let loop: boolean = false; + export let muted: boolean = true; + export let useControls: boolean = true; + export let profile: NamedProfile | Profile; + export let autoplayVisibilityThreshold: number = 0; + export let videoPlayerRef: InstanceType<typeof VideoPlayer> | null = null; + export let shouldSuperimposePosterImage: boolean = false; + + $: poster = + video.preview && buildPoster(video.preview, profile, $mediaQueries); + $: backgroundColor = video.preview.backgroundColor + ? colorAsString(video.preview.backgroundColor) + : '#f1f1f1'; + + $: metricsTemplate = video?.templateMediaEvent ?? {}; + const uuid = generateUuid(); +</script> + +<HlsJsDecorator let:HLS> + <VideoPlayer + {HLS} + {loop} + {muted} + {autoplay} + {useControls} + {autoplayVisibilityThreshold} + {metricsTemplate} + {shouldSuperimposePosterImage} + id={uuid} + src={video.videoUrl} + poster={poster ?? undefined} + --aspect-ratio={video.preview.width / video.preview.height} + bind:this={videoPlayerRef} + /> + + <div + class="loader" + slot="loading-component" + style:--aspect-ratio={video.preview.width / video.preview.height} + style:--background-image={`url(${poster})`} + style:--background-color={backgroundColor} + /> +</HlsJsDecorator> + +<style> + .loader { + aspect-ratio: var(--aspect-ratio); + width: 100%; + background-image: var(--background-image); + background-color: var(--background-color); + background-size: cover; + } +</style> diff --git a/src/components/jet/action/ExternalUrlAction.svelte b/src/components/jet/action/ExternalUrlAction.svelte new file mode 100644 index 0000000..e8a2ad6 --- /dev/null +++ b/src/components/jet/action/ExternalUrlAction.svelte @@ -0,0 +1,52 @@ +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + import type { ExternalUrlAction } from '@jet-app/app-store/api/models'; + import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg'; + import { getJetPerform } from '~/jet'; + + type AllowedAnchorAttributes = Omit< + HTMLAnchorAttributes, + // The `href` attribute is not allowed because it will be provided + // by the `ExternalUrlAction` + 'href' + >; + + interface $$Props extends AllowedAnchorAttributes { + destination: ExternalUrlAction; + includeArrowIcon?: boolean; + } + + const perform = getJetPerform(); + + export let destination: ExternalUrlAction; + export let includeArrowIcon: boolean = true; + + function handleClickAction() { + perform(destination); + } +</script> + +<a + {...$$restProps} + data-test-id="external-link" + href={destination.url} + target="_blank" + rel="nofollow noopener noreferrer" + on:click={handleClickAction} +> + <slot /> + {#if includeArrowIcon} + <ArrowIcon class="external-link-arrow" aria-hidden="true" /> + {/if} +</a> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + a :global(.external-link-arrow) { + @include rtl { + transform: rotate(-90deg); + } + } +</style> diff --git a/src/components/jet/action/FlowAction.svelte b/src/components/jet/action/FlowAction.svelte new file mode 100644 index 0000000..3e55e82 --- /dev/null +++ b/src/components/jet/action/FlowAction.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + import type { FlowAction } from '@jet-app/app-store/api/models'; + import { getJetPerform } from '~/jet'; + + type AllowedAnchorAttributes = Omit< + HTMLAnchorAttributes, + // The `href` attribute is not allowed because it will be provided + // by the `FlowAction` + 'href' + >; + + interface $$Props extends AllowedAnchorAttributes { + destination: FlowAction; + } + + const perform = getJetPerform(); + + export let destination: FlowAction; + + // Web cannot support internal protocols, so this guard prevents + // them from showing up in anchor tags. + $: pageUrl = destination.pageUrl?.includes('x-as3-internal:') + ? '#' + : destination?.pageUrl; + + function onClick(event: MouseEvent) { + event.preventDefault(); + + perform(destination); + } +</script> + +<a + {...$$restProps} + href={pageUrl} + data-test-id="internal-link" + on:click={onClick} +> + <slot /> +</a> diff --git a/src/components/jet/action/ShelfBasedPageScrollAction.svelte b/src/components/jet/action/ShelfBasedPageScrollAction.svelte new file mode 100644 index 0000000..9c1c13e --- /dev/null +++ b/src/components/jet/action/ShelfBasedPageScrollAction.svelte @@ -0,0 +1,51 @@ +<script lang="ts" context="module"> + import type { + Action, + ShelfBasedPageScrollAction, + } from '@jet-app/app-store/api/models'; + + export function isShelfBasedPageScrollAction( + action: Action, + ): action is ShelfBasedPageScrollAction { + return ( + action.$kind === 'ShelfBasedPageScrollAction' && 'shelfId' in action + ); + } +</script> + +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + + interface $$Props extends HTMLAnchorAttributes { + destination: ShelfBasedPageScrollAction; + } + + export let destination: ShelfBasedPageScrollAction; + + function handleLinkClick(e: Event) { + const anchorElement = e.currentTarget as HTMLAnchorElement; + const hash = anchorElement.hash; + const elementToScrollTo = document.querySelector(hash); + if (!elementToScrollTo) { + return; + } + elementToScrollTo.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + history.replaceState(null, '', hash); + } +</script> + +{#if destination.shelfId} + <a + {...$$restProps} + data-test-id="scroll-link" + href={`#${destination.shelfId}`} + on:click|preventDefault|stopPropagation={handleLinkClick} + > + <slot /> + </a> +{:else} + <slot /> +{/if} diff --git a/src/components/jet/badge/ContentRatingBadge.svelte b/src/components/jet/badge/ContentRatingBadge.svelte new file mode 100644 index 0000000..ff3a2c3 --- /dev/null +++ b/src/components/jet/badge/ContentRatingBadge.svelte @@ -0,0 +1,61 @@ +<script lang="ts" context="module"> + import type { Badge, BadgeType } from '@jet-app/app-store/api/models'; + + const ARTWORK_TYPE: BadgeType = 'artwork'; + const CONTENT_RATING_TYPE: BadgeType = 'contentRating'; + const CONTENT_RATING_KEY = 'contentRating'; + + interface ContentRatingBadge extends Badge { + type: typeof CONTENT_RATING_TYPE; + } + + export function isContentRatingBadge( + badge: Badge, + ): badge is ContentRatingBadge { + return ( + badge.type === CONTENT_RATING_TYPE || + (badge.key === CONTENT_RATING_KEY && badge.type === ARTWORK_TYPE) + ); + } +</script> + +<script lang="ts"> + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let badge: ContentRatingBadge; + + $: ({ artwork, accessibilityTitle } = badge); +</script> + +{#if artwork && isSystemImageArtwork(artwork)} + <div class="pictogram-container" aria-label={accessibilityTitle}> + <SystemImage {artwork} /> + </div> +{:else} + <span> + {badge.content.contentRating} + </span> +{/if} + +<style> + span { + height: 25px; + margin: 4px 0 2px; + font: var(--title-1-emphasized); + color: var(--color); + } + + .pictogram-container { + height: 25px; + padding: 2px; + aspect-ratio: 1/1; + margin: 4px 0 2px; + } + + .pictogram-container :global(svg) { + width: 21px; + height: 21px; + } +</style> diff --git a/src/components/jet/item/AccessibilityFeaturesItem.svelte b/src/components/jet/item/AccessibilityFeaturesItem.svelte new file mode 100644 index 0000000..bcbeb6c --- /dev/null +++ b/src/components/jet/item/AccessibilityFeaturesItem.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import type { AccessibilityFeatures } from '@jet-app/app-store/api/models'; + + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let item: AccessibilityFeatures; + export let isDetailView: boolean = false; +</script> + +<article + class:is-detail-view={isDetailView} + role={isDetailView ? 'presentation' : 'article'} +> + {#if !isDetailView} + {#if item.artwork && isSystemImageArtwork(item.artwork)} + <span class="icon-container" aria-hidden="true"> + <SystemImage artwork={item.artwork} /> + </span> + {/if} + <h2>{item.title}</h2> + {/if} + + <ul class:grid={item.features.length > 1 && !isDetailView}> + {#each item.features as feature} + <li> + {#if isSystemImageArtwork(feature.artwork)} + <span class="feature-icon-container" aria-hidden="true"> + <SystemImage artwork={feature.artwork} /> + </span> + {/if} + <div class="feature-content"> + <h3 class="feature-title">{feature.title}</h3> + {#if feature.description} + <span class="feature-description"> + {feature.description} + </span> + {/if} + </div> + </li> + {/each} + </ul> +</article> + +<style lang="scss"> + @use 'amp/stylekit/core/border-radiuses' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + display: flex; + flex-direction: column; + height: 100%; + padding: 30px; + gap: 8px; + text-align: center; + font: var(--body-tall); + border-radius: $global-border-radius-rounded-large; + background-color: var(--systemQuinary); + + &.is-detail-view { + padding: 0; + text-align: start; + background-color: transparent; + } + } + + .icon-container { + width: 30px; + margin: 0 auto; + } + + .icon-container :global(svg) { + width: 100%; + fill: var(--keyColor); + } + + h2 { + font: var(--title-3-emphasized); + margin-bottom: 8px; + } + + ul { + display: flex; + flex-direction: column; + gap: 25px; + } + + ul.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + li { + display: flex; + align-items: center; + justify-content: center; + text-align: start; + padding: 4px 0; + gap: 8px; + + .is-detail-view & { + gap: 10px; + justify-content: start; + align-items: flex-start; + } + } + + .grid li { + justify-content: start; + } + + .feature-icon-container { + display: inline-flex; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + + .is-detail-view & { + display: flex; + align-items: center; + + @media (prefers-color-scheme: dark) { + filter: none; + } + } + } + + .feature-icon-container :global(svg) { + width: 20px; + + .is-detail-view & { + width: 30px; + fill: var(--keyColor); + } + } + + .feature-content { + display: flex; + flex-direction: column; + gap: 6px; + } + + .feature-title { + font: var(--body-tall); + + .is-detail-view & { + color: var(--systemPrimary); + font: var(--title-2-emphasized); + } + } + + .feature-description { + color: var(--systemSecondary); + font: var(--body); + } +</style> diff --git a/src/components/jet/item/AccessibilityParagraphItem.svelte b/src/components/jet/item/AccessibilityParagraphItem.svelte new file mode 100644 index 0000000..836b52f --- /dev/null +++ b/src/components/jet/item/AccessibilityParagraphItem.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import type { AccessibilityParagraph } from '@jet-app/app-store/api/models'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let item: AccessibilityParagraph; +</script> + +<div> + <p> + <LinkableTextItem item={item.text} /> + </p> +</div> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/item/Annotation/AnnotationItem.svelte b/src/components/jet/item/Annotation/AnnotationItem.svelte new file mode 100644 index 0000000..38bb269 --- /dev/null +++ b/src/components/jet/item/Annotation/AnnotationItem.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { type Annotation } from '@jet-app/app-store/api/models'; + import ModernAnnotationItemRenderer from '~/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte'; + import LegacyAnnotationRenderer from '~/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte'; + + export let item: Annotation; + + $: ({ items, items_V3, linkAction, summary } = item); + + $: shouldRenderModernAnnotation = items_V3.length > 0; +</script> + +{#if shouldRenderModernAnnotation} + <ModernAnnotationItemRenderer items={items_V3} {summary} /> +{:else} + <LegacyAnnotationRenderer {items} {linkAction} /> +{/if} diff --git a/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte new file mode 100644 index 0000000..fc6586f --- /dev/null +++ b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import { isSome } from '@jet/environment'; + import { + type AnnotationItem, + type Action, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem[]; + export let linkAction: Action | undefined; + + const shouldRenderAsDefinitionList = (items: AnnotationItem[]) => + !!items[0]?.heading; + + const shouldRenderAsOrderedList = (items: AnnotationItem[]) => + !!items[0]?.textPairs; + + const shouldRenderAsUnorderedList = (items: AnnotationItem[]) => + !items[0]?.text; + + const shouldRenderAsDefinitionListWithHeading = (items: AnnotationItem[]) => + items[0]?.text && items[1]?.heading; +</script> + +{#if shouldRenderAsDefinitionList(items)} + <dl class="secondary-definition-list"> + {#each items as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> +{:else if shouldRenderAsOrderedList(items)} + <ol> + {#each items as annotationItem} + {#if annotationItem.textPairs} + {#each annotationItem.textPairs as [text, subtext]} + <li> + <span class="text">{text}</span> + <span class="subtext">{subtext}</span> + </li> + {/each} + {:else} + <li>{annotationItem.text}</li> + {/if} + {/each} + </ol> +{:else if shouldRenderAsUnorderedList(items)} + <ul> + {#each items as annotationItem} + <li> + <span class="text"> + {annotationItem.text} + </span> + </li> + {/each} + </ul> +{:else if shouldRenderAsDefinitionListWithHeading(items)} + {@const [heading, ...remainingItems] = items} + <dd> + <p class="secondary-definition-list-heading">{heading.text}</p> + + <dl class="secondary-definition-list"> + {#each remainingItems as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> + </dd> +{:else} + <dd> + <ul> + {#each items as annotationItem} + <li>{annotationItem.text}</li> + {/each} + </ul> + {#if isSome(linkAction) && isFlowAction(linkAction)} + <LinkWrapper action={linkAction}> + {linkAction.title} + </LinkWrapper> + {/if} + </dd> +{/if} + +<style> + dt { + color: var(--systemSecondary); + font: var(--body-tall); + } + + dd { + white-space: pre-line; + font: var(--body-tall); + } + + ol { + counter-reset: section; + } + + ol li { + display: table-row; + font: var(--body-tall); + } + + ol li::before { + counter-increment: section; + content: counter(section) '.'; + display: table-cell; + padding-inline-end: 6px; + } + + ol li .text { + display: table-cell; + width: 100%; + } + + ol li .subtext { + display: table-cell; + } + + .secondary-definition-list-heading { + margin-bottom: 16px; + } + + .secondary-definition-list dt { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .secondary-definition-list dd:not(:last-of-type) { + margin-bottom: 16px; + } + + dd li:not(:last-of-type) { + margin-bottom: 16px; + } + + dd :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + dd :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte new file mode 100644 index 0000000..20611d3 --- /dev/null +++ b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte @@ -0,0 +1,114 @@ +<script lang="ts"> + import type { AnnotationItem_V3 } from '@jet-app/app-store/api/models'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem_V3[]; + export let summary: string | undefined; + + const formatStyledText = (text: string): string => { + return ( + text + // Replace \n with <br> + .replace(/\n/g, '<br>') + // Replace **text** with <strong>text</strong> + .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') + ); + }; +</script> + +<ul> + {#each items as annotationItem} + <li> + {#if annotationItem.$kind === 'textEncapsulation'} + <div class="text-encapsulation"> + {annotationItem.text} + </div> + {:else if annotationItem.$kind === 'linkableText'} + <div class="styled-text"> + {@html sanitizeHtml( + formatStyledText( + annotationItem.linkableText.styledText.rawText, + ), + )} + </div> + {:else if annotationItem.$kind === 'artwork'} + {#if isSystemImageArtwork(annotationItem.artwork)} + <div class="artwork-wrapper" aria-label={summary}> + <SystemImage artwork={annotationItem.artwork} /> + </div> + {/if} + {:else if annotationItem.$kind === 'textPair'} + <div class="text-pair"> + <span>{annotationItem.leadingText}</span> + <span> + {annotationItem.trailingText} + </span> + </div> + {:else if annotationItem.$kind === 'button'} + <div class="button-wrapper"> + <LinkWrapper action={annotationItem.action}> + {annotationItem.action.title} + </LinkWrapper> + </div> + {:else if annotationItem.$kind === 'spacer'} + <div class="spacer" /> + {/if} + </li> + {/each} +</ul> + +<style> + li { + font: var(--body-tall); + } + + .styled-text :global(strong) { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .text-encapsulation { + width: fit-content; + color: var(--keyColor); + border: 1px solid; + border-radius: 3px; + padding-inline: 3px; + border-color: var(--keyColor); + margin-block: 3px; + } + + .artwork-wrapper :global(svg) { + height: 18px; + width: 18px; + margin-top: 4px; + } + + .spacer { + height: 16px; + } + + .button-wrapper :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + .button-wrapper :global(a:hover) { + text-decoration: underline; + } + + .button-wrapper :global(a) :global(.external-link-arrow) { + width: 7px; + height: 7px; + fill: var(--keyColor); + margin-top: 3px; + } + + .text-pair { + display: flex; + justify-content: space-between; + } +</style> diff --git a/src/components/jet/item/AppEventItem.svelte b/src/components/jet/item/AppEventItem.svelte new file mode 100644 index 0000000..c1e5e5a --- /dev/null +++ b/src/components/jet/item/AppEventItem.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import type { AppEvent } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import SmallLockupItem from './SmallLockupItem.svelte'; + + export let item: AppEvent; + export let isArticleContext: boolean = false; + + $: artwork = item.moduleArtwork; + $: video = item.moduleVideo; + $: hasLightArtwork = item.mediaOverlayStyle === 'light'; + $: gradientColor = hasLightArtwork + ? 'rgb(240 240 240 / 48%)' + : 'rgb(83 83 83 / 48%)'; + $: shouldShowLockup = !!item.lockup && !item.hideLockupWhenNotInstalled; +</script> + +<div + class="app-event-item" + class:with-lockup={!!item.lockup && !item.hideLockupWhenNotInstalled} +> + <span class="time-indicator"> + <AppEventDate appEvent={item} /> + </span> + + <div class="lockup-container"> + <HoverWrapper hasChin={shouldShowLockup} --display="block"> + <LinkWrapper action={item.clickAction}> + <div class="text-over-artwork"> + {#if video} + <div class="video-container"> + <Video + {video} + autoplay + loop={true} + useControls={false} + profile="app-promotion" + /> + </div> + {:else if artwork} + <div class="artwork-container"> + <Artwork + {artwork} + profile={isArticleContext + ? 'app-promotion-in-article' + : 'app-promotion'} + /> + </div> + {/if} + + <div class="gradient-container"> + <GradientOverlay + --border-radius={0} + --color={gradientColor} + --height="80%" + shouldDarken={!hasLightArtwork} + /> + </div> + + <div class="text-container" class:dark={hasLightArtwork}> + <h4>{item.kind}</h4> + + <h3>{item.title}</h3> + + <LineClamp clamp={1}> + <p>{item.detail}</p> + </LineClamp> + </div> + </div> + </LinkWrapper> + </HoverWrapper> + + {#if item.lockup && shouldShowLockup} + <div class="small-lockup-container"> + <SmallLockupItem item={item.lockup} appIconProfile="app-icon" /> + </div> + {/if} + </div> +</div> + +<style> + .app-event-item { + height: 100%; + display: grid; + grid-template-areas: + 'time-indicator' + 'lockup'; + grid-template-rows: 1rem 1fr; + gap: 4px; + } + + .time-indicator { + grid-area: time-indicator; + color: var(--keyColor); + font-weight: bold; + } + + .lockup-container { + grid-area: lockup; + } + + .text-over-artwork { + /* Allow artwork, overlay and text containers to overlap by targeting the same grid area */ + display: grid; + grid-template-areas: 'content'; + } + + .artwork-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + } + + .video-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + line-height: 0; + } + + .app-event-item.with-lockup .artwork-container, + .app-event-item.with-lockup .video-container { + border-radius: 0; + } + + .gradient-container { + grid-area: content; + z-index: 1; + position: relative; + } + + .text-container { + color: var(--systemPrimary-onDark); + padding: 12px 16px; + grid-area: content; + z-index: 2; + + /* Float text to the bottom of the lockup */ + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + .text-container.dark { + color: var(--systemPrimary-onLight); + } + + .small-lockup-container { + background: var(--systemPrimary-onDark); + border-radius: 0 0 var(--global-border-radius-large) + var(--global-border-radius-large); + box-shadow: var(--shadow-small); + padding: 12px; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuinary-onDark); + } + } + + h3 { + font: var(--title-2-tall); + } + + h4 { + font: var(--callout-emphasized-tall); + } + + p { + font: var(--callout-emphasized); + } +</style> diff --git a/src/components/jet/item/ArcadeFooterItem.svelte b/src/components/jet/item/ArcadeFooterItem.svelte new file mode 100644 index 0000000..94fe61d --- /dev/null +++ b/src/components/jet/item/ArcadeFooterItem.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import type { + ArcadeFooter, + Artwork, + ImpressionableArtwork, + } from '@jet-app/app-store/api/models'; + import { unwrapOptional as unwrap } from '@jet/environment/types/optional'; + + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: ArcadeFooter; + + $: action = unwrap(item.buttonAction); + + function isImpressionableArtwork( + item: ImpressionableArtwork | Artwork, + ): item is ImpressionableArtwork { + return 'art' in item; + } + + // Sometimes data used to render an app icon is directly on `icon` but other times, in the case + // of `ImpressionableArtwork`, it's on `icon.art`. Here we are plucking the data no matter where it is. + const icons = (item.icons ?? []).map((icon) => + isImpressionableArtwork(icon) ? icon.art : icon, + ); +</script> + +<LinkWrapper {action}> + <article> + {#if icons.length} + <AppIconRiver {icons} /> + {/if} + + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <button class="get-button gray"> + {action.title} + </button> + </div> + </article> +</LinkWrapper> + +<style> + article { + --app-icon-river-speed: 120s; + display: flex; + overflow: hidden; + flex-flow: column; + padding: 20px 0 30px; + margin-bottom: 20px; + text-align: center; + border-radius: var(--global-border-radius-large); + background: var(--footerBg); + + @media (--range-small-down) { + --app-icon-river-icon-width: 88px; + } + + @media (--range-medium-up) { + --get-button-font: var(--title-3-emphasized); + } + } + + .metadata-container { + display: flex; + align-items: center; + flex-flow: column; + gap: 20px; + } + + .logo-container { + width: 128px; + + @media (--range-small-down) { + width: 88px; + } + } +</style> diff --git a/src/components/jet/item/BannerItem.svelte b/src/components/jet/item/BannerItem.svelte new file mode 100644 index 0000000..819f621 --- /dev/null +++ b/src/components/jet/item/BannerItem.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { isFlowAction, type Banner } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: Banner; +</script> + +<div class="banner"> + <p> + {item.message} + {#if isSome(item.action) && isFlowAction(item.action)} + <LinkWrapper action={item.action}> + {item.action.title} + </LinkWrapper> + {/if} + </p> +</div> + +<style> + .banner { + background: rgba(var(--keyColor-rgb), 0.07); + padding: 8px 16px; + margin: 0 var(--bodyGutter); + text-align: center; + border-radius: var(--global-border-radius-small); + } + + .banner :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + .banner :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/jet/item/BrickItem.svelte b/src/components/jet/item/BrickItem.svelte new file mode 100644 index 0000000..a9e6319 --- /dev/null +++ b/src/components/jet/item/BrickItem.svelte @@ -0,0 +1,300 @@ +<script lang="ts"> + import type { Brick } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { + colorAsString, + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + + export let item: Brick; + export let shouldOverlayDescription: boolean = false; + + const rtlArtwork = item.artworks?.[1] || item.rtlArtwork; + const artwork = isRtl() && rtlArtwork ? rtlArtwork : item.artworks?.[0]; + const { collectionIcons } = item; + + const gradientColor: string = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : 'rgb(0 0 0 / 62%)'; + + let backgroundGradientCssVars: string | undefined = undefined; + + if (collectionIcons && collectionIcons.length > 1) { + // If there are multiple app icons, we build a string of CSS variables from the icons + // background colors to fill as many of the lockups quadrants as possible. + backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks( + collectionIcons, + { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + shouldRemoveGreys: true, + }, + ); + } +</script> + +<LinkWrapper + action={item.clickAction} + label={item.accessibilityLabel || item.clickAction?.title} +> + <div class="container"> + <HoverWrapper> + {#if artwork} + <Artwork + {artwork} + profile={shouldOverlayDescription ? 'small-brick' : 'brick'} + /> + {:else if backgroundGradientCssVars} + <div + class="background-gradient" + style={backgroundGradientCssVars} + /> + {/if} + + {#if item.title} + <GradientOverlay --color={gradientColor} /> + {/if} + + <div class="text-container"> + <div class="metadata-container"> + {#if item.caption} + <LineClamp clamp={1}> + <h4>{item.caption}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={3}> + <h3 class="title"> + {@html sanitizeHtml(item.title)} + </h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={2}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + {#if !artwork && collectionIcons} + <ul class="app-icons"> + {#each collectionIcons?.slice(0, 8) as collectionIcon} + <li class="app-icon-container"> + <AppIcon + icon={collectionIcon} + profile="brick-app-icon" + fixedWidth={false} + /> + </li> + {/each} + </ul> + {/if} + </div> + </HoverWrapper> + + {#if item.shortEditorialDescription} + <h3 + class="editorial-description" + class:overlaid={shouldOverlayDescription} + > + {item.shortEditorialDescription} + </h3> + {/if} + </div> +</LinkWrapper> + +<style> + .container { + position: relative; + container-type: inline-size; + container-name: container; + } + + .metadata-container { + width: 100%; + align-self: end; + } + + .text-container { + position: absolute; + z-index: 2; + bottom: 0; + display: flex; + align-items: flex-end; + width: 100%; + height: 100%; + padding: 20px; + color: var(--systemPrimary-onDark); + } + + .app-icon-container { + position: relative; + flex-shrink: 0; + width: 60px; + margin-inline-end: 5%; + } + + .title { + font: var(--title-1-emphasized); + text-wrap: pretty; + } + + h4 { + margin-bottom: 3px; + font: var(--callout-emphasized); + } + + p { + margin-top: 6px; + font: var(--body-emphasized); + } + + .editorial-description { + margin-top: 8px; + font: var(--title-3); + } + + .editorial-description.overlaid { + position: absolute; + z-index: 1; + bottom: 9px; + padding: 0 20px; + color: white; + font: var(--title-2-emphasized); + } + + @property --top-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 20%; + } + + @property --bottom-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 40%; + } + + @property --top-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 55%; + } + + @property --bottom-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 50%; + } + + .container .background-gradient { + width: 100%; + aspect-ratio: 16 / 9; + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) var(--top-left-stop), + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) var(--bottom-left-stop), + transparent 80% + ), + radial-gradient( + circle at 66% -175%, + var(--top-right, #000) var(--top-right-stop), + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) var(--bottom-right-stop), + transparent 100% + ); + animation: gradient-hover 8s infinite alternate-reverse; + animation-play-state: paused; + } + + @keyframes gradient-hover { + 0% { + --top-left-stop: 20%; + --bottom-left-stop: 40%; + --top-right-stop: 55%; + --bottom-right-stop: 50%; + background-size: 100% 100%; + } + + 50% { + --top-left-stop: 25%; + --bottom-left-stop: 15%; + --top-right-stop: 70%; + --bottom-right-stop: 30%; + background-size: 130% 130%; + } + + 100% { + --top-left-stop: 15%; + --bottom-left-stop: 20%; + --top-right-stop: 55%; + --bottom-right-stop: 20%; + background-size: 110% 110%; + } + } + + .container:hover .background-gradient { + animation-play-state: running; + } + + .app-icons { + display: grid; + align-self: center; + flex-direction: row; + width: 44%; + grid-template-rows: auto auto; + grid-auto-flow: column; + gap: 8px; + } + + .app-icons li:nth-child(even) { + inset-inline-start: 40px; + } + + @container container (max-width: 298px) { + .title { + font: var(--title-2-emphasized); + } + + .text-container { + padding: 16px; + } + + .editorial-description.overlaid { + bottom: 16px; + padding-inline: 16px; + } + + .app-icons { + width: 36%; + } + + .app-icon-container { + width: 50px; + } + } + + @container container (min-width: 440px) { + .app-icon-container { + width: 83px; + } + } +</style> diff --git a/src/components/jet/item/ContentModal.svelte b/src/components/jet/item/ContentModal.svelte new file mode 100644 index 0000000..486937d --- /dev/null +++ b/src/components/jet/item/ContentModal.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import ContentModal from '@amp/web-app-components/src/components/Modal/ContentModal.svelte'; + import { getI18n } from '~/stores/i18n'; + import { createEventDispatcher } from 'svelte'; + import { getJet } from '~/jet'; + + export let title: string | null; + export let subtitle: string | null; + export let text: string | null = null; + export let dialogTitleId: string | null = null; + export let targetId: string = 'close'; + + const i18n = getI18n(); + const jet = getJet(); + const dispatch = createEventDispatcher(); + + const translateFn = (key: string) => $i18n.t(key); + + const handleCloseModal = () => { + dispatch('close'); + jet.recordCustomMetricsEvent({ + eventType: 'click', + targetId, + targetType: 'button', + actionType: 'close', + }); + }; +</script> + +<ContentModal + on:close={handleCloseModal} + {translateFn} + {title} + {subtitle} + text={text || undefined} + {dialogTitleId} +> + <slot name="content" slot="content" /> +</ContentModal> diff --git a/src/components/jet/item/EditorialCardItem.svelte b/src/components/jet/item/EditorialCardItem.svelte new file mode 100644 index 0000000..2998b05 --- /dev/null +++ b/src/components/jet/item/EditorialCardItem.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { EditorialCard } from '@jet-app/app-store/api/models'; + + import Hero from '~/components/hero/Hero.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import AppLockupDetail from '~/components/hero/AppLockupDetail.svelte'; + import mediaQueries from '~/utils/media-queries'; + import { isRtl } from '~/utils/locale'; + + export let item: EditorialCard; + + $: isPortraitLayout = $mediaQueries === 'xsmall'; +</script> + +<Hero + action={item.clickAction} + artwork={item.artwork} + subtitle={item.subtitle} + title={item.title} + pinArtworkToHorizontalEnd={true} + backgroundColor={item.artwork?.backgroundColor} + isMediaDark={item.mediaOverlayStyle === 'dark'} + profileOverride={isPortraitLayout ? 'large-hero-portrait-iphone' : null} +> + <svelte:fragment slot="eyebrow"> + {#if item.appEventFormattedDates} + <AppEventDate formattedDates={item.appEventFormattedDates} /> + {:else} + {item.caption} + {/if} + </svelte:fragment> + + <svelte:fragment slot="details"> + {#if item.lockup} + <AppLockupDetail + lockup={item.lockup} + isOnDarkBackground={item.mediaOverlayStyle === 'dark'} + /> + {/if} + </svelte:fragment> +</Hero> diff --git a/src/components/jet/item/FooterLockupItem.svelte b/src/components/jet/item/FooterLockupItem.svelte new file mode 100644 index 0000000..848885d --- /dev/null +++ b/src/components/jet/item/FooterLockupItem.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Lockup; + + const i18n = getI18n(); +</script> + +<div class="footer-lockup-item"> + <LinkWrapper + action={item.clickAction} + label={`${$i18n.t('ASE.Web.AppStore.View')} ${ + item.title ? item.title : null + }`} + > + {#if item.icon} + <AppIcon icon={item.icon} profile="app-icon-small" /> + {/if} + + <div> + {#if item.heading} + <LineClamp clamp={1}> + <h4 dir="auto">{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={1}> + <h3 dir="auto">{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p dir="auto">{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + <span class="get-button blue" aria-hidden="true"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </LinkWrapper> +</div> + +<style> + .footer-lockup-item > :global(a) { + display: flex; + align-items: center; + flex-direction: column; + width: 100%; + padding: 32px; + gap: 16px; + text-align: center; + border-radius: var(--global-border-radius-small); + background-color: var(--systemQuinary); + transition: background-color 210ms ease-out; + } + + .footer-lockup-item > :global(a:hover) { + --darken-amount: 2%; + background-color: color-mix( + in srgb, + var(--systemQuinary) calc(100% - var(--darken-amount)), + black + ); + + @media (prefers-color-scheme: dark) { + --darken-amount: 10%; + } + } + + h3 { + margin-bottom: 4px; + font: var(--title-2-emphasized); + color: var(--title-color); + } + + h4 { + text-transform: uppercase; + font: var(--subhead-emphasized); + color: var(--systemSecondary); + } + + p { + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/HeroCarouselItem.svelte b/src/components/jet/item/HeroCarouselItem.svelte new file mode 100644 index 0000000..295aa8a --- /dev/null +++ b/src/components/jet/item/HeroCarouselItem.svelte @@ -0,0 +1,60 @@ +<!-- +@component +Component for rendering a `HeroCarouselItem` view-model from the App Store Client +--> +<script lang="ts"> + import type { HeroCarouselItem } from '@jet-app/app-store/api/models'; + + import Hero from '~/components/hero/Hero.svelte'; + import HeroAppLockup from '~/components/hero/AppLockupDetail.svelte'; + import mediaQueries from '~/utils/media-queries'; + + export let item: HeroCarouselItem; + + const { + titleText, + badgeText, + overlayType, + callToActionText, + lockup: overlayLockup, + clickAction, + descriptionText, + } = item.overlay || {}; + + $: artwork = item.artwork || item.video?.preview; + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: video = isXSmallViewport ? item.portraitVideo : item.video; +</script> + +<Hero + {artwork} + {video} + title={titleText} + eyebrow={badgeText} + action={clickAction} + backgroundColor={item.backgroundColor} + subtitle={descriptionText} + isMediaDark={item.isMediaDark} + collectionIcons={item.collectionIcons} +> + <svelte:fragment slot="details" let:isPortraitLayout> + {#if overlayLockup && overlayType === 'singleModule'} + <HeroAppLockup lockup={overlayLockup} /> + {:else if callToActionText && !isPortraitLayout} + <div class="button-container"> + <span class="get-button transparent"> + {callToActionText} + </span> + </div> + {/if} + </svelte:fragment> +</Hero> + +<style> + .button-container { + --get-button-font: var(--title-3-bold); + margin-top: 16px; + position: relative; + z-index: 1; + } +</style> diff --git a/src/components/jet/item/InAppPurchaseLockup.svelte b/src/components/jet/item/InAppPurchaseLockup.svelte new file mode 100644 index 0000000..29b7196 --- /dev/null +++ b/src/components/jet/item/InAppPurchaseLockup.svelte @@ -0,0 +1,74 @@ +<script lang="ts"> + import type { InAppPurchaseLockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import PlusIcon from '~/sf-symbols/plus.heavy.svg'; + + export let item: InAppPurchaseLockup; +</script> + +<article> + <div class="artwork-container"> + <PlusIcon class="plus-icon" aria-hidden="true" /> + <Artwork artwork={item.icon} profile="in-app-purchase" /> + </div> + + <div class="metadata-container"> + {#if item.title} + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + {/if} + + {#if item.productDescription} + <LineClamp clamp={1}> + <p>{item.productDescription}</p> + </LineClamp> + {/if} + + {#if item.offerDisplayProperties.titles} + <p> + {item.offerDisplayProperties.titles.discountUnownedParent || + item.offerDisplayProperties.titles.standard} + </p> + {/if} + </div> +</article> + +<style> + .artwork-container { + position: relative; + flex-shrink: 0; + width: 100%; + margin-bottom: 8px; + padding: 8%; + border-radius: var(--global-border-radius-small); + background: var(--systemQuinary); + } + + .artwork-container :global(.plus-icon) { + position: absolute; + top: 6%; + width: 9%; + inset-inline-end: 5%; + } + + .artwork-container :global(.artwork-component) { + border-radius: var(--global-border-radius-small) 43% + var(--global-border-radius-small) var(--global-border-radius-small); + } + + .metadata-container { + margin-inline-end: 16px; + } + + h3 { + font: var(--body-tall); + } + + p { + font: var(--callout-tall); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/LargeBrickItem.svelte b/src/components/jet/item/LargeBrickItem.svelte new file mode 100644 index 0000000..5ce9974 --- /dev/null +++ b/src/components/jet/item/LargeBrickItem.svelte @@ -0,0 +1,106 @@ +<script lang="ts"> + import type { Brick } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + export let item: Brick; + const artwork = + isRtl() && item.rtlArtwork ? item.rtlArtwork : item.artworks?.[0]; + const collectionIcon = item.collectionIcons?.[0]; + let artworkFallbackColor: string | null = null; + + const gradientOverlayColor: string = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : '#000'; + + if (!artwork) { + artworkFallbackColor = collectionIcon?.backgroundColor + ? colorAsString(collectionIcon.backgroundColor) + : '#000'; + } +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + {#if artwork} + <div class="artwork-container"> + <Artwork {artwork} profile="large-brick" /> + </div> + {:else} + <div + class="gradient-container" + style={`--color: ${artworkFallbackColor};`} + /> + {/if} + + <div class="text-container"> + <div class="metadata-container"> + {#if item.caption} + <LineClamp clamp={1}> + <h4>{item.caption}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={2}> + <h3>{@html sanitizeHtml(item.title)}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={2}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + </div> + </div> + + <GradientOverlay --color={gradientOverlayColor} /> + </HoverWrapper> +</LinkWrapper> + +<style> + .artwork-container { + width: 100%; + } + + .gradient-container { + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--color); + } + + .text-container { + position: absolute; + z-index: 2; + bottom: 0; + display: flex; + align-items: center; + width: 66%; + padding-inline: 20px; + padding-bottom: 20px; + color: var(--systemPrimary-onDark); + } + + h3 { + font: var(--title-1-emphasized); + text-wrap: balance; + } + + h4 { + font: var(--callout-emphasized); + margin-bottom: 3px; + } + + p { + font: var(--body-emphasized); + margin-top: 6px; + } +</style> diff --git a/src/components/jet/item/LargeHeroBreakoutItem.svelte b/src/components/jet/item/LargeHeroBreakoutItem.svelte new file mode 100644 index 0000000..d07eec8 --- /dev/null +++ b/src/components/jet/item/LargeHeroBreakoutItem.svelte @@ -0,0 +1,268 @@ +<script lang="ts"> + import { + type Artwork as JetArtworkType, + type LargeHeroBreakout, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment/types/optional'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import mediaQueries from '~/utils/media-queries'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import Video from '~/components/jet/Video.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + import { colorAsString, isRGBColor, isDark } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + export let item: LargeHeroBreakout; + + let profile: NamedProfile; + let artwork: JetArtworkType | undefined; + let gradientColor: string; + + const { + collectionIcons = [], + editorialDisplayOptions, + rtlArtwork, + video, + details: { callToActionButtonAction: action }, + } = item; + const canUseRTLArtwork = isRtl() && rtlArtwork; + const shouldShowCollectionIcons = + collectionIcons?.length > 1 && !editorialDisplayOptions.suppressLockup; + + $: artwork = + (canUseRTLArtwork ? rtlArtwork : item.artwork) || video?.preview; + $: doesArtworkHaveDarkBackground = + artwork?.backgroundColor && + isRGBColor(artwork.backgroundColor) && + isDark(artwork.backgroundColor); + $: isBackgroundDark = item.isMediaDark ?? doesArtworkHaveDarkBackground; + + $: profile = + $mediaQueries === 'xsmall' + ? 'large-hero-portrait-iphone' + : canUseRTLArtwork + ? 'large-hero-breakout-rtl' + : 'large-hero-breakout'; + + $: gradientColor = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : '#000'; +</script> + +<LinkWrapper {action}> + <HoverWrapper> + <div class="artwork-container"> + {#if video && $mediaQueries !== 'xsmall' && !canUseRTLArtwork} + <Video {video} {profile} autoplay loop useControls={false} /> + {:else if artwork} + <Artwork {artwork} {profile} /> + {/if} + </div> + + <div class="gradient" style="--color: {gradientColor};" /> + + <div + class="text-container" + class:on-dark={isBackgroundDark} + class:on-light={!isBackgroundDark} + > + {#if item.details?.badge} + <LineClamp clamp={1}> + <h4>{item.details.badge}</h4> + </LineClamp> + {/if} + + {#if item.details.title} + <LineClamp clamp={2}> + <h3>{@html sanitizeHtml(item.details.title)}</h3> + </LineClamp> + {/if} + + {#if item.details.description} + <LineClamp clamp={3}> + <p>{@html sanitizeHtml(item.details.description)}</p> + </LineClamp> + {/if} + + {#if isSome(action) && isFlowAction(action)} + <span class="link-container"> + {action.title} + <span aria-hidden="true"> + <SFSymbol name="chevron.forward" /> + </span> + </span> + {/if} + + {#if shouldShowCollectionIcons} + <ul class="collection-icons"> + {#each collectionIcons.slice(0, 6) as collectionIcon} + <li class="app-icon-container"> + <AppIcon icon={collectionIcon} /> + </li> + {/each} + </ul> + {/if} + </div> + </HoverWrapper> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .artwork-container { + width: 100%; + + @media (--range-small-up) { + aspect-ratio: 8 / 3; + } + } + + .artwork-container :global(.video-container) { + display: flex; + } + + .text-container { + position: absolute; + z-index: 2; + bottom: 0; + align-items: center; + width: 100%; + padding-inline: 20px; + padding-bottom: 20px; + text-wrap: pretty; + + @media (--range-small-up) { + width: 50%; + } + + @media (--range-large-up) { + width: 33%; + } + } + + .text-container.on-dark { + color: var(--systemPrimary-onDark); + + h4 { + color: var(--systemSecondary-onDark); + } + + :global(svg) { + fill: var(--systemPrimary-onDark); + } + } + + .text-container.on-light { + color: var(--systemPrimary-onLight); + + h4 { + color: var(--systemSecondary-onLight); + } + + :global(svg) { + fill: var(--systemPrimary-onLight); + } + } + + .link-container { + margin-top: 8px; + display: flex; + gap: 4px; + font: var(--body-emphasized); + + @media (--range-small-up) { + margin-top: 16px; + font: var(--title-2-emphasized); + } + } + + .link-container :global(svg) { + width: 8px; + height: 8px; + + @include rtl { + transform: rotate(180deg); + } + + @media (--range-small-up) { + width: 10px; + height: 10px; + } + } + + h3 { + text-wrap: balance; + font: var(--title-1-emphasized); + + @media (--range-small-up) { + font: var(--large-title-emphasized); + } + } + + h4 { + font: var(--subhead-emphasized); + + @media (--range-small-up) { + font: var(--callout-emphasized); + } + } + + p { + margin-top: 4px; + font: var(--body); + + @media (--range-small-up) { + margin-top: 8px; + font: var(--title-3); + } + } + + .collection-icons { + display: flex; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 2px solid var(--systemTertiary-onDark); + } + + .app-icon-container { + aspect-ratio: 1/1; + } + + .gradient { + --rotation: 35deg; + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + filter: saturate(1.5) brightness(0.9); + background: linear-gradient( + var(--rotation), + var(--color) 20%, + transparent 50% + ); + + // In non-XS viewports with an RTL text direction, we flip the legibility gradient to + // accomodate the right-justified text. + @include rtl { + @media (--range-small-up) { + --rotation: -35deg; + } + } + + // In XS viewports, this component is renderd in a 3/4 card layout, so we always want the + // gradient to be at 0deg rotation, as it goes from botttom to top. + @media (--range-xsmall-down) { + --rotation: 0deg; + } + } +</style> diff --git a/src/components/jet/item/LargeImageLockupItem.svelte b/src/components/jet/item/LargeImageLockupItem.svelte new file mode 100644 index 0000000..1df51c2 --- /dev/null +++ b/src/components/jet/item/LargeImageLockupItem.svelte @@ -0,0 +1,130 @@ +<script lang="ts"> + import type { ImageLockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: ImageLockup; + + const color: string = item.artwork.backgroundColor + ? colorAsString(item.artwork.backgroundColor) + : '#000'; +</script> + +<LinkWrapper action={item.lockup.clickAction}> + <HoverWrapper> + <div class="container"> + <div class="artwork-container"> + <Artwork artwork={item.artwork} profile="large-image-lockup" /> + </div> + + {#if item.lockup} + <div + class="lockup-container" + class:on-dark={item.isDark} + class:on-light={!item.isDark} + > + {#if item.lockup.icon} + <div class="app-icon-container"> + <AppIcon icon={item.lockup.icon} /> + </div> + {/if} + + <div class="metadata-container"> + {#if item.lockup.heading} + <LineClamp clamp={1}> + <p>{item.lockup.heading}</p> + </LineClamp> + {/if} + + {#if item.lockup.title} + <LineClamp clamp={2}> + <h3>{item.lockup.title}</h3> + </LineClamp> + {/if} + + {#if item.lockup.subtitle} + <LineClamp clamp={1}> + <p>{item.lockup.subtitle}</p> + </LineClamp> + {/if} + </div> + </div> + {/if} + + <div class="gradient-container"> + <GradientOverlay --color={color} --height="85%" /> + </div> + </div> + </HoverWrapper> +</LinkWrapper> + +<style> + .artwork-container { + position: absolute; + z-index: -1; + width: 100%; + } + + .container { + width: 100%; + aspect-ratio: 16/9; + container-type: inline-size; + container-name: container; + } + + .gradient-container { + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .lockup-container { + display: flex; + align-items: flex-end; + width: 100%; + height: 100%; + padding: 0 20px 20px; + } + + .lockup-container.on-dark { + color: var(--systemPrimary-onDark); + } + + .lockup-container.on-light { + color: var(--systemPrimary-onLight); + } + + @container container (max-width: 260px) { + .lockup-container { + padding: 0 10px 10px; + } + } + + .app-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 8px; + } + + h3 { + margin: 2px 0; + font: var(--title-1-emphasized); + } + + p { + font: var(--callout-emphasized); + } + + .lockup-container.on-dark p { + mix-blend-mode: plus-lighter; + } +</style> diff --git a/src/components/jet/item/LargeLockupItem.svelte b/src/components/jet/item/LargeLockupItem.svelte new file mode 100644 index 0000000..93adc6e --- /dev/null +++ b/src/components/jet/item/LargeLockupItem.svelte @@ -0,0 +1,121 @@ +<script lang="ts"> + import { + isFlowAction, + type FlowAction, + type Lockup, + } from '@jet-app/app-store/api/models'; + import type { Opt } from '@jet/environment'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Lockup; + const i18n = getI18n(); + const { clickAction } = item; + const destination: Opt<FlowAction> = isFlowAction(clickAction) + ? clickAction + : undefined; + + $: secondaryLine = item.editorialTagline || item.subtitle; +</script> + +<LinkWrapper action={destination}> + <article> + <div class="app-icon-container"> + <AppIcon + fixedWidth={false} + icon={item.icon} + profile="app-icon-large" + /> + </div> + + <div class="metadata-container"> + {#if item.heading} + <LineClamp clamp={2}> + <h4>{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={2}> + <h3>{item.title}</h3> + </LineClamp> + {/if} + + {#if !item.heading && secondaryLine} + <LineClamp clamp={1}> + <p>{secondaryLine}</p> + </LineClamp> + {/if} + + {#if item.tertiaryTitle} + <LineClamp clamp={1}> + <p class="tertiary-text">{item.tertiaryTitle}</p> + </LineClamp> + {/if} + </div> + + {#if destination} + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + {/if} + </article> +</LinkWrapper> + +<style> + article { + display: flex; + flex-direction: column; + min-height: 290px; + padding: 20px; + border-radius: var(--global-border-radius-large); + background: var(--systemPrimary-onDark); + box-shadow: var(--shadow-small); + } + + @media (prefers-color-scheme: dark) { + article { + background: var(--systemQuaternary); + } + } + + .app-icon-container { + --artwork-override-height: 100px; + --artwork-override-width: auto; + display: flex; + margin-bottom: 10px; + } + + .metadata-container { + flex-grow: 1; + } + + h3 { + margin-bottom: 3px; + font: var(--title-2-emphasized); + } + + h4 { + margin-bottom: 3px; + color: var(--systemSecondary); + font: var(--subhead-emphasized); + text-transform: uppercase; + } + + p { + margin: 3px 0; + font: var(--body); + color: var(--systemSecondary); + text-wrap: pretty; + } + + .tertiary-text { + font: var(--callout); + color: var(--systemTertiary); + } +</style> diff --git a/src/components/jet/item/LargeStoryCardItem.svelte b/src/components/jet/item/LargeStoryCardItem.svelte new file mode 100644 index 0000000..66079c2 --- /dev/null +++ b/src/components/jet/item/LargeStoryCardItem.svelte @@ -0,0 +1,38 @@ +<script lang="ts"> + import type { TodayCard } from '@jet-app/app-store/api/models'; + + import Hero from '~/components/hero/Hero.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + import mediaQueries from '~/utils/media-queries'; + import { isRtl } from '~/utils/locale'; + + export let item: TodayCard; + + let profile: NamedProfile; + + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: artwork = item.heroMedia?.artworks[0]; + $: video = isXSmallViewport ? null : item.heroMedia?.videos[0]; + $: ({ backgroundColor, clickAction, heading, inlineDescription, title } = + item); + $: profile = isXSmallViewport + ? 'large-hero-story-card-portrait' + : isRtl() + ? 'large-hero-story-card-rtl' + : 'large-hero-story-card'; +</script> + +<Hero + {artwork} + {backgroundColor} + {title} + {video} + action={clickAction} + eyebrow={heading} + subtitle={inlineDescription} + pinArtworkToVerticalMiddle={true} + pinArtworkToHorizontalEnd={true} + pinTextToVerticalStart={isRtl()} + profileOverride={profile} + isMediaDark={item.style !== 'white'} +/> diff --git a/src/components/jet/item/LinkableTextItem.svelte b/src/components/jet/item/LinkableTextItem.svelte new file mode 100644 index 0000000..a5a3e74 --- /dev/null +++ b/src/components/jet/item/LinkableTextItem.svelte @@ -0,0 +1,88 @@ +<script lang="ts"> + import type { LinkableText, Action } from '@jet-app/app-store/api/models'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: LinkableText; + + type Fragment = { + text: string; + action?: Action; + isTrailingPunctuation?: boolean; + }; + + const { + linkedSubstrings = {}, + styledText: { rawText }, + } = item; + + // `LinkableText` items contain a `rawText` string, and an object of `linkedSubstrings`, + // where the key of the object is the substring to replace in the `rawText` and whose value + // is the `Action` that the link should trigger. + // + // That means we have to render replace the keys from `linkedSubstrings` in the `rawText`. + // To do this, we build a regex to match all the strings that are supposed to be linked, + // then build an array of objects representing the fully text, with the `Action` appended + // to the fragments that need to be linked. + const fragmentsToLink = Object.keys(linkedSubstrings); + let fragments: Fragment[]; + + if (fragmentsToLink.length === 0) { + fragments = [{ text: rawText }]; + } else { + // Escapes regex-sensitive characters in the text, so characters like `.` or `+` don't act as regex operators + const cleanedFragmentsToLink = fragmentsToLink.map((fragment) => + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + ); + + const pattern = new RegExp( + `(${cleanedFragmentsToLink.join('|')})`, + 'g', + ); + + // After we split our text into an array representing the seqence of the raw text, with the + // linkable items as their own entries, we transform the array to contain include the linkable + // items actions, which we then use to determine if we want to render a `LinkWrapper` or plain-text. + fragments = rawText.split(pattern).map((fragment): Fragment => { + const action = linkedSubstrings[fragment]; + + if (action) { + return { action, text: fragment }; + } else { + const isTrailingPunctuation = /^[.,;:!?)\]}"”»']+$/.test( + fragment.trim(), + ); + + return { + isTrailingPunctuation, + text: fragment, + }; + } + }); + } +</script> + +{#each fragments as fragment} + {#if fragment.action} + <LinkWrapper + action={fragment.action} + includeExternalLinkArrowIcon={false} + > + {fragment.text} + </LinkWrapper> + {:else if fragment.isTrailingPunctuation} + <span class="trailing-punctuation">{fragment.text}</span> + {:else} + {@html sanitizeHtml(fragment.text)} + {/if} +{/each} + +<style> + span :global(a:hover) { + text-decoration: underline; + } + + .trailing-punctuation { + margin-inline-start: -0.45ch; + } +</style> diff --git a/src/components/jet/item/MediumImageLockupItem.svelte b/src/components/jet/item/MediumImageLockupItem.svelte new file mode 100644 index 0000000..8b93453 --- /dev/null +++ b/src/components/jet/item/MediumImageLockupItem.svelte @@ -0,0 +1,118 @@ +<script lang="ts"> + import type { ImageLockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: ImageLockup; + + const color: string = item.artwork.backgroundColor + ? colorAsString(item.artwork.backgroundColor) + : '#000'; +</script> + +<LinkWrapper action={item.lockup.clickAction}> + <div class="container"> + <HoverWrapper> + <div class="artwork-container"> + <Artwork artwork={item.artwork} profile="brick" /> + </div> + + {#if item.lockup} + <div + class="lockup-container" + class:on-dark={item.isDark} + class:on-light={!item.isDark} + > + {#if item.lockup.icon} + <div class="app-icon-container"> + <AppIcon icon={item.lockup.icon} /> + </div> + {/if} + + <div class="metadata-container"> + {#if item.lockup.heading} + <LineClamp clamp={1}> + <p class="eyebrow">{item.lockup.heading}</p> + </LineClamp> + {/if} + + {#if item.lockup.title} + <LineClamp clamp={2}> + <h3>{item.lockup.title}</h3> + </LineClamp> + {/if} + + {#if item.lockup.subtitle} + <LineClamp clamp={1}> + <p class="subtitle">{item.lockup.subtitle}</p> + </LineClamp> + {/if} + </div> + </div> + {/if} + + <GradientOverlay --color={color} --height="90%" /> + </HoverWrapper> + </div> +</LinkWrapper> + +<style> + .artwork-container { + width: 100%; + } + + .container { + container-type: inline-size; + container-name: container; + } + + .lockup-container { + position: absolute; + z-index: 2; + bottom: 0; + display: flex; + align-items: center; + width: 100%; + padding: 0 20px 20px; + } + + .lockup-container.on-dark { + color: var(--systemPrimary-onDark); + } + + .lockup-container.on-light { + color: var(--systemPrimary-onLight); + } + + @container container (max-width: 260px) { + .lockup-container { + padding: 0 10px 10px; + } + } + + .app-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 8px; + } + + h3 { + font: var(--title-3-emphasized); + } + + .eyebrow { + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: plus-lighter; + } + + .subtitle { + font: var(--callout-emphasized); + } +</style> diff --git a/src/components/jet/item/MediumLockupItem.svelte b/src/components/jet/item/MediumLockupItem.svelte new file mode 100644 index 0000000..be70acb --- /dev/null +++ b/src/components/jet/item/MediumLockupItem.svelte @@ -0,0 +1,96 @@ +<script lang="ts"> + import { + type FlowAction, + type Lockup, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + import type { Opt } from '@jet/environment'; + + export let item: Lockup; + + const i18n = getI18n(); + + const { clickAction } = item; + const destination: Opt<FlowAction> = isFlowAction(clickAction) + ? clickAction + : undefined; +</script> + +<LinkWrapper action={destination}> + <article> + <div class="app-icon-container"> + <AppIcon + icon={item.icon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + + <div class="metadata-container"> + {#if item.heading} + <span class="heading">{item.heading}</span> + {/if} + + {#if item.title} + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + + {#if destination} + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + {/if} + </div> + </article> +</LinkWrapper> + +<style> + article { + display: flex; + align-items: center; + } + + .app-icon-container { + flex-shrink: 0; + width: 85px; + margin-inline-end: 16px; + } + + .metadata-container { + margin-inline-end: 16px; + } + + h3 { + font: var(--title-3); + margin-bottom: 2px; + } + + p { + font: var(--callout); + color: var(--systemSecondary); + } + + .heading { + font: var(--callout-emphasized); + } + + .button-container { + margin-inline-start: auto; + margin-top: 8px; + } +</style> diff --git a/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte new file mode 100644 index 0000000..7b7807c --- /dev/null +++ b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte @@ -0,0 +1,304 @@ +<script lang="ts"> + import { + isFlowAction, + type EditorialStoryCard, + type FlowAction, + } from '@jet-app/app-store/api/models'; + import type { Opt } from '@jet/environment'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + + export let item: EditorialStoryCard; + + let { + clickAction, + collectionIcons, + title, + lockup: { title: lockupTitle, subtitle, heading: lockupHeading } = {}, + } = item; + const i18n = getI18n(); + const hasMultipleCollectionIcons = (collectionIcons?.length ?? 0) > 1; + const destination: Opt<FlowAction> = + clickAction && isFlowAction(clickAction) ? clickAction : undefined; +</script> + +<LinkWrapper action={destination}> + <article> + {#if item.artwork} + <div class="artwork-container"> + <HoverWrapper element="div"> + <Artwork + artwork={item.artwork} + profile="editorial-story-card" + /> + </HoverWrapper> + </div> + {/if} + <div class="details-container"> + <div + class="title-container" + class:on-dark={item.isMediaDark} + class:on-light={!item.isMediaDark} + > + {#if item.badge} + <h4>{item.badge.title}</h4> + {/if} + + {#if item.title} + <h3>{@html sanitizeHtml(item.title)}</h3> + {/if} + + {#if item.description} + <p>{@html sanitizeHtml(item.description)}</p> + {/if} + </div> + + {#if collectionIcons && !item.editorialDisplayOptions.suppressLockup} + <div class="lockup-container"> + <ul class:with-multiple-icons={hasMultipleCollectionIcons}> + {#each collectionIcons as collectionIcon} + <li class="app-icon-container"> + <AppIcon + icon={collectionIcon} + fixedWidth={false} + profile={hasMultipleCollectionIcons + ? 'app-icon-medium' + : 'app-icon'} + /> + </li> + {/each} + </ul> + + {#if !hasMultipleCollectionIcons} + <div class="metadata-container"> + {#if lockupHeading} + <span class="lockup-eyebrow"> + {lockupHeading} + </span> + {/if} + + <!-- + Some cards with the lockup UI don't have a `lockup` property, + so we use the title of the item as a fallback. + --> + {#if lockupTitle || title} + <LineClamp clamp={1}> + <h4 class="lockup-title"> + {lockupTitle || title} + </h4> + </LineClamp> + {/if} + + {#if subtitle} + <LineClamp clamp={1}> + <p class="lockup-subtitle">{subtitle}</p> + </LineClamp> + {/if} + </div> + + {#if destination} + <div class="button-container"> + <span class="get-button transparent"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + {/if} + {/if} + </div> + {/if} + </div> + <div + class="blur-overlay" + style:--brightness={item.isMediaDark ? 0.75 : 1.25} + /> + </article> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + article { + position: relative; + overflow: hidden; + border-radius: var(--global-border-radius-large); + box-shadow: var(--shadow-medium); + aspect-ratio: 3/4; + container-type: inline-size; + container-name: card; + } + + .artwork-container { + position: absolute; + width: 100%; + height: 100%; + } + + .details-container { + display: flex; + flex-direction: column; + justify-content: end; + height: 100%; + border-radius: var(--global-border-radius-large); + overflow: hidden; + z-index: 1; + } + + .title-container { + padding: 20px; + z-index: 2; + } + + .title-container h3 { + margin-bottom: 2px; + font: var(--title-1-emphasized); + text-wrap: pretty; + } + + .title-container h4 { + font: var(--callout-emphasized); + } + + .on-dark { + color: var(--systemPrimary-onDark); + } + + .on-light { + color: var(--systemPrimary-onLight); + } + + .title-container.on-dark h4 { + color: var(--systemSecondary-onDark); + mix-blend-mode: plus-lighter; + } + + .title-container.on-light h4 { + color: var(--systemSecondary-onLight); + } + + .title-container.on-dark p { + font: var(--body); + color: var(--systemSecondary-onDark); + } + + .title-container.on-light p { + font: var(--body); + color: var(--systemSecondary-onLight); + } + + .lockup-container { + display: flex; + align-items: center; + min-height: 80px; + padding: 10px 20px; + color: var(--systemPrimary-onDark); + background-image: linear-gradient(rgba(0, 0, 0, 0.2) 0 0); + z-index: 2; + } + + .metadata-container { + flex-grow: 1; + margin-inline-end: 16px; + } + + .lockup-title { + font: var(--title-3-emphasized); + } + + .lockup-eyebrow { + color: var(--systemSecondary-onDark); + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: plus-lighter; + } + + .lockup-subtitle { + color: var(--systemSecondary-onDark); + font: var(--callout); + mix-blend-mode: plus-lighter; + } + + .app-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 16px; + } + + article:hover .blur-overlay { + height: 52%; + backdrop-filter: blur(70px) saturate(1.5) + brightness(calc(var(--brightness) * 0.9)); + } + + .blur-overlay { + position: absolute; + z-index: 1; + top: unset; + bottom: 0; + width: 100%; + height: 50%; + border-radius: var(--global-border-radius-large); + mask-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 5%, + rgba(0, 0, 0, 1) 50% + ); + backdrop-filter: blur(50px) saturate(1.5) + brightness((var(--brightness))); + transition-property: height, backdrop-filter; + transition-duration: 210ms; + transition-timing-function: ease-out; + } + + ul.with-multiple-icons { + width: 100%; + display: grid; + gap: 12px; + + .app-icon-container { + width: 100%; + margin-inline-end: unset; + } + } + + // In the following container queries, we are specifying column counts and hiding icons past + // that number to ensure a reasonable number of icons are shown for different size cards. + @container card (max-width: 300px) { + ul.with-multiple-icons { + // Think of "4" as the number of columns to show + grid-template-columns: repeat(4, 1fr); + } + + // And "5" as the number of columns to hide past + .app-icon-container:nth-child(n + 5) { + display: none; + } + } + + @container card (min-width: 300px) and (max-width: 400px) { + ul.with-multiple-icons { + grid-template-columns: repeat(5, 1fr); + } + + .app-icon-container:nth-child(n + 6) { + display: none; + } + } + + @container card (min-width: 400px) { + ul.with-multiple-icons { + grid-template-columns: repeat(6, 1fr); + } + + .app-icon-container:nth-child(n + 7) { + display: none; + } + } +</style> diff --git a/src/components/jet/item/MediumStoryCardItem.svelte b/src/components/jet/item/MediumStoryCardItem.svelte new file mode 100644 index 0000000..80ead7d --- /dev/null +++ b/src/components/jet/item/MediumStoryCardItem.svelte @@ -0,0 +1,27 @@ +<script lang="ts" context="module"> + import type { + EditorialStoryCard, + TodayCard, + } from '@jet-app/app-store/api/models'; + + export type Item = EditorialStoryCard | TodayCard; + + function isEditorialStoryCard(item: Item): item is EditorialStoryCard { + return 'artwork' in item; + } +</script> + +<script lang="ts"> + import EditorialStoryCardItem from '~/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte'; + import SmallStoryCardWithMediaItem, { + isSmallStoryCardWithMediaItem, + } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte'; + + export let item: Item; +</script> + +{#if isEditorialStoryCard(item)} + <EditorialStoryCardItem {item} /> +{:else if isSmallStoryCardWithMediaItem(item)} + <SmallStoryCardWithMediaItem {item} /> +{/if} diff --git a/src/components/jet/item/MixedMediaLockupItem.svelte b/src/components/jet/item/MixedMediaLockupItem.svelte new file mode 100644 index 0000000..4874419 --- /dev/null +++ b/src/components/jet/item/MixedMediaLockupItem.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import type { MixedMediaLockup } from '@jet-app/app-store/api/models'; + + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: MixedMediaLockup; + + let video = item.trailers?.[0]?.videos[0]; +</script> + +<div class="mixed-media-lockup-item"> + <div class="video-wrapper"> + {#if video} + <Video {video} profile="brick" shouldSuperimposePosterImage /> + {/if} + </div> + <SmallLockupItem {item} /> +</div> + +<style> + .mixed-media-lockup-item { + display: flex; + flex-direction: column; + gap: 8px; + } + + .video-wrapper { + --mixed-media-lockup-video-aspect-ratio: 16/9; + aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio); + overflow: hidden; + border-radius: 7px; + } + + .video-wrapper :global(video) { + aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio); + object-fit: cover; + } +</style> diff --git a/src/components/jet/item/ParagraphShelfItem.svelte b/src/components/jet/item/ParagraphShelfItem.svelte new file mode 100644 index 0000000..9adf09c --- /dev/null +++ b/src/components/jet/item/ParagraphShelfItem.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { Paragraph } from '@jet-app/app-store/api/models'; + import he from 'he'; + + export let item: Paragraph; +</script> + +<p> + {@html he.decode(item.text)} +</p> + +<style> + p { + font: var(--title-2-medium); + color: var(--systemSecondary); + } + + p :global(b) { + color: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/item/PosterLockupItem.svelte b/src/components/jet/item/PosterLockupItem.svelte new file mode 100644 index 0000000..08b34e2 --- /dev/null +++ b/src/components/jet/item/PosterLockupItem.svelte @@ -0,0 +1,121 @@ +<script lang="ts"> + import type { PosterLockup } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + + export let item: PosterLockup; +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + <article> + <div class="background"> + {#if item.epicHeading} + <div class="title-container"> + <Artwork + hasTransparentBackground + artwork={item.epicHeading} + alt={item.heading} + profile="poster-title" + /> + </div> + {/if} + + {#if item.posterVideo} + <div class="video-container"> + <Video + autoplay + loop + video={item.posterVideo} + useControls={false} + profile="poster-lockup" + /> + </div> + {:else if item.posterArtwork} + <div class="artwork-container"> + <Artwork + artwork={item.posterArtwork} + profile="poster-lockup" + /> + </div> + {/if} + </div> + + <div class="content"> + <div class="logo-container"> + <AppleArcadeLogo aria-label={item.heading} /> + </div> + + <span> + {item.footerText} + {#if item.tertiaryTitle} + | {item.tertiaryTitle} + {/if} + </span> + </div> + </article> + </HoverWrapper> +</LinkWrapper> + +<style> + article { + position: relative; + width: 100%; + aspect-ratio: 16/9; + overflow: hidden; + color: var(--systemPrimary-onDark); + border-radius: var(--global-border-radius-large); + container-type: inline-size; + container-name: poster-lockup-item; + } + + .title-container { + position: absolute; + z-index: 2; + width: 100%; + } + + .background { + position: absolute; + z-index: -1; + width: 100%; + line-height: 0; + } + + .content { + display: flex; + align-items: center; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 12px 0; + font: var(--body); + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.5) 0%, + rgba(255, 255, 255, 0) 25%, + rgba(255, 255, 255, 0) 50%, + rgba(255, 255, 255, 0) 80%, + rgba(0, 0, 0, 0.4) 100% + ); + } + + .logo-container { + width: 62px; + margin-bottom: 10px; + line-height: 0; + } + + @container poster-lockup-item (min-width: 550px) { + .logo-container { + width: 78px; + } + } + + .logo-container :global(path) { + color: var(--systemPrimary-onDark); + } +</style> diff --git a/src/components/jet/item/PrivacyHeaderItem.svelte b/src/components/jet/item/PrivacyHeaderItem.svelte new file mode 100644 index 0000000..f9611a6 --- /dev/null +++ b/src/components/jet/item/PrivacyHeaderItem.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { PrivacyHeader } from '@jet-app/app-store/api/models'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let item: PrivacyHeader; +</script> + +<div> + <p> + <LinkableTextItem item={item.bodyText} /> + </p> + + {#if item.supplementaryItems.length} + <div class="supplementary-items-container"> + {#each item.supplementaryItems as supItem} + <p> + <LinkableTextItem item={supItem.bodyText} /> + </p> + {/each} + </div> + {/if} +</div> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } + + .supplementary-items-container { + display: flex; + flex-direction: column; + gap: 10px; + padding: 20px 0 0; + margin-top: 20px; + border-top: 1px solid var(--systemGray4); + } +</style> diff --git a/src/components/jet/item/PrivacyTypeItem.svelte b/src/components/jet/item/PrivacyTypeItem.svelte new file mode 100644 index 0000000..5e63966 --- /dev/null +++ b/src/components/jet/item/PrivacyTypeItem.svelte @@ -0,0 +1,193 @@ +<script lang="ts"> + import type { PrivacyType } from '@jet-app/app-store/api/models'; + + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let item: PrivacyType; + export let isDetailView: boolean = false; +</script> + +<article class:is-detail-view={isDetailView}> + {#if item.artwork && isSystemImageArtwork(item.artwork)} + <span class="icon-container" aria-hidden="true"> + <SystemImage artwork={item.artwork} /> + </span> + {/if} + + <h2>{item.title}</h2> + <p>{item.detail}</p> + + <ul class:grid={item.categories.length > 1 && !isDetailView}> + {#each item.categories as category} + <li> + {#if isSystemImageArtwork(category.artwork)} + <span aria-hidden="true" class="category-icon-container"> + <SystemImage artwork={category.artwork} /> + </span> + {/if} + {category.title} + </li> + {/each} + </ul> + + {#each item.purposes as purpose} + <section class="purpose-section"> + <h3>{purpose.title}</h3> + + {#each purpose.categories as category} + <li class="purpose-category"> + {#if isSystemImageArtwork(category.artwork)} + <span + aria-hidden="true" + class="category-icon-container" + > + <SystemImage artwork={category.artwork} /> + </span> + {/if} + + <span class="category-title">{category.title}</span> + + <ul class="privacy-data-types"> + {#each category.dataTypes as type} + <li>{type}</li> + {/each} + </ul> + </li> + {/each} + </section> + {/each} +</article> + +<style lang="scss"> + @use 'amp/stylekit/core/border-radiuses' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + display: flex; + flex-direction: column; + height: 100%; + padding: 30px; + gap: 8px; + text-align: center; + font: var(--body-tall); + border-radius: $global-border-radius-rounded-large; + background-color: var(--systemQuinary); + + &.is-detail-view { + padding: 20px 0 0; + margin-top: 20px; + text-align: left; + border-radius: 0; + background-color: transparent; + border-top: 1px solid var(--defaultLine); + } + } + + .icon-container { + width: 30px; + margin: 0 auto; + + .is-detail-view & { + display: block; + width: 32px; + margin: 0; + } + } + + .icon-container :global(svg) { + width: 100%; + fill: var(--keyColor); + } + + h2 { + font: var(--title-3-emphasized); + + .is-detail-view & { + font: var(--title-2-emphasized); + } + } + + p { + text-wrap: pretty; + font: var(--body-tall); + color: var(--systemSecondary); + } + + .grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + li { + display: flex; + align-items: center; + justify-content: center; + text-align: start; + padding: 4px 0; + gap: 8px; + + .is-detail-view & { + justify-content: start; + } + } + + .category-title { + font: var(--title-3); + } + + .grid li { + justify-content: start; + } + + .category-icon-container { + display: inline-flex; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + + .is-detail-view & { + display: flex; + align-items: center; + } + } + + .category-icon-container :global(svg) { + width: 20px; + + .is-detail-view & { + width: 20px; + height: 18px; + } + } + + .purpose-section { + border-top: 1px solid var(--defaultLine); + padding-top: 16px; + } + + .purpose-section + .purpose-section { + margin-top: 4px; + } + + .purpose-section h3 { + margin-bottom: 8px; + } + + .purpose-category { + display: grid; + grid-template-areas: + 'icon title' + '. types'; + align-items: center; + } + + .privacy-data-types { + grid-area: types; + color: var(--systemSecondary); + font: var(--body); + } +</style> diff --git a/src/components/jet/item/ProductBadgeItem.svelte b/src/components/jet/item/ProductBadgeItem.svelte new file mode 100644 index 0000000..fa32e6f --- /dev/null +++ b/src/components/jet/item/ProductBadgeItem.svelte @@ -0,0 +1,188 @@ +<script lang="ts"> + import type { Badge } from '@jet-app/app-store/api/models'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import StarRating from '~/components/StarRating.svelte'; + import GameController from '~/sf-symbols/gamecontroller.fill.svg'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import ContentRatingBadge, { + isContentRatingBadge, + } from '../badge/ContentRatingBadge.svelte'; + + export let item: Badge; + + const { artwork, content, type } = item; + + $: isParagraph = type === 'paragraph'; + $: isRating = type === 'rating'; + $: isEditorsChoice = type === 'editorsChoice'; + $: isController = type === 'controller'; + $: hasImageArtwork = artwork && !isSystemImageArtwork(artwork); +</script> + +<LinkWrapper withoutLabel action={item.clickAction}> + <div class="badge-container"> + <div class="badge"> + <div class="badge-dt" role="term"> + <LineClamp clamp={1}> + {item.heading} + </LineClamp> + </div> + + <div class="badge-dd" role="definition"> + {#if isContentRatingBadge(item)} + <ContentRatingBadge badge={item} /> + {:else if isParagraph} + <span class="text-container">{content.paragraphText}</span> + {:else if isRating && !content.rating} + <span class="text-container"> + {content.ratingFormatted} + </span> + {:else if isEditorsChoice} + <span class="editors-choice"> + <SFSymbol name="laurel.leading" ariaHidden={true} /> + + <span> + <LineClamp clamp={2}> + {item.accessibilityTitle} + </LineClamp> + </span> + + <SFSymbol name="laurel.trailing" ariaHidden={true} /> + </span> + {:else if artwork && hasImageArtwork} + <div class="artwork-container" aria-hidden="true"> + <Artwork + {artwork} + profile="app-icon" + hasTransparentBackground + /> + </div> + {:else if artwork && isSystemImageArtwork(artwork)} + <div class="icon-container color" aria-hidden="true"> + <SystemImage {artwork} /> + </div> + {:else if isController} + <div class="icon-container" aria-hidden="true"> + <GameController /> + </div> + {/if} + + {#if isRating && content.rating} + <span class="text-container" aria-hidden="true"> + {content.ratingFormatted} + </span> + <StarRating rating={content.rating} /> + {:else} + <LineClamp clamp={1}>{item.caption}</LineClamp> + {/if} + </div> + </div> + </div> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .badge-container { + --color: var(--systemGray3-onDark); + --accent-color: var(--systemSecondary); + display: flex; + align-items: center; + flex-direction: column; + transition: filter 210ms ease-in; + + @media (prefers-color-scheme: dark) { + --color: var(--systemGray3-onLight); + } + } + + .badge { + text-align: center; + } + + .artwork-container { + height: 25px; + aspect-ratio: 1/1; + margin: 4px 0 2px; + opacity: 0.7; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + } + + .icon-container { + display: flex; + width: 35px; + height: 25px; + margin: 4px 0 2px; + line-height: 0; + } + + .icon-container.color { + filter: brightness(1); + } + + .badge-dt { + text-transform: uppercase; + font: var(--subhead-emphasized); + color: var(--accent-color); + margin-bottom: 4px; + } + + .text-container { + height: 25px; + margin: 4px 0 2px; + font: var(--title-1-emphasized); + color: var(--color); + } + + .editors-choice { + display: flex; + align-items: center; + justify-content: center; + height: 30px; + + :global(svg) { + height: 20px; + flex-shrink: 0; + + @include rtl { + transform: rotateY(180deg); + } + } + + @media (--range-medium-only) { + gap: 2px; + } + + :global(svg path:not([fill='none'])) { + fill: var(--color); + } + } + + .editors-choice span { + width: 50%; + font: var(--subhead-medium); + + @media (--range-medium-only) { + width: 55%; + } + } + + .badge-dd { + --fill-color: var(--color); + display: flex; + align-items: center; + flex-direction: column; + font: var(--subhead-tall); + color: var(--color); + gap: 4px; + } +</style> diff --git a/src/components/jet/item/ProductCapabilityItem.svelte b/src/components/jet/item/ProductCapabilityItem.svelte new file mode 100644 index 0000000..21e97cd --- /dev/null +++ b/src/components/jet/item/ProductCapabilityItem.svelte @@ -0,0 +1,84 @@ +<script lang="ts"> + import { + type ProductCapability, + type ProductCapabilityType, + } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + type CapabilityIcons = Record<ProductCapabilityType, string | undefined>; + + const capabilityIcons: CapabilityIcons = { + gameCenter: '/assets/images/supports/supports-GameCenter@2x.png', + siri: '/assets/images/supports/supports-Siri@2x.png', + wallet: '/assets/images/supports/supports-Wallet@2x.png', + controllers: '/assets/images/supports/supports-GameController@2x.png', + familySharing: '/assets/images/supports/supports-FamilySharing@2x.png', + sharePlay: '/assets/images/supports/supports-Shareplay@2x.png', + spatialControllers: + '/assets/images/supports/supports-SpatialController@2x.png', + safariExtensions: '/assets/images/supports/supports-Safari@2x.png', + }; + + export let item: ProductCapability; +</script> + +<article> + <div class="capability-icon-container"> + <img + src={capabilityIcons[item.type]} + class="capability-icon" + alt="" + aria-hidden="true" + /> + </div> + + <div class="metadata-container"> + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + + <p> + <LinkableTextItem item={item.caption} /> + </p> + </div> +</article> + +<style> + article { + display: flex; + align-items: center; + } + + .capability-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 16px; + } + + .capability-icon { + margin-top: 2px; + min-width: 46px; + height: 46px; + } + + .metadata-container { + margin-inline-end: 16px; + } + + .metadata-container :global(a) { + color: var(--keyColor); + } + + h3 { + color: var(--systemPrimary); + font-size: 1em; + margin-bottom: 1px; + } + + p { + color: var(--systemSecondary); + font: var(--body-tall); + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte new file mode 100644 index 0000000..516ed32 --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import type { ProductMediaItem } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; +</script> + +{#if item.screenshot} + <article> + <Artwork artwork={item.screenshot} profile="screenshot-mac" /> + </article> +{:else if item.video} + <article> + <Video autoplay video={item.video} profile="screenshot-mac" /> + </article> +{/if} + +<style> + article { + overflow: hidden; + } + + article :global(.video) { + aspect-ratio: 16/10; + } + + article :global(video) { + object-fit: cover; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte new file mode 100644 index 0000000..6b9886c --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte @@ -0,0 +1,89 @@ +<script lang="ts"> + import type { + ProductMediaItem, + MediaType, + } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; + export let hasPortraitMedia: boolean; + export let mediaType: MediaType | undefined; +</script> + +{#if item.screenshot || item.video} + <article> + <div + class="artwork-container" + class:ipad-pro-2018={mediaType === 'ipadPro_2018'} + class:ipad-11={mediaType === 'ipad_11'} + class:portrait={hasPortraitMedia} + > + {#if item.screenshot} + <Artwork + artwork={item.screenshot} + profile={hasPortraitMedia + ? 'screenshot-pad-portrait' + : 'screenshot-pad'} + /> + {:else if item.video} + <Video + autoplay + video={item.video} + profile={hasPortraitMedia + ? 'screenshot-pad-portrait' + : 'screenshot-pad'} + /> + {/if} + </div> + </article> +{/if} + +<style> + .artwork-container, + .artwork-container :global(video) { + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + border-radius: 1.3% / 1.9%; + overflow: hidden; + + /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */ + transform: translateZ(0); + } + + .artwork-container.portrait { + aspect-ratio: 3/4; + background: var(--systemQuaternary); + } + + .artwork-container.portrait, + .artwork-container.portrait :global(video) { + border-radius: 1.9% / 1.3%; + } + + .ipad-pro-2018, + .ipad-pro-2018 :global(video) { + mask-image: url('/assets/images/masks/ipad-pro-2018-mask-landscape.svg'); + } + + .ipad-pro-2018.portrait, + .ipad-pro-2018.portrait :global(video) { + mask-image: url('/assets/images/masks/ipad-pro-2018-mask.svg'); + } + + .ipad-11, + .ipad-11 :global(video) { + mask-image: url('/assets/images/masks/ipad-11-mask-landscape.svg'); + } + + .ipad-11.portrait, + .ipad-11.portrait :global(video) { + mask-image: url('/assets/images/masks/ipad-11-mask.svg'); + } + + .artwork-container :global(video):fullscreen { + mask-image: none; + border-radius: 0; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte new file mode 100644 index 0000000..255b663 --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte @@ -0,0 +1,142 @@ +<script lang="ts"> + import type { + ProductMediaItem, + MediaType, + } from '@jet-app/app-store/api/models'; + import { getAspectRatio } from '@amp/web-app-components/src/components/Artwork/utils/artProfile'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + + export let item: ProductMediaItem; + export let hasPortraitMedia: boolean; + export let mediaType: MediaType | undefined; + + const getArtworkProfile = ( + mediaType: MediaType | undefined, + hasPortraitMedia: boolean, + ): NamedProfile => { + const suffix = hasPortraitMedia ? '_portrait' : ''; + + // Map specific media types to their artwork profile names + const mediaTypeProfiles: Record<string, string> = { + iphone_6_5: 'screenshot-iphone_6_5', + iphone_5_8: 'screenshot-iphone_5_8', + iphone_d74: 'screenshot-iphone_d74', + }; + + const baseProfile = + mediaType && mediaTypeProfiles[mediaType] + ? mediaTypeProfiles[mediaType] + : 'screenshot-phone'; + + return `${baseProfile}${suffix}` as NamedProfile; + }; + + $: isLandscapeScreenshot = + item.screenshot && item.screenshot.width > item.screenshot.height; + $: profile = getArtworkProfile(mediaType, !isLandscapeScreenshot); + $: restOfShelfAspectRatio = getAspectRatio( + getArtworkProfile(mediaType, hasPortraitMedia), + ); +</script> + +{#if item.screenshot || item.video} + <article + class:with-rotated-artwork={isLandscapeScreenshot && hasPortraitMedia} + style:--aspect-ratio={`${restOfShelfAspectRatio}`} + > + <div + class="artwork-container" + class:iphone-6-5={mediaType === 'iphone_6_5'} + class:iphone-5-8={mediaType === 'iphone_5_8'} + class:iphone-d74={mediaType === 'iphone_d74'} + class:portrait={hasPortraitMedia} + > + {#if item.screenshot} + <Artwork + {profile} + artwork={item.screenshot} + disableAutoCenter={true} + withoutBorder={true} + /> + {:else if item.video} + <Video autoplay video={item.video} {profile} /> + {/if} + </div> + </article> +{/if} + +<style> + article.with-rotated-artwork { + position: relative; + aspect-ratio: var(--aspect-ratio); + } + + /* + * For iPhone screenshots that are landscape, but in a shelf/list with portrait screenshots, + * as denoted by `hasPortraitMedia`, we rotate the landscape screenshot to be in the portrait + * orientation, and scale it up so it fills the container. + */ + article.with-rotated-artwork .artwork-container { + position: absolute; + top: 50%; + left: 50%; + height: auto; + width: calc((1 / var(--aspect-ratio)) * 100%); + transform: translate(-50%, -50%) rotate(-90deg); + transform-origin: center; + } + + .artwork-container, + .artwork-container :global(video) { + mask-position: center; + mask-repeat: no-repeat; + mask-size: 100%; + border-radius: 20px; + overflow: hidden; + + /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */ + transform: translateZ(0); + } + + .iphone-5-8, + .iphone-5-8 :global(video) { + /* need to confirm with design for correct value */ + border-radius: 23px; + mask-image: url('/assets/images/masks/iphone-5-8-mask-landscape.svg'); + } + + .iphone-5-8.portrait, + .iphone-5-8.portrait :global(video) { + mask-image: url('/assets/images/masks/iphone-5-8-mask.svg'); + } + + .iphone-6-5, + .iphone-6-5 :global(video) { + /* need to confirm with design for correct value */ + border-radius: 21px; + mask-image: url('/assets/images/masks/iphone-6-5-mask-landscape.svg'); + } + + .iphone-6-5.portrait, + .iphone-6-5.portrait :global(video) { + mask-image: url('/assets/images/masks/iphone-6-5-mask.svg'); + } + + .iphone-d74, + .iphone-d74 :global(video) { + border-radius: 5.7% / 12.8%; + } + + .iphone-d74.portrait, + .iphone-d74.portrait :global(video) { + border-radius: 12.8% / 5.7%; + } + + .artwork-container :global(video):fullscreen { + mask-image: none; + border-radius: 0; + object-fit: contain; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte new file mode 100644 index 0000000..7f7fd7a --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import type { ProductMediaItem } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; +</script> + +{#if item.screenshot || item.video} + <article> + <div class="artwork-container"> + {#if item.screenshot} + <Artwork artwork={item.screenshot} profile="screenshot-tv" /> + {:else if item.video} + <Video autoplay video={item.video} profile="screenshot-tv" /> + {/if} + </div> + </article> +{/if} + +<style> + .artwork-container, + .artwork-container :global(video) { + border-radius: 1.3% / 1.9%; + overflow: hidden; + + /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */ + transform: translateZ(0); + } + + .artwork-container :global(video):fullscreen { + border-radius: 0; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte new file mode 100644 index 0000000..e893dd6 --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte @@ -0,0 +1,38 @@ +<script lang="ts"> + import type { ProductMediaItem } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; +</script> + +{#if item.screenshot || item.video} + <article> + <div class="artwork-container"> + {#if item.screenshot} + <Artwork + artwork={item.screenshot} + profile="screenshot-vision" + /> + {:else if item.video} + <Video + autoplay + video={item.video} + profile="screenshot-vision" + /> + {/if} + </div> + </article> +{/if} + +<style> + .artwork-container, + .artwork-container :global(video) { + border-radius: 20px; + overflow: hidden; + } + + .artwork-container :global(video):fullscreen { + border-radius: 0; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte new file mode 100644 index 0000000..0a4b50e --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import type { + ProductMediaItem, + MediaType, + } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + + export let item: ProductMediaItem; + export let mediaType: MediaType | undefined; +</script> + +{#if item.screenshot} + <article> + <div + class="artwork-container" + class:apple-watch-2018={mediaType === 'appleWatch_2018'} + class:apple-watch-2021={mediaType === 'appleWatch_2021'} + class:apple-watch-2022={mediaType === 'appleWatch_2022'} + class:apple-watch-2024={mediaType === 'appleWatch_2024'} + > + <Artwork artwork={item.screenshot} profile="screenshot-watch" /> + </div> + </article> +{/if} + +<style> + .artwork-container { + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + border-radius: 12px; + overflow: hidden; + } + + .apple-watch-2018 { + mask-image: url('/assets/images/masks/apple-watch-2018-mask.svg'); + } + + .apple-watch-2021 { + mask-image: url('/assets/images/masks/apple-watch-2021-mask.svg'); + } + + .apple-watch-2022 { + mask-image: url('/assets/images/masks/apple-watch-2022-mask.svg'); + } + + .apple-watch-2024 { + mask-image: url('/assets/images/masks/apple-watch-2024-mask.svg'); + } +</style> diff --git a/src/components/jet/item/ProductPageLinkItem.svelte b/src/components/jet/item/ProductPageLinkItem.svelte new file mode 100644 index 0000000..be4bb16 --- /dev/null +++ b/src/components/jet/item/ProductPageLinkItem.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import { + type ProductPageLink, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import { isExternalUrlAction } from '~/jet/models/'; + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import ExternalURLAction from '~/components/jet/action/ExternalUrlAction.svelte'; + + export let item: ProductPageLink; + + const clickAction = item.clickAction; + + $: canRenderContainer = + isFlowAction(clickAction) || isExternalUrlAction(clickAction); +</script> + +{#if canRenderContainer} + <div class="product-link-container"> + {#if isFlowAction(clickAction)} + <FlowAction destination={clickAction}> + {item.text} + </FlowAction> + {:else if isExternalUrlAction(clickAction)} + <ExternalURLAction destination={clickAction}> + {item.text} + </ExternalURLAction> + {/if} + </div> +{/if} + +<style> + .product-link-container { + @media (--range-xsmall-down) { + padding: 10px 0; + } + } + + .product-link-container :global(a) { + display: inline-flex; + align-items: center; + color: var(--keyColor); + text-decoration: none; + gap: 6px; + + &:hover { + text-decoration: underline; + } + + @media (--range-xsmall-down) { + font-size: 18px; + gap: 8px; + } + } + + .product-link-container :global(a) :global(.external-link-arrow) { + width: 7px; + height: 7px; + fill: var(--keyColor); + margin-top: 3px; + + @media (--range-xsmall-down) { + width: 10px; + height: 10px; + margin-top: 2px; + } + } +</style> diff --git a/src/components/jet/item/ProductRatingsItem.svelte b/src/components/jet/item/ProductRatingsItem.svelte new file mode 100644 index 0000000..0345993 --- /dev/null +++ b/src/components/jet/item/ProductRatingsItem.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { Ratings } from '@jet-app/app-store/api/models'; + + import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte'; + import { getJet } from '~/jet/svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Ratings; + + const i18n = getI18n(); + const jet = getJet(); + const numberOfRatings = jet.localization.formattedCount( + item.totalNumberOfRatings, + ); +</script> + +<article> + {#if item.totalNumberOfRatings === 0} + {item.status} + {:else} + <RatingComponent + averageRating={jet.localization.decimal(item.ratingAverage, 1)} + ratingCount={item.totalNumberOfRatings} + ratingCountText={$i18n.t('ASE.Web.AppStore.Ratings.CountText', { + numberOfRatings: numberOfRatings, + })} + totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')} + ratingCountsList={item.ratingCounts} + /> + {/if} +</article> + +<style> + article { + --ratingBarColor: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte new file mode 100644 index 0000000..2bb6a06 --- /dev/null +++ b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte @@ -0,0 +1,99 @@ +<script lang="ts" context="module"> + import type { + EditorsChoice, + ProductReview, + } from '@jet-app/app-store/api/models'; + + interface EditorsChoiceReview extends ProductReview { + sourceType: 'editorsChoice'; + review: EditorsChoice; + } + + export function isEditorsChoiceReviewItem( + productReview: ProductReview, + ): productReview is EditorsChoiceReview { + return productReview.sourceType === 'editorsChoice'; + } +</script> + +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte'; + import EditorsChoiceBadge from '~/components/EditorsChoiceBadge.svelte'; + import { getJet } from '~/jet'; + import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics'; + + export let item: EditorsChoiceReview; + export let isDetailView: boolean = false; + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const translateFn = (key: string) => $i18n.t(key); + const i18n = getI18n(); + const jet = getJet(); + + const handleCloseModal = () => modalComponent?.close(); + const handleOpenModal = () => { + modalComponent?.showModal(); + jet.recordCustomMetricsEvent({ + eventType: 'dialog', + dialogId: 'more', + targetId: CUSTOMER_REVIEW_MODAL_ID, + dialogType: 'button', + }); + }; +</script> + +<article class:is-detail-view={isDetailView}> + <EditorsChoiceBadge + --font={isDetailView + ? 'var(--large-title-emphasized)' + : 'var(--title-1-emphasized)'} + /> + + {#if isDetailView} + <p>{item.review.notes}</p> + {:else} + <Truncate + {translateFn} + lines={4} + text={item.review.notes} + title={$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')} + isPortalModal={true} + on:openModal={handleOpenModal} + /> + {/if} +</article> + +{#if !isDetailView} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleCloseModal} + title={null} + subtitle={null} + targetId={CUSTOMER_REVIEW_MODAL_ID} + > + <svelte:fragment slot="content"> + <svelte:self {item} isDetailView={true} /> + </svelte:fragment> + </ContentModal> + </Modal> +{/if} + +<style> + article:not(.is-detail-view) { + height: 186px; + padding: 20px; + background-color: var(--systemQuinary); + border-radius: var(--global-border-radius-xlarge); + } + + article :global(.more) { + --moreTextColorOverride: var(--keyColor); + --moreFontOverride: var(--body); + text-transform: lowercase; + } +</style> diff --git a/src/components/jet/item/ProductReview/UserReviewItem.svelte b/src/components/jet/item/ProductReview/UserReviewItem.svelte new file mode 100644 index 0000000..472dd1f --- /dev/null +++ b/src/components/jet/item/ProductReview/UserReviewItem.svelte @@ -0,0 +1,25 @@ +<script lang="ts" context="module"> + import { + type Review as ReviewModel, + ProductReview, + } from '@jet-app/app-store/api/models'; + + interface UserReview extends ProductReview { + sourceType: 'user'; + review: ReviewModel; + } + + export function isUserReviewItem( + productReview: ProductReview, + ): productReview is UserReview { + return productReview.sourceType === 'user'; + } +</script> + +<script lang="ts"> + import ReviewItem from '~/components/jet/item/ReviewItem.svelte'; + + export let item: UserReview; +</script> + +<ReviewItem item={item.review} /> diff --git a/src/components/jet/item/ReviewItem.svelte b/src/components/jet/item/ReviewItem.svelte new file mode 100644 index 0000000..7f406c8 --- /dev/null +++ b/src/components/jet/item/ReviewItem.svelte @@ -0,0 +1,237 @@ +<script lang="ts"> + import type { Review as ReviewModel } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte'; + import StarRating from '~/components/StarRating.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import { getI18n } from '~/stores/i18n'; + import { getJet } from '~/jet/svelte'; + import { + escapeHtml, + stripUnicodeWhitespace, + } from '~/utils/string-formatting'; + import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics'; + + export let item: ReviewModel; + export let isDetailView: boolean = false; + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const jet = getJet(); + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + + const handleCloseModal = () => modalComponent?.close(); + const handleOpenModal = () => { + modalComponent?.showModal(); + jet.recordCustomMetricsEvent({ + eventType: 'dialog', + dialogId: 'more', + targetId: CUSTOMER_REVIEW_MODAL_ID, + dialogType: 'button', + }); + }; + + $: ({ id, reviewerName, rating, contents, title, date, response } = item); + $: dateForDisplay = jet.localization.timeAgo(new Date(date)); + $: dateForAttribute = new Date(date).toISOString(); + $: titleId = `review-${id}-title`; + $: maximumLinesForReview = response ? 3 : 5; + $: responseDateForDisplay = + response && jet.localization.timeAgo(new Date(response.date)); + $: responseDateForAttribute = + response && new Date(response.date).toISOString(); + $: reviewContents = stripUnicodeWhitespace(escapeHtml(contents)); + $: responseContents = + response && stripUnicodeWhitespace(escapeHtml(response.contents)); +</script> + +<article class:is-detail-view={isDetailView} aria-labelledby={titleId}> + <div class="header"> + <div class="title-and-rating-container"> + {#if !isDetailView} + <h3 id={titleId} class="title"> + <LineClamp clamp={1}> + {title} + </LineClamp> + </h3> + {/if} + + <StarRating + {rating} + --fill-color="var(--systemOrange)" + --star-size={isDetailView ? '24px' : '12px'} + /> + </div> + + <div class="review-header"> + <time class="date" datetime={dateForAttribute}> + {dateForDisplay} + </time> + + <LineClamp clamp={1}> + <p class="author"> + {reviewerName} + </p> + </LineClamp> + </div> + </div> + + {#if isDetailView} + <p> + {@html sanitizeHtml(reviewContents, { + allowedTags: [''], + keepChildrenWhenRemovingParent: true, + })} + + {#if response} + <div class="developer-response-container"> + <div class="developer-response-header"> + <span class="developer-response-heading"> + {$i18n.t( + 'ASE.Web.AppStore.Review.DeveloperResponse', + )} + </span> + + <time class="date" datetime={responseDateForAttribute}> + {responseDateForDisplay} + </time> + </div> + + {@html sanitizeHtml(responseContents, { + allowedTags: [''], + keepChildrenWhenRemovingParent: true, + })} + </div> + {/if} + </p> + {:else} + <div class="content"> + <Truncate + on:openModal={handleOpenModal} + {title} + lines={maximumLinesForReview} + {translateFn} + text={reviewContents} + isPortalModal={true} + /> + + {#if item.response} + <div class="developer-response-container"> + <span class="developer-response-heading"> + {$i18n.t('ASE.Web.AppStore.Review.DeveloperResponse')} + </span> + <Truncate + on:openModal={handleOpenModal} + {title} + {translateFn} + lines={1} + text={responseContents} + isPortalModal={true} + /> + </div> + {/if} + </div> + {/if} +</article> + +{#if !isDetailView} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleCloseModal} + {title} + subtitle={null} + targetId={CUSTOMER_REVIEW_MODAL_ID} + > + <svelte:fragment slot="content"> + <svelte:self {item} isDetailView={true} /> + </svelte:fragment> + </ContentModal> + </Modal> +{/if} + +<style lang="scss"> + article:not(.is-detail-view) { + height: 186px; + padding: 20px 16px; + background-color: var(--systemQuinary); + border-radius: var(--global-border-radius-xlarge); + + @media (--small) { + padding: 20px; + } + } + + .header { + display: flex; + gap: 8px; + margin-bottom: 18px; + align-items: center; + justify-content: space-between; + + .is-detail-view & { + margin-bottom: 0; + } + } + + .title-and-rating-container { + .is-detail-view & { + display: flex; + } + } + + .title { + color: var(--systemPrimary); + font: var(--body-emphasized); + margin-bottom: 4px; + } + + .date, + .author { + color: var(--systemSecondary); + font: var(--callout); + word-break: normal; + } + + .content { + position: relative; + word-wrap: break-word; /* Break to fit the review block, even when people leave a review with long text without spaces */ + text-align: start; + font: var(--body); + } + + .review-header { + text-align: end; + } + + .developer-response-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + margin-top: 20px; + } + + .developer-response-heading { + font: var(--body-emphasized); + + .is-detail-view & { + display: block; + font: var(--title-3-emphasized); + } + } + + .developer-response-container { + margin-top: 10px; + } + + article :global(.more) { + --moreTextColorOverride: var(--keyColor); + --moreFontOverride: var(--body); + text-transform: lowercase; + } +</style> diff --git a/src/components/jet/item/SearchLinkItem.svelte b/src/components/jet/item/SearchLinkItem.svelte new file mode 100644 index 0000000..cd60512 --- /dev/null +++ b/src/components/jet/item/SearchLinkItem.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { + isFlowAction, + type SearchLink, + } from '@jet-app/app-store/api/models'; + + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import MagnifyingGlass from '~/sf-symbols/magnifyingglass.svg'; + + export let item: SearchLink; +</script> + +{#if isFlowAction(item.clickAction)} + <div class="link-container"> + <FlowAction destination={item.clickAction}> + <MagnifyingGlass class="icon" /> + {item.title} + </FlowAction> + </div> +{/if} + +<style> + .link-container { + display: contents; + } + + .link-container :global(a) { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px; + font: var(--title-2); + border-radius: var(--global-border-radius-large); + background: var(--systemQuinary); + } + + .link-container :global(a:hover) { + text-decoration: none; + } + + .link-container :global(a) :global(.icon) { + overflow: visible; + width: 20px; + fill: currentColor; + } +</style> diff --git a/src/components/jet/item/SearchResult/AppSearchResultItem.svelte b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte new file mode 100644 index 0000000..c36e5fc --- /dev/null +++ b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte @@ -0,0 +1,392 @@ +<script lang="ts" context="module"> + import type { + AppSearchResult, + AppEventSearchResult, + SearchResult, + Trailers, + Screenshots, + FlowAction, + Artwork as ArtworkType, + Video as VideoType, + } from '@jet-app/app-store/api/models'; + + export function isAppSearchResult( + result: SearchResult, + ): result is AppSearchResult { + return result.resultType === 'content'; + } + + export function isAppEventSearchResult( + result: SearchResult, + ): result is AppEventSearchResult { + return result.resultType === 'appEvent'; + } +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import type { + ImageSizes, + Profile, + } from '@amp/web-app-components/src/components/Artwork/types'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + + import type { NamedProfile } from '~/config/components/artwork'; + import { getI18n } from '~/stores/i18n'; + import AppIcon, { + doesAppIconNeedBorder, + } from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import StarRating from '~/components/StarRating.svelte'; + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { isNamedColor } from '~/utils/color'; + import mediaQueries from '~/utils/media-queries'; + import VideoPlayer from '~/components/VideoPlayer.svelte'; + + const i18n = getI18n(); + + export let item: AppSearchResult; + + $: ({ + clickAction, + heading, + isEditorsChoice, + rating, + ratingCount, + screenshots, + subtitle, + title, + trailers, + } = item.lockup); + let video: VideoType | undefined; + let media: (ArtworkType | VideoType)[]; + let mediaAspectRatio: number; + let numberOfMediaToShow: number; + let profile: NamedProfile | Profile; + let mediaSizes: ImageSizes; + let videoPlayerInstance: InstanceType<typeof VideoPlayer> | null = null; + let shouldAutoplayVideo: boolean = false; + + const currentPlatform = + (item.lockup.clickAction as FlowAction).destination?.platform ?? ''; + + function isForCurrentPlatform(media: Trailers | Screenshots) { + return media.mediaPlatform.appPlatform === currentPlatform; + } + + $: { + const selectedTrailer = + trailers?.find(isForCurrentPlatform) ?? trailers?.[0]; + video = selectedTrailer?.videos?.[0]; + + const selectedScreenshot = + screenshots.find(isForCurrentPlatform) ?? screenshots[0]; + + const firstMedia = video + ? video.preview + : selectedScreenshot.artwork[0]; + const hasPortraitMedia = firstMedia.width < firstMedia.height; + const isMobile = $mediaQueries === 'xsmall' && $sidebarIsHidden; + + mediaAspectRatio = firstMedia.width / firstMedia.height; + + if (!hasPortraitMedia) { + numberOfMediaToShow = 1; + mediaSizes = isMobile ? [308] : [648, 417, 417, 656]; + } else if (currentPlatform !== 'iphone') { + numberOfMediaToShow = 2; + mediaSizes = isMobile ? [150] : [238, 203, 203, 320]; + } else { + numberOfMediaToShow = 3; + mediaSizes = isMobile ? [98] : [156, 133, 133, 210]; + } + + profile = getNaturalProfile(firstMedia, mediaSizes); + media = [video, ...selectedScreenshot.artwork] + .filter(Boolean) + .slice(0, numberOfMediaToShow) as (ArtworkType | VideoType)[]; + } + + function handleMouseEnter() { + videoPlayerInstance?.play(); + } + + function handleMouseLeave() { + videoPlayerInstance?.pause(); + } + + onMount(() => { + shouldAutoplayVideo = navigator.maxTouchPoints > 0; + }); +</script> + +<LinkWrapper + action={clickAction} + label={`${$i18n.t('ASE.Web.AppStore.View')} ${clickAction.title}`} +> + <article on:mouseenter={handleMouseEnter} on:mouseleave={handleMouseLeave}> + <div class="top-container"> + {#if item.lockup.icon} + <div class="app-icon-container"> + <AppIcon + icon={item.lockup.icon} + profile="app-icon" + withBorder={doesAppIconNeedBorder(item.lockup.icon)} + /> + </div> + {/if} + + <div class="metadata-container"> + {#if heading} + <LineClamp clamp={1}> + <h4>{heading}</h4> + </LineClamp> + {/if} + + <LineClamp clamp={1}> + <h3>{title}</h3> + </LineClamp> + + <LineClamp clamp={1}> + <p>{subtitle}</p> + </LineClamp> + + {#if isEditorsChoice} + <div class="editors-choice-badge-container"> + <SFSymbol name="laurel.leading" ariaHidden={true} /> + + {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')} + + <SFSymbol name="laurel.trailing" ariaHidden={true} /> + </div> + {:else if ratingCount} + <span class="rating-container"> + <StarRating + {rating} + --fill-color="var(--systemGray2-onDark_IC)" + /> + {ratingCount} + </span> + {/if} + </div> + + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + </div> + + <div + class="artwork-container {currentPlatform}" + style:--media-aspect-ratio={mediaAspectRatio} + > + {#each media as mediaItem} + {#if 'videoUrl' in mediaItem} + <div class="video-wrapper"> + <Video + {profile} + loop + video={mediaItem} + autoplay={shouldAutoplayVideo} + useControls={false} + autoplayVisibilityThreshold={0.75} + bind:videoPlayerRef={videoPlayerInstance} + /> + </div> + {:else} + <Artwork + {profile} + artwork={mediaItem} + disableAutoCenter={true} + useCropCodeFromArtwork={false} + /> + {/if} + {/each} + </div> + </article> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + article { + display: flex; + align-items: stretch; + flex-direction: column; + padding: 16px; + border-radius: 28px; + box-shadow: var(--shadow-medium); + background: #fff; + transition: box-shadow 210ms ease-out; + width: 100%; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuaternary); + } + } + + article:hover { + box-shadow: 0 5px 28px rgba(0, 0, 0, 0.12); + } + + .top-container { + align-items: center; + width: 100%; + padding-bottom: 16px; + gap: 8px; + } + + .top-container, + .metadata-container { + display: flex; + } + + .metadata-container { + flex-direction: column; + flex-grow: 1; + } + + .rating-container { + display: flex; + align-items: center; + font: var(--subhead-emphasized); + color: var(--systemSecondary); + } + + .rating-container :global(svg) { + @media (prefers-contrast: more) and (prefers-color-scheme: dark) { + --fill-color: #fff; + } + } + + .editors-choice-badge-container { + display: flex; + align-items: center; + gap: 4px; + font: var(--caption-1-emphasized); + color: var(--systemSecondary); + } + + .editors-choice-badge-container :global(svg) { + height: 14px; + overflow: visible; + + @include rtl { + transform: rotateY(180deg); + } + } + + .editors-choice-badge-container :global(svg path) { + fill: var(--systemSecondary); + } + + h3 { + font: var(--headline); + } + + h4 { + color: var(--systemSecondary); + font: var(--footnote-emphasized); + text-transform: uppercase; + } + + p { + font: var(--callout); + color: var(--systemSecondary); + } + + .artwork-container { + --container-aspect-ratio: 1.333; + --artwork-override-object-fit: contain; + --artwork-override-height: auto; + --artwork-override-width: 100%; + --artwork-override-max-height: 100%; + --artwork-override-max-width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: calc(100% * var(--container-aspect-ratio)); + aspect-ratio: var(--container-aspect-ratio); + border-radius: var(--global-border-radius-medium); + + &.iphone { + --container-aspect-ratio: 1.444; + } + + &.ipad { + --container-aspect-ratio: 1.54; + } + + &.mac { + --container-aspect-ratio: 1.6; + } + + &.watch { + --container-aspect-ratio: 1.636; + } + + &.tv, + &.vision { + --container-aspect-ratio: 1.77; + } + } + + // Centers a single item in the grid + .artwork-container :global(> :only-child) { + justify-self: center; + } + + // Aligns the first of two items to the center edge + .artwork-container :global(> :nth-child(1):nth-last-child(2)) { + justify-self: flex-end; + } + + // Aligns the second of two items to the center edge + .artwork-container :global(> :nth-child(2):nth-last-child(1)) { + justify-self: flex-start; + } + + .video-wrapper { + display: flex; + overflow: hidden; + max-height: 100%; + width: auto; + aspect-ratio: var(--media-aspect-ratio, 16/9); + border: 1px solid var(--systemQuaternary); + border-radius: 16px; + } + + .artwork-container :global(.artwork-component) { + display: flex; + aspect-ratio: var(--media-aspect-ratio); + border-radius: 16px; + justify-content: center; + align-items: center; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + } + + .artwork-container :global(.artwork-component img) { + height: 100%; + } + + .artwork-container :global(.video-container) { + container-type: normal; + } + + .artwork-container :global(video) { + width: 100%; + height: 100%; + object-fit: cover; + } +</style> diff --git a/src/components/jet/item/SmallBreakoutItem.svelte b/src/components/jet/item/SmallBreakoutItem.svelte new file mode 100644 index 0000000..311fbef --- /dev/null +++ b/src/components/jet/item/SmallBreakoutItem.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import { + type Artwork as JetArtworkType, + type SmallBreakout, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment/types/optional'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: SmallBreakout; + + $: ({ backgroundColor, iconArtwork, clickAction: action = null } = item); + + $: backgroundColorForCss = backgroundColor + ? colorAsString(backgroundColor) + : '#000'; +</script> + +<LinkWrapper {action}> + <HoverWrapper> + <div class="container" style:--background-color={backgroundColorForCss}> + {#if iconArtwork} + <div class="artwork-container"> + <AppIcon + icon={iconArtwork} + profile="app-icon-xlarge" + fixedWidth={false} + /> + </div> + {/if} + + <div + class="text-container" + class:with-dark-background={item.details.backgroundStyle === + 'dark'} + > + {#if item.details?.badge} + <LineClamp clamp={1}> + <h4>{item.details.badge}</h4> + </LineClamp> + {/if} + + {#if item.details.title} + <LineClamp clamp={2}> + <h3>{item.details.title}</h3> + </LineClamp> + {/if} + + {#if item.details.description} + <LineClamp clamp={3}> + <p>{item.details.description}</p> + </LineClamp> + {/if} + + {#if isSome(action) && isFlowAction(action)} + <span class="link-container"> + {action.title} + <span aria-hidden="true"> + <SFSymbol name="chevron.forward" /> + </span> + </span> + {/if} + </div> + </div> + </HoverWrapper> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .container { + width: 100%; + max-height: 460px; + aspect-ratio: 16/9; + background-color: var(--background-color); + container-type: inline-size; + container-name: container; + + @media (--range-small-up) { + aspect-ratio: 13/5; + } + } + + .artwork-container { + --rotation: -30deg; + position: absolute; + width: 33%; + max-width: 430px; + inset-inline-end: -10%; + transform: translateY(-8%) rotate(var(--rotation)); + + @include rtl { + --rotation: 30deg; + } + } + + @container container (min-width: 1150px) { + .artwork-container { + transform: translateY(-11%) rotate(var(--rotation)); + } + } + + .artwork-container :global(.artwork-component) { + --angle: -7px; + box-shadow: var(--angle) 5px 12px 0 rgba(0, 0, 0, 0.15); + + @include rtl { + --angle: 7px; + } + } + + .text-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 66%; + height: 100%; + padding: 0 20px; + text-wrap: pretty; + + @media (--range-small-up) { + width: 33%; + } + + @media (--range-large-up) { + width: 33%; + } + } + + .text-container.with-dark-background { + color: var(--systemPrimary-onDark); + } + + .link-container { + display: flex; + gap: 4px; + margin-top: 16px; + font: var(--title-3-emphasized); + + @media (--range-small-up) { + font: var(--title-2-emphasized); + } + } + + .link-container :global(svg) { + width: 10px; + height: 10px; + fill: currentColor; + + @include rtl { + transform: rotate(180deg); + } + } + + h3 { + text-wrap: balance; + font: var(--title-1-emphasized); + + @media (--range-small-up) { + font: var(--large-title-emphasized); + } + } + + h4 { + font: var(--subhead-emphasized); + + @media (--range-small-up) { + font: var(--headline); + } + } + + p { + margin-top: 8px; + + @media (--range-small-up) { + font: var(--title-3); + } + } +</style> diff --git a/src/components/jet/item/SmallLockupItem.svelte b/src/components/jet/item/SmallLockupItem.svelte new file mode 100644 index 0000000..b235652 --- /dev/null +++ b/src/components/jet/item/SmallLockupItem.svelte @@ -0,0 +1,110 @@ +<script lang="ts"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Lockup; + + /** + * Controls the `get-button` variant class that is applied to the "View" button + * + * @default "gray" + */ + export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'gray'; + export let shouldShowLaunchNativeButton: boolean = false; + export let titleLineCount: number = 2; + export let appIconProfile: AppIconProfile = 'app-icon-small'; + + const i18n = getI18n(); +</script> + +<div class="small-lockup-item"> + <LinkWrapper + action={item.clickAction} + label={`${$i18n.t('ASE.Web.AppStore.View')} ${ + item.title ? item.title : null + }`} + > + {#if item.icon} + <div class="app-icon-container"> + <AppIcon icon={item.icon} profile={appIconProfile} /> + </div> + {/if} + + <div class="metadata-container"> + {#if item.heading} + <LineClamp clamp={1}> + <h4 dir="auto">{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={titleLineCount}> + <h3 dir="auto">{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p dir="auto">{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + <div class="button-container" aria-hidden="true"> + {#if shouldShowLaunchNativeButton && $$slots['launch-native-button']} + <slot name="launch-native-button" /> + {:else} + <span class="get-button {buttonVariant}"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + {/if} + </div> + </LinkWrapper> +</div> + +<style> + .small-lockup-item, + .small-lockup-item :global(a) { + display: flex; + align-items: center; + width: 100%; + } + + .app-icon-container { + flex-shrink: 0; + margin-inline-end: 16px; + } + + .metadata-container { + margin-inline-end: 16px; + } + + h3 { + color: var(--title-color); + font: var(--title-3-emphasized); + } + + h4 { + color: var(--eyebrow-color, var(--systemSecondary)); + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: var(--eyebrow-blend-mode); + } + + p { + font: var(--callout); + color: var(--subtitle-color, var(--systemSecondary)); + mix-blend-mode: var(--subtitle-blend-mode); + } + + .button-container { + margin-inline-start: auto; + margin-inline-end: var(--margin-inline-end, 0); + mix-blend-mode: var(--button-blend-mode); + flex-shrink: 0; + } +</style> diff --git a/src/components/jet/item/SmallLockupWithOrdinalItem.svelte b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte new file mode 100644 index 0000000..9fb796c --- /dev/null +++ b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte @@ -0,0 +1,176 @@ +<script lang="ts" context="module"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + interface SmallLockupWithOrdinalItem extends Lockup { + ordinal: string; + } + + export function isSmallLockupWithOrdinalItem( + item: Lockup, + ): item is SmallLockupWithOrdinalItem { + return !!item?.ordinal; + } +</script> + +<script lang="ts"> + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + import mediaQueries from '~/utils/media-queries'; + + export let item: Lockup; + + $: titleLineCount = item.heading || $mediaQueries === 'xsmall' ? 1 : 2; + + const i18n = getI18n(); +</script> + +<LinkWrapper action={item.clickAction}> + <article> + {#if item.ordinal} + <div class="ordinal"> + {item.ordinal} + </div> + {/if} + + {#if item.icon} + <div + class="app-icon-container" + style:--icon-aspect-ratio={item.icon.width / item.icon.height} + > + <AppIcon + icon={item.icon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + {/if} + <div class="metadata-container"> + {#if item.heading} + <LineClamp clamp={1}> + <h4>{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={titleLineCount}> + <h3 title={item.title}>{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + </article> +</LinkWrapper> + +<style> + article { + position: relative; + aspect-ratio: 0.9; + height: 100%; + padding: 16px; + gap: 10px; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + text-align: center; + border-radius: var(--global-border-radius-xlarge); + background: var(--systemPrimary-onDark); + box-shadow: var(--shadow-small); + container-type: inline-size; + container-name: container; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuaternary); + } + + @media (--sidebar-visible) and (--range-xsmall-only) { + aspect-ratio: 1; + } + + @media (--range-medium-up) { + aspect-ratio: 1; + } + } + + .app-icon-container { + flex-shrink: 0; + margin-top: 4px; + aspect-ratio: var(--icon-aspect-ratio); + height: clamp(40px, 40cqi, 100px); + width: auto; + } + + .metadata-container { + display: flex; + flex-direction: column; + gap: 4px; + } + + h3 { + text-wrap: balance; + font: var(--body-emphasized); + line-height: 1.1; + color: var(--title-color); + } + + h4 { + text-transform: uppercase; + font: var(--subhead-emphasized); + color: var(--systemSecondary); + } + + p { + font: var(--subhead); + color: var(--systemSecondary); + } + + .button-container { + --get-button-font: var(--subhead-bold); + align-content: end; + flex-grow: 1; + } + + .ordinal { + position: absolute; + top: 12px; + inset-inline-start: 12px; + font: var(--title-1-semibold); + color: var(--systemTertiary); + } + + @container container (width >= 180px) { + h3 { + font: var(--title-3-emphasized); + } + } + + @container container (width >= 250px) { + h3 { + font: var(--title-2-emphasized); + margin-bottom: 4px; + } + + p { + font: var(--body); + } + } + + @container container (width >= 200px) { + .button-container { + --get-button-font: unset; + } + } +</style> diff --git a/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte new file mode 100644 index 0000000..ce7784b --- /dev/null +++ b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte @@ -0,0 +1,69 @@ +<script lang="ts" context="module"> + import type { + TodayCard, + TodayCardMediaBrandedSingleApp, + } from '@jet-app/app-store/api/models'; + + export interface SmallStoryCardMediaBrandedSingleApp extends TodayCard { + media: TodayCardMediaBrandedSingleApp; + } + + export function isSmallStoryCardMediaBrandedSingleApp( + item: TodayCard, + ): item is SmallStoryCardMediaBrandedSingleApp { + return !!item.media && item.media.kind === 'brandedSingleApp'; + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: SmallStoryCardMediaBrandedSingleApp; + + $: artwork = item.media.artworks?.[0] || item.media.icon; +</script> + +<article> + <LinkWrapper action={item.clickAction}> + <HoverWrapper element="div"> + <Artwork {artwork} profile="brick" useCropCodeFromArtwork={false} /> + </HoverWrapper> + + <div class="text-container"> + <h4>{item.heading}</h4> + <h3>{item.title}</h3> + <p>{item.inlineDescription}</p> + </div> + </LinkWrapper> +</article> + +<style> + article { + aspect-ratio: 16/9; + } + + .text-container { + gap: 4px; + display: flex; + flex-direction: column; + margin-top: 8px; + } + + h3 { + font: var(--title-3); + } + + h4 { + margin-bottom: 2px; + font: var(--callout-emphasized); + color: var(--systemSecondary); + } + + p { + font: var(--body-tall); + color: var(--systemSecondary); + text-wrap: pretty; + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte new file mode 100644 index 0000000..bcd7333 --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte @@ -0,0 +1,87 @@ +<script lang="ts" context="module"> + import type { + Artwork as ArtworkModel, + TodayCard, + } from '@jet-app/app-store/api/models'; + + export interface SmallStoryCardWithArtwork extends TodayCard { + artwork: ArtworkModel; + badge: any; + } + + export function isSmallStoryCardWithArtworkItem( + item: TodayCard, + ): item is SmallStoryCardWithArtwork { + return !('media' in item) && 'artwork' in item; + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import { colorAsString } from '~/utils/color'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + export let item: SmallStoryCardWithArtwork; + + $: artwork = item.heroMedia?.artworks?.[0] || item.artwork; + + $: gradientColor = artwork.backgroundColor + ? colorAsString(artwork.backgroundColor) + : 'rgb(0 0 0 / 62%)'; +</script> + +<article> + <LinkWrapper action={item.clickAction}> + <HoverWrapper element="div"> + <Artwork {artwork} profile="small-story-card-portrait" /> + + <GradientOverlay --color={gradientColor} /> + + <div class="text-container"> + {#if item.badge?.title} + <h4>{item.badge.title}</h4> + {/if} + + {#if item.title} + <h3>{@html sanitizeHtml(item.title)}</h3> + {/if} + </div> + </HoverWrapper> + </LinkWrapper> +</article> + +<style> + article { + aspect-ratio: 3/4; + } + + .text-container { + position: absolute; + display: flex; + flex-direction: column; + justify-content: end; + height: 100%; + margin-top: 8px; + padding: 16px; + color: var(--systemPrimary); + } + + h3 { + z-index: 1; + text-wrap: pretty; + font: var(--body-bold); + color: var(--systemPrimary-onDark); + } + + h4 { + position: relative; + z-index: 1; + margin-bottom: 2px; + font: var(--caption-2-emphasized); + color: var(--systemSecondary-onDark); + mix-blend-mode: plus-lighter; + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte new file mode 100644 index 0000000..5b20e1c --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte @@ -0,0 +1,156 @@ +<script lang="ts" context="module"> + import type { + TodayCard, + TodayCardMediaAppIcon, + } from '@jet-app/app-store/api/models'; + + export interface TodayCardWithMediAppIcon extends TodayCard { + media: TodayCardMediaAppIcon; + } + + export function isSmallStoryCardWithMediaAppIcon( + item: TodayCard, + ): item is TodayCardWithMediAppIcon { + return !!item.media && item.media.kind === 'appIcon'; + } +</script> + +<script lang="ts"> + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: TodayCardWithMediAppIcon; + + $: artwork = item.heroMedia?.artworks[0]; + $: appIcon = item.media.icon; + $: backgroundImage = appIcon + ? buildSrc( + appIcon.template, + { + crop: 'bb', + width: 160, + height: 160, + fileType: 'webp', + }, + {}, + ) + : undefined; + $: backgroundColor = appIcon.backgroundColor + ? colorAsString(appIcon.backgroundColor) + : '#000'; +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + <div + class="container" + style:--background-color={backgroundColor} + style:--background-image={`url(${backgroundImage})`} + > + <div class="protection" /> + + {#if artwork} + <Artwork {artwork} profile="brick" /> + {:else} + <div class="app-icon-container"> + <div class="app-icon-normal"> + <AppIcon + icon={appIcon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + + <div class="app-icon-glow"> + <AppIcon + icon={appIcon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + </div> + {/if} + </div> + </HoverWrapper> + + <div class="text-container"> + <h4>{item.heading}</h4> + <h3>{item.title}</h3> + </div> +</LinkWrapper> + +<style lang="scss"> + @use 'amp/stylekit/core/mixins/browser-targets' as *; + + .container { + aspect-ratio: 16 / 9; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background: linear-gradient( + to bottom, + transparent 20%, + rgba(0, 0, 0, 0.33) 100% + ), + var(--background-image), var(--background-color, #000); + background-size: cover; + background-position: center; + + // Safari has issues rendering the overlaid `backdrop-filter` from `.proection` atop the + // background image of `.container`, so in Safari only we are forgoing the use of + // `var(--background-image)` and just using colors. + @include target-safari { + background: linear-gradient( + to bottom, + transparent 20%, + rgba(0, 0, 0, 0.33) 100% + ), + var(--background-color, #000); + } + } + + .protection { + position: absolute; + width: 100%; + height: 100%; + backdrop-filter: blur(80px) saturate(1.5); + } + + .app-icon-container { + position: relative; + width: 80px; + } + + .app-icon-normal { + position: relative; + z-index: 1; + filter: drop-shadow(0 0 13px rgba(0, 0, 0, 0.15)); + } + + .app-icon-glow { + position: absolute; + inset: 0; + width: 100%; + transform: scale(1.4); + filter: blur(25px); + } + + .text-container { + margin-top: 8px; + } + + h3 { + font: var(--title-3); + } + + h4 { + margin-bottom: 2px; + font: var(--callout-emphasized); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithMediaItem.svelte b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte new file mode 100644 index 0000000..4901744 --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte @@ -0,0 +1,104 @@ +<script lang="ts" context="module"> + import { isSome } from '@jet/environment/types/optional'; + import type { + TodayCard, + TodayCardMediaWithArtwork, + } from '@jet-app/app-store/api/models'; + + import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + + export interface SmallStoryCardWithMedia extends TodayCard { + media: TodayCardMediaWithArtwork; + heroMedia: TodayCardMediaWithArtwork; + } + + export function isSmallStoryCardWithMediaItem( + item: TodayCard, + ): item is SmallStoryCardWithMedia { + return isSome(item.media); + } +</script> + +<script lang="ts"> + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: SmallStoryCardWithMedia; + + $: artwork = (() => { + if (item.heroMedia) { + return item.heroMedia?.artworks?.[0]; + } + + if (isTodayCardMediaWithArtwork(item.media)) { + return item.media.artworks?.[0]; + } + + return null; + })(); +</script> + +<article> + <LinkWrapper action={item.clickAction}> + <HoverWrapper element="div"> + {#if artwork} + <div class="artwork-container"> + <Artwork + {artwork} + profile={item.heroMedia + ? 'small-story-card' + : 'small-story-card-legacy'} + useCropCodeFromArtwork={!item.heroMedia} + /> + </div> + {/if} + </HoverWrapper> + + <div class="text-container"> + <h4>{item.heading}</h4> + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + + {#if item.inlineDescription} + <LineClamp clamp={1}> + <p>{item.inlineDescription}</p> + </LineClamp> + {/if} + </div> + </LinkWrapper> +</article> + +<style> + .artwork-container { + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--color); + border-radius: 8px; + } + + .text-container { + display: flex; + margin-top: 8px; + gap: 4px; + color: var(--systemPrimary); + flex-direction: column; + } + + h3 { + font: var(--title-3); + } + + h4 { + font: var(--callout-emphasized); + color: var(--systemTertiary); + } + + p { + font: var(--body-tall); + color: var(--systemSecondary); + text-wrap: pretty; + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte new file mode 100644 index 0000000..038f504 --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte @@ -0,0 +1,118 @@ +<script lang="ts" context="module"> + import type { + TodayCard, + TodayCardMediaRiver, + } from '@jet-app/app-store/api/models'; + + export interface TodayCardWithMediaRiver extends TodayCard { + media: TodayCardMediaRiver; + } + + export function isSmallStoryCardWithMediaRiver( + item: TodayCard, + ): item is TodayCardWithMediaRiver { + return !!item.media && item.media.kind === 'river'; + } +</script> + +<script lang="ts"> + import type { Opt } from '@jet/environment/types/optional'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + import { + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + + export let item: TodayCardWithMediaRiver; + + $: icons = item.media.lockups.map((lockup) => lockup.icon); + $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks( + icons, + { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + }, + ); + + let title: Opt<string>; + let eyebrow: Opt<string>; + $: { + eyebrow = item.heading; + title = item.title; + + if (item.inlineDescription) { + eyebrow = item.title; + title = item.inlineDescription; + } + } +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + <div class="river-container" style={backgroundGradientCssVars}> + <AppIconRiver {icons} profile="app-icon" /> + </div> + </HoverWrapper> + + <div class="text-container"> + {#if eyebrow} + <h4>{eyebrow}</h4> + {/if} + + {#if title} + <h3>{title}</h3> + {/if} + </div> +</LinkWrapper> + +<style> + .river-container { + --app-icon-river-icon-width: 48px; + display: flex; + flex-direction: column; + justify-content: center; + aspect-ratio: 16 / 9; + width: 100%; + border-radius: 8px; + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) 20%, + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) 40%, + transparent 80% + ), + radial-gradient( + circle at 140% -50%, + var(--top-right, #000) 60%, + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) 50%, + transparent 100% + ); + } + + .river-container :global(.app-icons:last-of-type) { + margin-bottom: 0; + } + + .text-container { + margin-top: 8px; + } + + h3 { + font: var(--title-3); + } + + h4 { + margin-bottom: 2px; + font: var(--callout-emphasized); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/TitledParagraphItem.svelte b/src/components/jet/item/TitledParagraphItem.svelte new file mode 100644 index 0000000..ad8e4bc --- /dev/null +++ b/src/components/jet/item/TitledParagraphItem.svelte @@ -0,0 +1,175 @@ +<script lang="ts" context="module"> + import type { + ShelfModel, + TitledParagraph, + } from '@jet-app/app-store/api/models'; + + export function isTitledParagraphItem( + item: ShelfModel | string, + ): item is TitledParagraph { + return typeof item !== 'string' && 'text' in item; + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { getNumericDateFromDateString } from '@amp/web-app-components/src/utils/date'; + import { getJet } from '~/jet/svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: TitledParagraph; + + const i18n = getI18n(); + const jet = getJet(); + const isDetailView = item.style === 'detail'; + const dateForDisplay = jet.localization.timeAgo( + new Date(item.secondarySubtitle), + ); + const dateForAttribute = getNumericDateFromDateString( + item.secondarySubtitle, + ); + + let isTruncated = true; +</script> + +<article class:detail={isDetailView} class:overview={!isDetailView}> + <div class="container"> + <p> + {#if item.text} + {#if !isTruncated || isDetailView} + {item.text} + {:else} + <LineClamp + clamp={5} + observe + on:resize={({ detail }) => + (isTruncated = detail.truncated)} + > + {@html sanitizeHtml(item.text)} + </LineClamp> + + {#if isTruncated} + <button on:click={() => (isTruncated = false)}> + {$i18n.t('ASE.Web.AppStore.More')} + </button> + {/if} + {/if} + {/if} + </p> + + <div class="metadata"> + <h4>{item.primarySubtitle}</h4> + <time datetime={dateForAttribute}>{dateForDisplay}</time> + </div> + </div> +</article> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + article { + display: flex; + flex-direction: column-reverse; + font: var(--body-tall); + color: var(--systemPrimary); + margin: 0 var(--bodyGutter); + + @media (--range-small-up) { + flex-direction: row; + } + } + + .container { + display: flex; + width: 100%; + } + + p { + position: relative; + display: flex; + flex-direction: column; + white-space: break-spaces; + font: var(--body-tall); + } + + .metadata { + display: flex; + flex-direction: column; + justify-content: flex-start; + margin: 0 0 8px 8px; + text-align: end; + color: var(--systemSecondary); + } + + h4 { + font: var(--body-tall); + } + + button { + --gradient-direction: 270deg; + position: absolute; + bottom: 0; + display: flex; + justify-content: end; + color: var(--keyColor); + inset-inline-end: 0; + padding-inline-start: 20px; + background: linear-gradient( + var(--gradient-direction), + var(--pageBg) 72%, + transparent 100% + ); + + @include rtl { + --gradient-direction: 90deg; + } + } + + time { + color: var(--systemSecondary); + white-space: nowrap; + } + + .detail { + flex-direction: column-reverse; + margin: 0; + padding: 16px 0 0; + border-top: 1px solid var(--systemGray4); + } + + .detail .metadata { + gap: 2px; + } + + .detail h4 { + font: var(--body-emphasized-tall); + color: var(--systemPrimary); + } + + .overview .container { + @media (--range-medium-up) { + width: 66%; + } + } + + .overview .metadata { + flex-grow: 1; + gap: 4px; + } + + .overview p { + @media (--range-small-up) { + width: 66%; + } + + @media (--range-large-up) { + width: 50%; + } + } + + .detail .container { + justify-content: space-between; + } +</style> diff --git a/src/components/jet/item/TrailersLockupItem.svelte b/src/components/jet/item/TrailersLockupItem.svelte new file mode 100644 index 0000000..6b2ee42 --- /dev/null +++ b/src/components/jet/item/TrailersLockupItem.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + import type { TrailersLockup } from '@jet-app/app-store/api/models'; + import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: TrailersLockup; + + $: video = item.trailers.videos[0]; +</script> + +<article> + {#if video} + <div class="video-container"> + <Video + {video} + shouldSuperimposePosterImage + loop={true} + useControls={true} + profile="app-trailer-lockup-video" + /> + </div> + {/if} + + <SmallLockup {item} /> +</article> + +<style> + /* + The video container is explicitly not 16/9 aspect ratio, because a lot trailers have + pillarboxing (black bars on the sides), so expand the height of their container which + causes those black bars to overflow outside the container, thus cropping them. + This follows the iOS pattern. + */ + .video-container { + --app-trailer-lockup-video-aspect-ratio: 16/10; + aspect-ratio: var(--app-trailer-lockup--video-aspect-ratio); + margin-bottom: 16px; + overflow: hidden; + border-radius: var(--global-border-radius-large); + } + + /* + Not all trailers are in a landscape aspect ratio (many iPhone trailers are portrait), + so for those cases we force them to fit inside a landscape container, centered vertically, + by using `object-fit: cover;`. + */ + .video-container :global(video) { + aspect-ratio: var(--app-trailer-lockup-video-aspect-ratio); + object-fit: cover; + } +</style> diff --git a/src/components/jet/marker-shelf/ProductTopLockup.svelte b/src/components/jet/marker-shelf/ProductTopLockup.svelte new file mode 100644 index 0000000..e56e5b0 --- /dev/null +++ b/src/components/jet/marker-shelf/ProductTopLockup.svelte @@ -0,0 +1,463 @@ +<script lang="ts" context="module"> + import type { + AppPlatform, + ShelfBasedProductPage, + } from '@jet-app/app-store/api/models'; + + /** + * The parts of {@linkcode ShelfBasedProductPage} that are required to render + * the `MarkerShelf` component + */ + export type MarkerShelfPageRequirements = Pick< + ShelfBasedProductPage, + | 'badges' + | 'banner' + | 'developerAction' + | 'lockup' + | 'shelfMapping' + | 'titleOfferDisplayProperties' + | 'canonicalURL' + | 'appPlatforms' + >; +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import { platform } from '@amp/web-apps-utils'; + import AppIcon, { + doesAppIconNeedBorder, + } from '~/components/AppIcon.svelte'; + import Banner from '~/components/jet/item/BannerItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ProductPageArcadeBanner from '~/components/ProductPageArcadeBanner.svelte'; + import { getI18n } from '~/stores/i18n'; + import { colorAsString, isNamedColor, isRGBColor } from '~/utils/color'; + import { concatWithMiddot, isString } from '~/utils/string-formatting'; + import { + isPlatformExclusivelySupported, + isPlatformSupported, + PlatformToExclusivityText, + } from '~/utils/app-platforms'; + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import ShareArrowButton, { + isShareSupported, + } from '~/components/ShareArrowButton.svelte'; + import LaunchNativeButton from '~/components/LaunchNativeButton.svelte'; + + export let page: MarkerShelfPageRequirements; + + $: banner = page.banner; + $: lockup = page.lockup; + $: appPlatforms = page.appPlatforms; + $: offerDisplayProperties = lockup.offerDisplayProperties || {}; + $: ({ expectedReleaseDate } = offerDisplayProperties?.subtitles || {}); + + const i18n = getI18n(); + + // TODO: replace with `supportsArcade` from Jet + // rdar://143706610 (Support `supportsArcade` attribute) + $: supportsArcade = offerDisplayProperties.offerType === 'arcadeApp'; + + $: backgroundColor = isRGBColor(lockup.icon?.backgroundColor) + ? colorAsString(lockup.icon.backgroundColor) + : '#fff'; + + $: backgroundImage = lockup.icon + ? buildSrc( + lockup.icon.template, + { + crop: 'bb', + width: 400, + height: 400, + fileType: 'webp', + }, + {}, + ) + : undefined; + + $: attributes = concatWithMiddot( + [ + expectedReleaseDate && $i18n.t('ASE.Web.AppStore.App.ComingSoon'), + expectedReleaseDate && expectedReleaseDate, + // Attributes that are not relevant for Arcade Apps: + ...(!supportsArcade + ? [ + page.titleOfferDisplayProperties?.isFree && + $i18n.t('ASE.Web.AppStore.Free'), + offerDisplayProperties.priceFormatted, + offerDisplayProperties.subtitles?.standard, + lockup.tertiaryTitle, + ] + : []), + ].filter(isString), + $i18n, + ); + + $: exclusivePlatform = ( + Object.keys(PlatformToExclusivityText) as AppPlatform[] + ).find((platform: AppPlatform) => + isPlatformExclusivelySupported(platform, appPlatforms), + ); + $: exclusivityText = exclusivePlatform + ? PlatformToExclusivityText[exclusivePlatform] + : null; + + $: shouldShowLaunchNativeButton = + platform.ismacOS() && + (lockup.isIOSBinaryMacOSCompatible || + isPlatformSupported('mac', appPlatforms)); + + let shouldShowShareButton: boolean = true; + + onMount(() => { + shouldShowShareButton = isShareSupported(); + }); +</script> + +<ShelfWrapper withBottomPadding={false} withPaddingTop={false}> + <div + class="container" + style:--background-color={backgroundColor} + style:--background-image={`url(${backgroundImage})`} + > + <div class="rotate" /> + <div class="blur" /> + + <div class="content-container"> + {#if lockup.icon} + <div + class="app-icon-contianer" + class:without-border={!doesAppIconNeedBorder(lockup.icon)} + aria-hidden="true" + > + <AppIcon + icon={lockup.icon} + profile="app-icon-large" + fixedWidth={false} + /> + + <div class="glow"> + <AppIcon + icon={lockup.icon} + profile="app-icon-large" + fixedWidth={false} + /> + </div> + </div> + {/if} + + <section> + {#if supportsArcade} + <span + class="arcade-logo" + aria-label={$i18n.t( + 'ASE.Web.AppStore.ArcadeLogo.AccessibilityValue', + )} + > + <AppleArcadeLogo /> + </span> + {:else if lockup.editorialTagline} + <h3>{lockup.editorialTagline}</h3> + {/if} + + <h1> + {lockup.title} + </h1> + + <h2 class="subtitle"> + {lockup.subtitle} + </h2> + + {#if exclusivityText} + <p class="attributes"> + {$i18n.t(exclusivityText)} + </p> + {/if} + + {#if attributes.length > 0} + <p class="attributes"> + {attributes} + </p> + {/if} + + {#if page.canonicalURL && (shouldShowLaunchNativeButton || shouldShowShareButton)} + <div class="buttons-container"> + {#if shouldShowLaunchNativeButton} + <span class="launch-native-button-container"> + <LaunchNativeButton url={page.canonicalURL} /> + </span> + {/if} + + {#if shouldShowShareButton} + <!-- + If there is no launch native button, then we show a label for + the share button, which helps to visually fill out the space. + --> + <ShareArrowButton + url={page.canonicalURL} + withLabel={!shouldShowLaunchNativeButton} + /> + {/if} + </div> + {/if} + </section> + </div> + </div> +</ShelfWrapper> + +{#if banner} + <ShelfWrapper withBottomPadding={false} withTopMargin={false}> + <Banner item={banner} /> + </ShelfWrapper> +{/if} + +{#if supportsArcade} + <ShelfWrapper + withBottomPadding={false} + withTopMargin={true} + centered={false} + > + <ProductPageArcadeBanner /> + </ShelfWrapper> +{/if} + +<style> + .container { + --blend-mode: plus-lighter; + position: relative; + display: flex; + overflow: hidden; + align-items: center; + height: 200px; + color: var(--systemPrimary-onDark); + border-bottom: 1px solid var(--systemQuaternary-vibrant); + border-bottom-right-radius: 2px; + border-bottom-left-radius: 2px; + background: linear-gradient( + to bottom, + transparent 20%, + rgba(0, 0, 0, 0.8) 100% + ), + var(--background-image), var(--background-color, #000); + background-size: cover; + background-position: center; + transition: border-bottom-left-radius 210ms ease-out, + border-bottom-right-radius 210ms ease-out; + transform: translate(0); + + @media (--range-small-up) { + height: 286px; + } + + @media (--range-xlarge-up) { + border: 1px solid var(--systemQuaternary-vibrant); + border-top: none; + border-bottom-right-radius: 30px; + border-bottom-left-radius: 30px; + } + } + + .glow { + position: absolute; + z-index: -1; + top: 0; + width: 100%; + transform: scale(1.5); + filter: blur(60px); + } + + .blur { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + backdrop-filter: blur(100px) saturate(1.5); + } + + .rotate { + position: absolute; + z-index: 2; + top: 0; + left: 0; + width: 100%; + filter: brightness(1.3) saturate(0) blur(50px); + mix-blend-mode: overlay; + height: 500%; + background-image: var(--background-image); + background-repeat: repeat; + opacity: 0; + transform-origin: top center; + animation: shift-background 60s infinite linear 10s; + } + + .content-container { + display: flex; + flex-direction: row; + max-width: 840px; + gap: 1em; + margin: 0 var(--bodyGutter); + + @media (--range-small-up) { + gap: 1.5em; + } + + @media (--range-medium-up) { + gap: 2em; + } + } + + .app-icon-contianer { + position: relative; + z-index: 2; + display: flex; + align-items: center; + width: 128px; + flex-shrink: 0; + + @media (--range-small-up) { + width: 194px; + } + } + + .app-icon-contianer:not(.without-border) :global(> .app-icon) { + box-shadow: 0 0 30px rgba(0, 0, 0, 0.33); + border: 2px solid var(--systemQuaternary-onDark); + } + + section { + display: flex; + flex-direction: column; + justify-content: center; + } + + .subtitle, + .attributes { + position: relative; + z-index: 2; + margin-bottom: 4px; + font: var(--body); + color: rgba(245.973, 245.973, 245.973, 0.6); + text-wrap: pretty; + mix-blend-mode: var(--blend-mode); + + @media (--range-small-up) { + margin-bottom: 8px; + font: var(--title-2-emphasized); + } + } + + .attributes { + margin-bottom: 0; + font: var(--body); + } + + .buttons-container { + --share-arrow-size: 27px; + --launch-native-button-arrow-size: 7px; + --get-button-font: var(--footnote-bold); + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; + + @media (--range-small-up) { + --share-arrow-size: unset; + --launch-native-button-arrow-size: unset; + --get-button-font: unset; + } + } + + h1 { + position: relative; + z-index: 2; + display: flex; + align-items: center; + margin-bottom: 2px; + font: var(--title-2-emphasized); + color: white; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + text-wrap: pretty; + + @media (--sidebar-visible) { + font: var(--title-1-emphasized); + } + + @media (--range-small-up) { + font: var(--header-emphasized); + letter-spacing: -0.02em; + } + } + + h3 { + margin-bottom: 0; + position: relative; + z-index: 2; + mix-blend-mode: plus-lighter; + font: var(--body-emphasized); + + @media (--range-small-up) { + font: var(--title-3-emphasized); + } + } + + .arcade-logo { + display: flex; + height: 10px; + margin-bottom: 4px; + position: relative; + z-index: 2; + mix-blend-mode: plus-lighter; + + @media (--range-small-up) { + height: 14px; + } + } + + .launch-native-button-container { + position: relative; + z-index: 2; + } + + @keyframes shift-background { + 0% { + background-position: 50% 50%; + background-size: 100%; + transform: rotate(0deg); + opacity: 0; + } + + 10% { + opacity: 0.5; + } + + 20% { + background-position: 65% 25%; + background-size: 160%; + transform: rotate(45deg); + } + + 45% { + background-position: 90% 60%; + background-size: 250%; + transform: rotate(160deg); + opacity: 0.5; + } + + 70% { + background-position: 70% 40%; + background-size: 200%; + transform: rotate(250deg); + opacity: 0.5; + } + + 100% { + background-position: 50% 50%; + background-size: 100%; + transform: rotate(360deg); + opacity: 0; + } + } +</style> diff --git a/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte new file mode 100644 index 0000000..c1e7b2e --- /dev/null +++ b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte @@ -0,0 +1,36 @@ +<script lang="ts" context="module"> + import type { + AccessibilityParagraph, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface AccessibilityDeveloperLinkShelf extends Shelf { + items: [AccessibilityParagraph]; + } + + export function isAccessibilityDeveloperLinkShelf( + shelf: Shelf, + ): shelf is AccessibilityDeveloperLinkShelf { + let { contentType, items, title } = shelf; + + return ( + contentType === 'accessibilityParagraph' && + !title && + Array.isArray(items) + ); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import AccessibilityParagraphItem from '../item/AccessibilityParagraphItem.svelte'; + import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout'; + + export let shelf: AccessibilityDeveloperLinkShelf; + + $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf)); +</script> + +<ShelfWrapper {shelf} centered {withBottomPadding}> + <AccessibilityParagraphItem item={shelf.items[0]} /> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte new file mode 100644 index 0000000..cb2fed8 --- /dev/null +++ b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte @@ -0,0 +1,35 @@ +<script lang="ts" context="module"> + import type { + AccessibilityFeatures, + Shelf, + } from '@jet-app/app-store/api/models'; + + export interface AccessibilityFeaturesShelf extends Shelf { + items: AccessibilityFeatures[]; + } + + export function isAccessibilityFeaturesShelf( + shelf: Shelf, + ): shelf is AccessibilityFeaturesShelf { + let { contentType, items } = shelf; + + return contentType === 'accessibilityFeatures' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte'; + import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout'; + + export let shelf: AccessibilityFeaturesShelf; + + $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf)); +</script> + +<ShelfWrapper {shelf} {withBottomPadding}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <AccessibilityFeaturesItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AccessibilityHeaderShelf.svelte b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte new file mode 100644 index 0000000..990c507 --- /dev/null +++ b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte @@ -0,0 +1,182 @@ +<script lang="ts" context="module"> + import { + type Action, + type FlowAction, + type GenericPage, + type AccessibilityParagraph, + type Shelf, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + import { + isAccessibilityFeaturesShelf, + type AccessibilityFeaturesShelf, + } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte'; + + interface AccessibilityParagraphShelf extends Shelf { + items: [AccessibilityParagraph]; + } + + interface AccessibilityHeaderShelf extends AccessibilityParagraphShelf { + items: [AccessibilityParagraph]; + } + + interface AccessibilityDetailPage extends GenericPage { + shelves: (AccessibilityFeaturesShelf | AccessibilityParagraphShelf)[]; + } + + interface AccessibilityDetailPageFlowAction extends FlowAction { + page: 'accessibilityDetails'; + pageData: AccessibilityDetailPage; + } + + export function isAccessibilityHeaderShelf( + shelf: Shelf, + ): shelf is AccessibilityHeaderShelf { + let { contentType, items, title } = shelf; + + return ( + contentType === 'accessibilityParagraph' && + !!title && + Array.isArray(items) + ); + } + + function isAccessibilityParagraphShelf( + shelf: Shelf, + ): shelf is AccessibilityParagraphShelf { + let { contentType, items } = shelf; + + return contentType === 'accessibilityParagraph' && Array.isArray(items); + } + + function isAccessibilityDetailFlowAction( + action: Action, + ): action is AccessibilityDetailPageFlowAction { + return isFlowAction(action) && action.page === 'accessibilityDetails'; + } +</script> + +<script lang="ts"> + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import AccessibilityParagraphItem from '~/components/jet/item/AccessibilityParagraphItem.svelte'; + import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout'; + + export let shelf: AccessibilityHeaderShelf; + + $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf)); + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const { seeAllAction } = shelf; + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + const handleModalClose = () => modalComponent?.close(); + const handleOpenModalClick = (e: Event) => { + modalTriggerElement = e.target as HTMLElement; + modalComponent?.showModal(); + }; + + const destination = + seeAllAction && isAccessibilityDetailFlowAction(seeAllAction) + ? seeAllAction + : undefined; + const pageData = destination?.pageData; +</script> + +<ShelfWrapper {shelf} {withBottomPadding}> + <div slot="title" class="title-container"> + {#if shelf.title} + {#if destination} + <button on:click={handleOpenModalClick}> + <ShelfTitle + title={shelf.title} + seeAllAction={destination} + /> + </button> + {:else} + <ShelfTitle title={shelf.title} /> + {/if} + {/if} + + {#if pageData} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleModalClose} + title={pageData.title || null} + subtitle={null} + > + <svelte:fragment slot="content"> + <div class="modal-content-container"> + {#each pageData.shelves as shelf} + <div class="content-section"> + {#if isAccessibilityParagraphShelf(shelf)} + {#each shelf.items as item} + <AccessibilityParagraphItem + {item} + /> + {/each} + {/if} + + {#if isAccessibilityFeaturesShelf(shelf)} + {#each shelf.items as item} + <AccessibilityFeaturesItem + {item} + isDetailView={true} + /> + {/each} + {/if} + </div> + {/each} + </div> + </svelte:fragment> + </ContentModal> + </Modal> + {/if} + </div> + + <div class="header-container"> + <div> + <AccessibilityParagraphItem item={shelf.items[0]} /> + </div> + </div> +</ShelfWrapper> + +<style> + .title-container { + display: flex; + justify-content: space-between; + padding-top: 16px; + padding-inline-end: var(--bodyGutter); + } + + .header-container { + margin: 0 var(--bodyGutter); + } + + .header-container div { + @media (--range-medium-up) { + width: 66%; + } + } + + .modal-content-container { + font: var(--body-tall); + white-space: normal; + } + + .modal-content-container .content-section { + padding-top: 20px; + border-top: 1px solid var(--defaultLine); + } + + .modal-content-container .content-section:not(:first-child) { + margin-top: 20px; + } +</style> diff --git a/src/components/jet/shelf/ActionShelf.svelte b/src/components/jet/shelf/ActionShelf.svelte new file mode 100644 index 0000000..847438f --- /dev/null +++ b/src/components/jet/shelf/ActionShelf.svelte @@ -0,0 +1,80 @@ +<script lang="ts" context="module"> + import type { Shelf, Action } from '@jet-app/app-store/api/models'; + + interface ActionShelf extends Shelf { + items: Action[]; + } + + export function isActionShelf(shelf: Shelf): shelf is ActionShelf { + return shelf.contentType === 'action' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ActionShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="F" let:item> + {@const action = item} + {@const artwork = item.artwork} + {@const title = item.title} + + <div class="container"> + <LinkWrapper {action}> + {#if artwork} + <div class="artwork-container" aria-hidden="true"> + <Artwork + {artwork} + profile={getNaturalProfile(artwork, [24])} + hasTransparentBackground + /> + </div> + {/if} + {title} + </LinkWrapper> + </div> + </ShelfItemLayout> +</ShelfWrapper> + +<style> + .container :global(a) { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + background: var(--pageBG); + border-radius: var(--global-border-radius-medium); + box-shadow: var(--shadow-small); + padding: 16px 10px; + width: 100%; + font: var(--title-3-medium); + transition: background-color 210ms ease-out; + } + + .container :global(a:hover) { + /* stylelint-disable color-function-notation */ + background-color: rgb(from var(--pageBG) r g b/0.1); + /* stylelint-enable color-function-notation */ + + @media (prefers-color-scheme: dark) { + /* stylelint-disable color-function-notation */ + background-color: rgb(from var(--pageBG) r g b/0.85); + /* stylelint-enable color-function-notation */ + } + } + + .artwork-container { + width: 24px; + height: 24px; + } + + .container :global(.external-link-arrow) { + height: 10px; + } +</style> diff --git a/src/components/jet/shelf/AnnotationShelf.svelte b/src/components/jet/shelf/AnnotationShelf.svelte new file mode 100644 index 0000000..e11de72 --- /dev/null +++ b/src/components/jet/shelf/AnnotationShelf.svelte @@ -0,0 +1,49 @@ +<script lang="ts" context="module"> + import type { Shelf, Annotation } from '@jet-app/app-store/api/models'; + + interface AnnotationShelf extends Shelf { + items: Annotation[]; + } + + export function isAnnotationShelf(shelf: Shelf): shelf is AnnotationShelf { + const { contentType, items } = shelf; + + return contentType === 'annotation' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import Grid from '~/components/Grid.svelte'; + import CollapsableContent from '~/components/CollapsableContent.svelte'; + import AnnotationItem from '~/components/jet/item/Annotation/AnnotationItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: AnnotationShelf; +</script> + +<ShelfWrapper {shelf}> + <dl> + <Grid items={shelf.items} gridType="F" let:item> + <dt>{item.title}</dt> + + {#if item.summary} + <CollapsableContent> + <svelte:fragment slot="summary"> + {item.summary} + </svelte:fragment> + + <AnnotationItem {item} /> + </CollapsableContent> + {:else} + <AnnotationItem {item} /> + {/if} + </Grid> + </dl> +</ShelfWrapper> + +<style> + dt { + color: var(--systemSecondary); + margin-bottom: 4px; + } +</style> diff --git a/src/components/jet/shelf/AppEventDetailShelf.svelte b/src/components/jet/shelf/AppEventDetailShelf.svelte new file mode 100644 index 0000000..2ae84eb --- /dev/null +++ b/src/components/jet/shelf/AppEventDetailShelf.svelte @@ -0,0 +1,290 @@ +<script lang="ts" context="module"> + import { + type AppEventDetailShelf, + isAppEventDetailShelf, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + export { isAppEventDetailShelf }; +</script> + +<script lang="ts"> + import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import mediaQueries from '~/utils/media-queries'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import { colorAsString } from '~/utils/color'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import { platform } from '@amp/web-apps-utils'; + import LaunchNativeButton from '~/components/LaunchNativeButton.svelte'; + + export let shelf: AppEventDetailShelf; + + $: item = shelf.items[0]; + $: ({ appEvent, artwork: productArtwork, video } = item); + $: ({ requirements, lockup } = appEvent); + $: artwork = video ? video.preview : productArtwork; + + $: backgroundImageUrl = artwork + ? buildSrc( + artwork.template, + { + crop: artwork.crop as CropCode, + width: 200, + height: Math.floor(200 / (artwork.width / artwork.height)), + fileType: 'webp', + }, + {}, + ) + : undefined; + + $: backgroundColor = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : '#000'; + $: hasLightArtwork = appEvent.mediaOverlayStyle === 'light'; + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: clickAction = lockup?.clickAction; + $: urlToLaunchNatively = + clickAction && isFlowAction(clickAction) ? clickAction.pageUrl : null; + $: shouldShowLaunchNativeButton = + platform.ismacOS() && + lockup?.isIOSBinaryMacOSCompatible && + !!urlToLaunchNatively; + + function makeCSSURL(url: string | null | undefined): string { + return url ? `url(${url})` : ''; + } +</script> + +<ShelfWrapper {shelf} withBottomPadding={false} centered={false}> + <div + class="event-detail" + style:--background-image-url={makeCSSURL(backgroundImageUrl)} + style:--background-color={backgroundColor} + > + {#if video} + <div class="video-container"> + <Video + {video} + autoplay + loop + useControls={false} + profile="app-event-detail" + /> + </div> + {:else if artwork} + <div class="artwork-container"> + <Artwork {artwork} profile="app-event-detail" /> + </div> + {/if} + + {#if isXSmallViewport} + <div class="gradient-container"> + <GradientOverlay + --color={backgroundColor} + --height="70%" + shouldDarken={!hasLightArtwork} + /> + </div> + {:else} + <div class="tint-container" /> + {/if} + + <div class="time-container"> + <AppEventDate {appEvent} /> + </div> + + <div + class="text-container" + class:dark={hasLightArtwork && isXSmallViewport} + > + <div class="event-details-container"> + <p class="app-event-kind">{appEvent.kind}</p> + <h1 class="app-event-title">{appEvent.title}</h1> + <p class="app-event-subtitle"> + {appEvent.detail} + </p> + {#if requirements} + <span class="requirements">{requirements}</span> + {/if} + </div> + + {#if lockup} + <div class="lockup-container"> + <SmallLockupItem + {shouldShowLaunchNativeButton} + item={lockup} + buttonVariant="transparent" + appIconProfile="app-icon" + > + <svelte:fragment slot="launch-native-button"> + {#if urlToLaunchNatively} + <LaunchNativeButton url={urlToLaunchNatively} /> + {/if} + </svelte:fragment> + </SmallLockupItem> + </div> + {/if} + </div> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .event-detail { + --event-image-desktop-width: 31.64%; + --border-radius: 16px; + --event-gutter: 16px; + border-radius: var(--border-radius); + display: grid; + grid-template-areas: + 'time' + 'text'; + grid-template-rows: 1fr auto; + aspect-ratio: 9/16; + max-height: 90vh; + overflow: hidden; + position: relative; + + @media (--range-small-up) { + --event-gutter: 20px; + aspect-ratio: 16/9; + background-image: var(--background-image-url); + background-position-x: 50%; + background-position-y: 50%; + background-size: cover; + grid-template-areas: + 'image time' + 'image text'; + grid-template-columns: var(--event-image-desktop-width) auto; + grid-template-rows: auto 1fr; + } + } + + .artwork-container, + .video-container { + z-index: 1; + + /* On "mobile" the artwork should be behind both the time and text */ + grid-row-start: time; + grid-row-end: text; + grid-column: 1; + + @media (--range-small-up) { + /* On large screens, it should be to the right of the text */ + grid-area: image; + } + } + + .video-container { + background: var(--background-color); + color: transparent; + } + + .video-container :global(video) { + width: unset; + position: absolute; + } + + .tint-container { + background: var(--systemTertiary-onLight_IC); + backdrop-filter: saturate(120%) blur(24px); + z-index: 1; + + /* One smaller screens, extend behind just the text */ + grid-area: text; + + /* On larger screens, extend behind time and text */ + grid-row-start: time; + grid-row-end: text; + } + + .time-container { + grid-area: time; + margin-top: var(--event-gutter); + margin-inline-start: var(--event-gutter); + } + + .time-container :global(time) { + color: var(--systemPrimary-onLight); + font: var(--callout-emphasized); + padding: 3px 10px; + background-color: var(--systemSecondary-onDark); + border-radius: var(--global-border-radius-medium); + position: relative; + z-index: 3; + + @media (--range-small-up) { + position: relative; + z-index: 3; + mix-blend-mode: plus-lighter; + } + } + + .text-container { + --blend-mode: plus-lighter; + --text-color: var(--systemPrimary-onDark); + padding: var(--event-gutter); + + /* Placement within parent */ + grid-area: text; + + /* Layout of child elements */ + display: flex; + flex-direction: column; + gap: 16px; + justify-content: space-between; + color: var(--text-color); + } + + .text-container.dark { + --blend-mode: normal; + --text-color: var(--systemPrimary-onLight); + } + + .event-details-container { + display: flex; + flex-direction: column; + gap: 6px; + } + + .app-event-kind { + font: var(--callout-emphasized); + mix-blend-mode: var(--blend-mode); + z-index: 1; + } + + .app-event-title { + font: var(--large-title-emphasized); + text-wrap: pretty; + z-index: 1; + } + + .app-event-subtitle { + font: var(--title-3); + z-index: 1; + } + + .requirements { + position: relative; + z-index: 1; + font: var(--body-emphasized); + } + + .lockup-container { + --title-color: var(--text-color); + --subtitle-color: var(--text-color); + --eyebrow-color: var(--text-color); + --linkColor: var(--text-color); + --button-blend-mode: var(--blend-mode); + border-top: 1px solid var(--systemQuaternary-onDark); + padding-top: 16px; + z-index: 1; + } +</style> diff --git a/src/components/jet/shelf/AppPromotionShelf.svelte b/src/components/jet/shelf/AppPromotionShelf.svelte new file mode 100644 index 0000000..48590cb --- /dev/null +++ b/src/components/jet/shelf/AppPromotionShelf.svelte @@ -0,0 +1,47 @@ +<script lang="ts" context="module"> + import type { + AppPromotion, + AppEvent, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface AppPromotionShelf extends Shelf { + items: AppPromotion[]; + } + + export function isAppPromotionShelf( + shelf: Shelf, + ): shelf is AppPromotionShelf { + const { contentType, items } = shelf; + return contentType === 'appPromotion' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import AppEventItem from '~/components/jet/item/AppEventItem.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import mediaQueries from '~/utils/media-queries'; + + export let shelf: AppPromotionShelf; + + $: appEventItems = shelf.items.filter( + (item): item is AppEvent => item.promotionType === 'appEvent', + ); + $: isArticleContext = shelf.presentationHints?.isArticleContext; + $: gridType = + isArticleContext && $mediaQueries !== 'small' ? 'Spotlight' : 'B'; +</script> + +<ShelfWrapper {shelf} withTopMargin={isArticleContext}> + <ShelfItemLayout + shelf={{ + ...shelf, + items: appEventItems, + }} + {gridType} + let:item + > + <AppEventItem {item} {isArticleContext} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AppShowcaseShelf.svelte b/src/components/jet/shelf/AppShowcaseShelf.svelte new file mode 100644 index 0000000..095acf2 --- /dev/null +++ b/src/components/jet/shelf/AppShowcaseShelf.svelte @@ -0,0 +1,29 @@ +<script lang="ts" context="module"> + import type { AppShowcase, Shelf } from '@jet-app/app-store/api/models'; + + interface AppShowcaseShelf extends Shelf { + contentType: 'appShowcase'; + items: [AppShowcase]; + } + + export function isAppShowcaseShelf( + shelf: Shelf, + ): shelf is AppShowcaseShelf { + return ( + shelf.contentType === 'appShowcase' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte'; + + export let shelf: AppShowcaseShelf; + + $: item = shelf.items[0]; +</script> + +<ShelfWrapper {shelf} withTopMargin centered> + <SmallLockup item={item.lockup} /> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AppTrailerLockupShelf.svelte b/src/components/jet/shelf/AppTrailerLockupShelf.svelte new file mode 100644 index 0000000..f516074 --- /dev/null +++ b/src/components/jet/shelf/AppTrailerLockupShelf.svelte @@ -0,0 +1,48 @@ +<script lang="ts" context="module"> + import type { + TrailersLockup, + MixedMediaLockup, + Shelf, + } from '@jet-app/app-store/api/models'; + + type AppTrailerLockupItem = TrailersLockup | MixedMediaLockup; + + interface AppTrailerLockupShelf extends Shelf { + contentType: 'appTrailerLockup'; + items: AppTrailerLockupItem[]; + } + + export function isAppTrailerLockupShelf( + shelf: Shelf, + ): shelf is AppTrailerLockupShelf { + return ( + shelf.contentType === 'appTrailerLockup' && + Array.isArray(shelf.items) + ); + } + + function isMixedMediaLockup( + item: AppTrailerLockupItem, + ): item is MixedMediaLockup { + return Array.isArray(item.trailers); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import MixedMediaLockupItem from '~/components/jet/item/MixedMediaLockupItem.svelte'; + import TrailersLockupItem from '~/components/jet/item/TrailersLockupItem.svelte'; + + export let shelf: AppTrailerLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="B" let:item> + {#if isMixedMediaLockup(item)} + <MixedMediaLockupItem {item} /> + {:else} + <TrailersLockupItem {item} /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ArcadeFooterShelf.svelte b/src/components/jet/shelf/ArcadeFooterShelf.svelte new file mode 100644 index 0000000..dc46740 --- /dev/null +++ b/src/components/jet/shelf/ArcadeFooterShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { Shelf, ArcadeFooter } from '@jet-app/app-store/api/models'; + + interface ArcadeFooterShelf extends Shelf { + items: [ArcadeFooter]; + } + + export function isArcadeFooterShelf( + shelf: Shelf, + ): shelf is ArcadeFooterShelf { + const { contentType, items } = shelf; + + return contentType === 'arcadeFooter' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ArcadeFooterItem from '~/components/jet/item/ArcadeFooterItem.svelte'; + import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ArcadeFooterShelf; + + $: gridRows = shelf.rowsPerColumn ?? undefined; + $: items = shelf.items; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <HorizontalShelf {gridRows} gridType="Spotlight" {items} let:item> + <ArcadeFooterItem {item} /> + </HorizontalShelf> +</ShelfWrapper> diff --git a/src/components/jet/shelf/BannerShelf.svelte b/src/components/jet/shelf/BannerShelf.svelte new file mode 100644 index 0000000..84289c9 --- /dev/null +++ b/src/components/jet/shelf/BannerShelf.svelte @@ -0,0 +1,35 @@ +<script lang="ts" context="module"> + import type { Shelf, Banner } from '@jet-app/app-store/api/models'; + + interface BannerShelf extends Shelf { + contentType: 'banner'; + items: Banner[]; + } + + export function isBannerShelf(shelf: Shelf): shelf is BannerShelf { + return shelf.contentType === 'banner' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import BannerItem from '~/components/jet/item/BannerItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: BannerShelf; +</script> + +<ShelfWrapper {shelf}> + <div class="banner-items-container"> + {#each shelf.items as item} + <BannerItem {item} /> + {/each} + </div> +</ShelfWrapper> + +<style> + .banner-items-container { + display: flex; + flex-direction: column; + gap: 8px; + } +</style> diff --git a/src/components/jet/shelf/BrickShelf.svelte b/src/components/jet/shelf/BrickShelf.svelte new file mode 100644 index 0000000..4bd55e5 --- /dev/null +++ b/src/components/jet/shelf/BrickShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface BrickShelf extends Shelf { + items: Brick[]; + } + + export function isBrickShelf(shelf: Shelf): shelf is BrickShelf { + const { contentType, items } = shelf; + return contentType === 'brick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import BrickItem from '~/components/jet/item/BrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: BrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout + {shelf} + gridTypeForShelf="Brick" + gridTypeForGrid="F" + let:item + > + <BrickItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/CategoryBrickShelf.svelte b/src/components/jet/shelf/CategoryBrickShelf.svelte new file mode 100644 index 0000000..22ca86b --- /dev/null +++ b/src/components/jet/shelf/CategoryBrickShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface CategoryBrickShelf extends Shelf { + items: Brick[]; + } + + export function isCategoryBrickShelf( + shelf: Shelf, + ): shelf is CategoryBrickShelf { + const { contentType, items } = shelf; + return contentType === 'categoryBrick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import BrickItem from '~/components/jet/item/BrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: CategoryBrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="C" let:item> + <BrickItem {item} shouldOverlayDescription /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/EditorialCardShelf.svelte b/src/components/jet/shelf/EditorialCardShelf.svelte new file mode 100644 index 0000000..efbd71d --- /dev/null +++ b/src/components/jet/shelf/EditorialCardShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { Shelf, EditorialCard } from '@jet-app/app-store/api/models'; + + interface EditorialCardShelf extends Shelf { + items: EditorialCard[]; + } + + export function isEditorialCardShelf( + shelf: Shelf, + ): shelf is EditorialCardShelf { + const { contentType, items } = shelf; + + return contentType === 'editorialCard' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import HeroCarousel from '~/components/hero/Carousel.svelte'; + import EditorialCardItem from '~/components/jet/item/EditorialCardItem.svelte'; + + export let shelf: EditorialCardShelf; + + $: items = shelf.items; + + function deriveBackgroundArtworkFromItem(item: EditorialCard) { + return item.artwork; + } +</script> + +<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item> + <EditorialCardItem {item} /> +</HeroCarousel> diff --git a/src/components/jet/shelf/EditorialLinkShelf.svelte b/src/components/jet/shelf/EditorialLinkShelf.svelte new file mode 100644 index 0000000..0946462 --- /dev/null +++ b/src/components/jet/shelf/EditorialLinkShelf.svelte @@ -0,0 +1,122 @@ +<script lang="ts" context="module"> + import type { Shelf, EditorialLink } from '@jet-app/app-store/api/models'; + + interface EditorialLinkShelf extends Shelf { + contentType: 'smallStoryCard'; + items: [EditorialLink]; + } + + export function isEditorialLinkShelf( + shelf: Shelf, + ): shelf is EditorialLinkShelf { + const { contentType, items } = shelf; + return contentType === 'editorialLink' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ChevronRightIcon from '~/sf-symbols/chevron.right.svg'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let shelf: EditorialLinkShelf; + $: item = shelf.items[0]; + $: ({ clickAction, descriptionText, summaryText } = item); +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <article> + <LinkWrapper + action={clickAction} + includeExternalLinkArrowIcon={false} + label={descriptionText} + > + <svelte:fragment> + <div> + <span class="title">{descriptionText}</span> + <span class="subtitle">{summaryText}</span> + </div> + + <span class="icon-container" aria-hidden="true"> + <ChevronRightIcon /> + </span> + </svelte:fragment> + </LinkWrapper> + </article> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + article { + display: flex; + align-items: center; + flex-direction: row; + justify-content: space-between; + padding: 16px; + margin: 0 var(--bodyGutter); + border-radius: var(--global-border-radius-medium); + background-color: var(--systemQuinary); + transition: background-color 210ms ease-out; + } + + article:hover { + cursor: pointer; + // a fallback for browsers that don't support relative colors (e.g. the `from` syntax) + background-color: var(--systemQuinary); + // stylelint-disable-next-line color-function-notation + background-color: rgb( + from var(--systemQuinary) r g b / calc(alpha + 0.02) + ); + } + + article:hover .icon-container { + transform: translateX(2px); + + @include rtl { + transform: translateX(-2px) rotate(-180deg); + } + } + + div { + display: flex; + flex-direction: column; + gap: 4px; + } + + .title { + font: var(--body-emphasized); + } + + .subtitle { + color: var(--systemSecondary); + } + + .icon-container { + position: relative; + height: 10px; + aspect-ratio: 0.9; + transition: transform 210ms ease-out; + + @include rtl { + transform: rotate(-180deg); + } + } + + .icon-container :global(path:not([fill='none'])) { + fill: var(--systemPrimary); + } + + article :global(a) { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + &:hover { + text-decoration: none; + } + } +</style> diff --git a/src/components/jet/shelf/FallbackShelf.svelte b/src/components/jet/shelf/FallbackShelf.svelte new file mode 100644 index 0000000..c7e4200 --- /dev/null +++ b/src/components/jet/shelf/FallbackShelf.svelte @@ -0,0 +1,39 @@ +<script lang="ts" context="module"> + import type { Shelf, ShelfModel } from '@jet-app/app-store/api/models'; + + interface FallbackShelf extends Shelf { + items: ShelfModel[]; + } + + export function isFallbackShelf(shelf: Shelf): shelf is FallbackShelf { + return Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: FallbackShelf; + + const isPlaceholder = shelf.contentType === 'placeholder'; +</script> + +<ShelfWrapper withTopBorder> + <ShelfItemLayout {shelf} gridType="C"> + <div class="wip"> + {isPlaceholder + ? `🔄 Placeholder for ${shelf.placeholderContentType}` + : `🚧 ${shelf.contentType}`} + </div> + </ShelfItemLayout> +</ShelfWrapper> + +<style> + .wip { + background: #f8f8f8; + padding: 16px; + border-radius: 8px; + border: 1px solid #ccc; + } +</style> diff --git a/src/components/jet/shelf/FramedArtworkShelf.svelte b/src/components/jet/shelf/FramedArtworkShelf.svelte new file mode 100644 index 0000000..16f7c48 --- /dev/null +++ b/src/components/jet/shelf/FramedArtworkShelf.svelte @@ -0,0 +1,98 @@ +<script lang="ts" context="module"> + import type { FramedArtwork, Shelf } from '@jet-app/app-store/api/models'; + + interface FramedArtworkShelf extends Shelf { + contentType: 'framedArtwork'; + items: [FramedArtwork]; + } + + export function isFramedArtworkShelf( + shelf: Shelf, + ): shelf is FramedArtworkShelf { + return ( + shelf.contentType === 'framedArtwork' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: FramedArtworkShelf; + + $: item = shelf.items[0]; + $: ({ artwork, caption, hasRoundedCorners } = item); + $: profile = getNaturalProfile(artwork, [1275, 1185, 825, 500, 690]); + $: aspectRatio = artwork.width / artwork.height; + $: isPortrait = aspectRatio < 1; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <figure + class="framed-artwork-item" + class:has-rounded-corners={hasRoundedCorners} + class:is-portrait={isPortrait} + > + <div + class="artwork-container" + style:--aspect-ratio={artwork.width / artwork.height} + > + <Artwork {artwork} {profile} forceFullWidth={!isPortrait} /> + </div> + + {#if caption} + <figcaption class="caption"> + {@html sanitizeHtml(caption)} + </figcaption> + {/if} + </figure> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + .framed-artwork-item { + border-radius: var(--framed-artwork-border-radius); + padding: 0 20px; + overflow: hidden; + + @media (--sidebar-visible) { + padding: 0 20px; + } + + @media (--range-small-only) { + padding: 0 var(--bodyGutter); + } + } + + .framed-artwork-item.has-rounded-corners { + --framed-artwork-border-radius: var(--global-border-radius-medium); + } + + .artwork-container { + border-radius: inherit; + } + + .caption { + border-bottom-left-radius: var(--framed-artwork-border-radius); + border-bottom-right-radius: var(--framed-artwork-border-radius); + color: var(--systemSecondary); + padding: 8px var(--article-page-padding) 0; + } + + .framed-artwork-item.is-portrait { + --artwork-override-max-height: 560px; + --artwork-override-max-width: 100%; + --artwork-override-width: auto; + } + + .framed-artwork-item.framed-artwork-item.is-portrait .artwork-container { + display: flex; + justify-content: center; + width: 100%; + max-height: var(--artwork-override-max-height); + aspect-ratio: var(--aspect-ratio); + } +</style> diff --git a/src/components/jet/shelf/FramedVideoShelf.svelte b/src/components/jet/shelf/FramedVideoShelf.svelte new file mode 100644 index 0000000..a685d39 --- /dev/null +++ b/src/components/jet/shelf/FramedVideoShelf.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { FramedVideo, Shelf } from '@jet-app/app-store/api/models'; + + interface FramedVideoShelf extends Shelf { + contentType: 'framedArtwork'; + items: [FramedVideo]; + } + + export function isFramedVideoShelf( + shelf: Shelf, + ): shelf is FramedVideoShelf { + return ( + shelf.contentType === 'framedVideo' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { getNaturalProfile } from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let shelf: FramedVideoShelf; + + $: ({ caption, video } = shelf.items[0]); + $: aspectRatio = video.preview.width / video.preview.height; + $: profile = getNaturalProfile(video.preview, [608, 528, 608, 928, 298]); + $: isPortrait = aspectRatio < 1; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <figure class="framed-artwork-item" class:is-portrait={isPortrait}> + <div class="artwork-container" style:--aspect-ratio={aspectRatio}> + <Video {video} {profile} autoplay /> + </div> + + {#if caption} + <figcaption class="caption"> + {@html sanitizeHtml(caption)} + </figcaption> + {/if} + </figure> +</ShelfWrapper> + +<style> + .framed-artwork-item { + border-radius: var(--global-border-radius-medium); + padding: 0 20px; + overflow: hidden; + + @media (--sidebar-visible) { + padding: 0 20px; + } + + @media (--range-small-only) { + padding: 0 var(--bodyGutter); + } + } + + .artwork-container { + aspect-ratio: var(--aspect-ratio); + overflow: hidden; + line-height: 0; + border-radius: var(--global-border-radius-medium); + background-color: var(--systemQuaternary); + max-height: 560px; + max-width: 100%; + margin: 0 auto; + } + + .caption { + border-bottom-left-radius: var(--global-border-radius-medium); + border-bottom-right-radius: var(--global-border-radius-medium); + color: var(--systemSecondary); + padding: 8px var(--article-page-padding) 0; + } +</style> diff --git a/src/components/jet/shelf/HeroCarouselShelf.svelte b/src/components/jet/shelf/HeroCarouselShelf.svelte new file mode 100644 index 0000000..31a0287 --- /dev/null +++ b/src/components/jet/shelf/HeroCarouselShelf.svelte @@ -0,0 +1,38 @@ +<script lang="ts" context="module"> + import type { + Shelf, + HeroCarousel as HeroCarouselModel, + HeroCarouselItem as HeroCarouselItemModel, + } from '@jet-app/app-store/api/models'; + + interface HeroCarouselShelf extends Shelf { + items: [HeroCarouselModel]; + } + + export function isHeroCarouselShelf( + shelf: Shelf, + ): shelf is HeroCarouselShelf { + const { contentType, items } = shelf; + + return contentType === 'heroCarousel' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import HeroCarousel from '~/components/hero/Carousel.svelte'; + import HeroCarouselItem from '~/components/jet/item/HeroCarouselItem.svelte'; + import { isRtl } from '~/utils/locale'; + + export let shelf: HeroCarouselShelf; + + $: ({ items: ltrItems, rtlItems } = shelf.items[0]); + $: items = isRtl() && rtlItems.length ? rtlItems : ltrItems; + + function deriveBackgroundArtworkFromItem(item: HeroCarouselItemModel) { + return item.artwork || item.video?.preview; + } +</script> + +<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item> + <HeroCarouselItem {item} /> +</HeroCarousel> diff --git a/src/components/jet/shelf/HorizontalRuleShelf.svelte b/src/components/jet/shelf/HorizontalRuleShelf.svelte new file mode 100644 index 0000000..3313ff2 --- /dev/null +++ b/src/components/jet/shelf/HorizontalRuleShelf.svelte @@ -0,0 +1,54 @@ +<script lang="ts" context="module"> + import type { + HorizontalRule, + HorizontalRuleStyle, + Shelf, + } from '@jet-app/app-store/api/models'; + + export interface HorizontalRuleShelf extends Shelf { + contentType: 'horizontalRule'; + items: [HorizontalRule]; + } + + export function isHorizontalRuleShelf( + shelf: Shelf, + ): shelf is HorizontalRuleShelf { + return ( + shelf.contentType === 'horizontalRule' && Array.isArray(shelf.items) + ); + } + + function horizontalRuleStyleToBorderStyle( + style: HorizontalRuleStyle, + ): string { + return style.toLowerCase(); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let shelf: HorizontalRuleShelf; + + $: item = shelf.items[0]; + $: borderStyle = horizontalRuleStyleToBorderStyle(item.style); +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <hr + style:color={colorAsString(item.color)} + style:border-style={borderStyle} + /> +</ShelfWrapper> + +<style> + hr { + display: block; + height: 1px; + border-width: 1px 0 0; + border-color: currentColor; + margin: 1em var(--bodyGutter); + padding: 0; + } +</style> diff --git a/src/components/jet/shelf/HorizontalShelf.svelte b/src/components/jet/shelf/HorizontalShelf.svelte new file mode 100644 index 0000000..1addb31 --- /dev/null +++ b/src/components/jet/shelf/HorizontalShelf.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import type { Opt } from '@jet/environment'; + import Shelf from '@amp/web-app-components/src/components/Shelf/Shelf.svelte'; + import type { + ArrowOffset, + GridType, + } from '@amp/web-app-components/src/components/Shelf/types'; + import { getI18n } from '~/stores/i18n'; + + type T = $$Generic; + + export let items: T[]; + export let gridType: GridType; + export let gridRows: number = 1; + export let arrowOffset: Opt<ArrowOffset> = null; + + const i18n = getI18n(); + // This makes the let:item of type T, because it doesn't know type when it comes back from the Shelf component. + function castGenericItem(x: T): T { + return x; + } +</script> + +<div class="horizontal-shelf" data-test-id="horizontal-shelf"> + <Shelf translateFn={$i18n.t} {items} {gridType} {gridRows} {arrowOffset}> + <svelte:fragment slot="item" let:item let:index let:numberOfItems> + <slot item={castGenericItem(item)} {index} {numberOfItems} /> + </svelte:fragment> + </Shelf> +</div> + +<style> + .horizontal-shelf :global(.shelf-grid) { + --shelfGridPaddingInline: var(--bodyGutter); + --shelfGridGutterWidth: var(--bodyGutter); + } + + .horizontal-shelf :global(.shelf-grid__list) { + @media (--range-xsmall-only) { + scroll-padding-inline-start: var( + --shelfScrollPaddingInline, + var(--bodyGutter) + ); + } + } + + .horizontal-shelf + :global(.shelf-grid__list--grid-type-Spotlight .shelf-grid__list-item) { + @media (--range-xsmall-only) { + --standard-lockup-shadow-offset: var(--shelfScrollPaddingInline, 0); + } + } +</style> diff --git a/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte new file mode 100644 index 0000000..bf2e75e --- /dev/null +++ b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { + InAppPurchaseLockup, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface InAppPurchaseLockupShelf extends Shelf { + items: InAppPurchaseLockup[]; + } + + export function isInAppPurchaseLockupShelf( + shelf: Shelf, + ): shelf is InAppPurchaseLockupShelf { + const { contentType, items } = shelf; + return contentType === 'inAppPurchaseLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import InAppPurchaseLockupComponent from '~/components/jet/item/InAppPurchaseLockup.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: InAppPurchaseLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="InAppPurchaseLockup" let:item> + <InAppPurchaseLockupComponent {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeBrickShelf.svelte b/src/components/jet/shelf/LargeBrickShelf.svelte new file mode 100644 index 0000000..eea1044 --- /dev/null +++ b/src/components/jet/shelf/LargeBrickShelf.svelte @@ -0,0 +1,26 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface LargeBrickShelf extends Shelf { + items: Brick[]; + } + + export function isLargeBrickShelf(shelf: Shelf): shelf is LargeBrickShelf { + const { contentType, items } = shelf; + return contentType === 'largeBrick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import LargeBrickItem from '~/components/jet/item/LargeBrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeBrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="LargeBrick" let:item> + <LargeBrickItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte new file mode 100644 index 0000000..a0dfe9c --- /dev/null +++ b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { + LargeHeroBreakout, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface LargeHeroBreakoutShelf extends Shelf { + items: LargeHeroBreakout[]; + } + + export function isLargeHeroBreakoutShelf( + shelf: Shelf, + ): shelf is LargeHeroBreakoutShelf { + const { contentType, items } = shelf; + return contentType === 'largeHeroBreakout' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import LargeHeroBreakoutItem from '~/components/jet/item/LargeHeroBreakoutItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeHeroBreakoutShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="Spotlight" let:item> + <LargeHeroBreakoutItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeImageLockupShelf.svelte b/src/components/jet/shelf/LargeImageLockupShelf.svelte new file mode 100644 index 0000000..fd192fb --- /dev/null +++ b/src/components/jet/shelf/LargeImageLockupShelf.svelte @@ -0,0 +1,30 @@ +<script lang="ts" context="module"> + import type { Shelf, ImageLockup } from '@jet-app/app-store/api/models'; + + interface LargeImageLockupShelf extends Shelf { + items: ImageLockup[]; + } + + export function isLargeImageLockupShelf( + shelf: Shelf, + ): shelf is LargeImageLockupShelf { + return ( + shelf.contentType === 'largeImageLockup' && + Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import LargeImageLockupItem from '~/components/jet/item/LargeImageLockupItem.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeImageLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="A" let:item> + <LargeImageLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeLockupShelf.svelte b/src/components/jet/shelf/LargeLockupShelf.svelte new file mode 100644 index 0000000..dedd1fe --- /dev/null +++ b/src/components/jet/shelf/LargeLockupShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { Lockup, Shelf } from '@jet-app/app-store/api/models'; + + interface LargeLockupShelf extends Shelf { + items: Lockup[]; + } + + export function isLargeLockupShelf( + shelf: Shelf, + ): shelf is LargeLockupShelf { + const { contentType, items } = shelf; + return contentType === 'largeLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import LargeLockupItem from '~/components/jet/item/LargeLockupItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="LargeLockup" let:item> + <LargeLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeStoryCardShelf.svelte b/src/components/jet/shelf/LargeStoryCardShelf.svelte new file mode 100644 index 0000000..c1a1e57 --- /dev/null +++ b/src/components/jet/shelf/LargeStoryCardShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { Shelf, TodayCard } from '@jet-app/app-store/api/models'; + + interface LargeStoryCardShelf extends Shelf { + items: TodayCard[]; + } + + export function isLargeStoryCardShelf( + shelf: Shelf, + ): shelf is LargeStoryCardShelf { + return ( + shelf.contentType === 'largeStoryCard' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import HeroCarousel from '~/components/hero/Carousel.svelte'; + import LargeStoryCardItem from '~/components/jet/item/LargeStoryCardItem.svelte'; + + export let shelf: LargeStoryCardShelf; + + $: items = shelf.items; + + function deriveBackgroundArtworkFromItem(item: TodayCard) { + return item.heroMedia?.artworks[0]; + } +</script> + +<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item> + <LargeStoryCardItem {item} /> +</HeroCarousel> diff --git a/src/components/jet/shelf/LinkableTextShelf.svelte b/src/components/jet/shelf/LinkableTextShelf.svelte new file mode 100644 index 0000000..dcfde36 --- /dev/null +++ b/src/components/jet/shelf/LinkableTextShelf.svelte @@ -0,0 +1,43 @@ +<script lang="ts" context="module"> + import type { Shelf, LinkableText } from '@jet-app/app-store/api/models'; + + interface LinkableTextShelf extends Shelf { + contentType: 'linkableText'; + items: [LinkableText]; + } + + export function isLinkableTextShelf( + shelf: Shelf, + ): shelf is LinkableTextShelf { + return ( + shelf.contentType === 'linkableText' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let shelf: LinkableTextShelf; +</script> + +<ShelfWrapper centered withPaddingTop={true}> + <div class="banner"> + <LinkableTextItem item={shelf.items[0]} /> + </div> +</ShelfWrapper> + +<style> + .banner { + background: rgba(var(--keyColor-rgb), 0.07); + padding: 8px 16px; + text-align: center; + border-radius: var(--global-border-radius-small); + } + + .banner :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/shelf/MarkerShelf.svelte b/src/components/jet/shelf/MarkerShelf.svelte new file mode 100644 index 0000000..c719235 --- /dev/null +++ b/src/components/jet/shelf/MarkerShelf.svelte @@ -0,0 +1,36 @@ +<script lang="ts" context="module"> + import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models'; + import type { + Lockup, + Shelf, + ShelfMarker, + } from '@jet-app/app-store/api/models'; + + export interface MarkerShelf extends Shelf { + contentType: 'marker'; + marker: ShelfMarker; + items: Lockup[]; + } + + export function isMarkerShelf(shelf: Shelf): shelf is MarkerShelf { + const { contentType, marker, items } = shelf; + + return ( + contentType === 'marker' && + typeof marker === 'string' && + Array.isArray(items) + ); + } +</script> + +<script lang="ts"> + import ProductTopLockup from '~/components/jet/marker-shelf/ProductTopLockup.svelte'; + + export let shelf: MarkerShelf; + + export let page: ShelfBasedProductPage; +</script> + +{#if shelf.marker === 'productTopLockup'} + <ProductTopLockup {page} /> +{/if} diff --git a/src/components/jet/shelf/MediumImageLockupShelf.svelte b/src/components/jet/shelf/MediumImageLockupShelf.svelte new file mode 100644 index 0000000..f7b1316 --- /dev/null +++ b/src/components/jet/shelf/MediumImageLockupShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { ImageLockup, Shelf } from '@jet-app/app-store/api/models'; + + interface MediumImageLockupShelf extends Shelf { + items: ImageLockup[]; + } + + export function isMediumImageLockupShelf( + shelf: Shelf, + ): shelf is MediumImageLockupShelf { + const { contentType, items } = shelf; + return contentType === 'mediumImageLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import MediumImageLockupItem from '~/components/jet/item/MediumImageLockupItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: MediumImageLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <MediumImageLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/MediumLockupShelf.svelte b/src/components/jet/shelf/MediumLockupShelf.svelte new file mode 100644 index 0000000..186acb2 --- /dev/null +++ b/src/components/jet/shelf/MediumLockupShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Lockup, Shelf } from '@jet-app/app-store/api/models'; + + interface MediumLockupShelf extends Shelf { + items: Lockup[]; + } + + export function isMediumLockupShelf( + shelf: Shelf, + ): shelf is MediumLockupShelf { + const { contentType, items } = shelf; + return contentType === 'mediumLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import MediumLockupItem from '~/components/jet/item/MediumLockupItem.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: MediumLockupShelf; + + $: isArticleContext = shelf.presentationHints?.isArticleContext; + $: gridType = isArticleContext ? 'Spotlight' : 'MediumLockup'; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} {gridType} rowsPerColumnOverride={2} let:item> + <MediumLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/MediumStoryCardShelf.svelte b/src/components/jet/shelf/MediumStoryCardShelf.svelte new file mode 100644 index 0000000..35c3ec3 --- /dev/null +++ b/src/components/jet/shelf/MediumStoryCardShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Shelf } from '@jet-app/app-store/api/models'; + + import MediumStoryCardItem, { + type Item as MediumStoryCardItemModel, + } from '~/components/jet/item/MediumStoryCardItem.svelte'; + + interface MediumStoryCardShelf extends Shelf { + items: MediumStoryCardItemModel[]; + } + + export function isMediumStoryCardShelf( + shelf: Shelf, + ): shelf is MediumStoryCardShelf { + const { contentType, items } = shelf; + return contentType === 'mediumStoryCard' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: MediumStoryCardShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <MediumStoryCardItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/PageHeaderShelf.svelte b/src/components/jet/shelf/PageHeaderShelf.svelte new file mode 100644 index 0000000..59c99b2 --- /dev/null +++ b/src/components/jet/shelf/PageHeaderShelf.svelte @@ -0,0 +1,34 @@ +<script lang="ts" context="module"> + import type { PageHeader, Shelf } from '@jet-app/app-store/api/models'; + + interface PageHeaderShelf extends Shelf { + items: [PageHeader]; + } + + export function isPageHeaderShelf(shelf: Shelf): shelf is PageHeaderShelf { + const { contentType, items } = shelf; + return contentType === 'pageHeader' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + + export let shelf: PageHeaderShelf; + + $: [item] = shelf.items; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div class="shelf-title-wrapper" slot="title"> + <ShelfTitle title={item.title} subtitle={item.subtitle} /> + </div> +</ShelfWrapper> + +<style> + .shelf-title-wrapper { + --shelf-title-font: var(--title-1-emphasized); + display: contents; + } +</style> diff --git a/src/components/jet/shelf/ParagraphShelf.svelte b/src/components/jet/shelf/ParagraphShelf.svelte new file mode 100644 index 0000000..777338e --- /dev/null +++ b/src/components/jet/shelf/ParagraphShelf.svelte @@ -0,0 +1,52 @@ +<script lang="ts" context="module"> + import type { Paragraph, Shelf } from '@jet-app/app-store/api/models'; + + interface ParagraphShelf extends Shelf { + contentType: 'paragraph'; + items: Paragraph[]; + } + + export function isParagraphShelf(shelf: Shelf): shelf is ParagraphShelf { + return shelf.contentType === 'paragraph' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ParagraphShelfItem from '~/components/jet/item/ParagraphShelfItem.svelte'; + + export let shelf: ParagraphShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div slot="title" class="title-container"> + {#if shelf.title} + <h2>{shelf.title}</h2> + {/if} + </div> + + <div class="content-container"> + {#each shelf.items as item} + <ParagraphShelfItem {item} /> + {/each} + </div> +</ShelfWrapper> + +<style> + h2 { + color: var(--systemPrimary); + font: var(--title-2-emphasized); + text-wrap: pretty; + margin: 16px 0; + } + + .title-container, + .content-container { + margin: 0 var(--bodyGutter); + } + + /* Whenever this shelf is nested in a modal, we don't want to add extra margin since the modal provides its own */ + :global(.modal-content) .content-container { + margin: unset; + } +</style> diff --git a/src/components/jet/shelf/PosterLockupShelf.svelte b/src/components/jet/shelf/PosterLockupShelf.svelte new file mode 100644 index 0000000..101c1d6 --- /dev/null +++ b/src/components/jet/shelf/PosterLockupShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Shelf, PosterLockup } from '@jet-app/app-store/api/models'; + + interface PosterLockupShelf extends Shelf { + items: PosterLockup[]; + } + + export function isPosterLockupShelf( + shelf: Shelf, + ): shelf is PosterLockupShelf { + const { contentType, items } = shelf; + return contentType === 'posterLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import mediaQueries from '~/utils/media-queries'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import PosterLockupItem from '~/components/jet/item/PosterLockupItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: PosterLockupShelf; + + $: gridType = $mediaQueries === 'xsmall' ? 'Spotlight' : 'PosterLockup'; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} {gridType} let:item> + <PosterLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/PrivacyFooterShelf.svelte b/src/components/jet/shelf/PrivacyFooterShelf.svelte new file mode 100644 index 0000000..dccade6 --- /dev/null +++ b/src/components/jet/shelf/PrivacyFooterShelf.svelte @@ -0,0 +1,40 @@ +<script lang="ts" context="module"> + import type { PrivacyFooter, Shelf } from '@jet-app/app-store/api/models'; + + interface PrivacyFooterShelf extends Shelf { + items: [PrivacyFooter]; + } + + export function isPrivacyFooterShelf( + shelf: Shelf, + ): shelf is PrivacyFooterShelf { + let { contentType, items } = shelf; + + return contentType === 'privacyFooter' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: PrivacyFooterShelf; + + $: bodyText = shelf.items[0].bodyText; +</script> + +<ShelfWrapper {shelf} centered> + <p> + <LinkableTextItem item={bodyText} /> + </p> +</ShelfWrapper> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/shelf/PrivacyHeaderShelf.svelte b/src/components/jet/shelf/PrivacyHeaderShelf.svelte new file mode 100644 index 0000000..5ace666 --- /dev/null +++ b/src/components/jet/shelf/PrivacyHeaderShelf.svelte @@ -0,0 +1,145 @@ +<script lang="ts" context="module"> + import { + type Action, + type FlowAction, + type GenericPage, + type PrivacyHeader, + type Shelf, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + import { + isPrivacyTypeShelf, + type PrivacyTypeShelf, + } from '~/components/jet/shelf/PrivacyTypeShelf.svelte'; + + interface PrivacyHeaderShelf extends Shelf { + items: [PrivacyHeader]; + } + + interface PrivacyDetailPage extends GenericPage { + shelves: (PrivacyTypeShelf | PrivacyHeaderShelf)[]; + } + + interface PrivacyDetailPageFlowAction extends FlowAction { + page: 'privacyDetail'; + pageData: PrivacyDetailPage; + } + + export function isPrivacyHeaderShelf( + shelf: Shelf, + ): shelf is PrivacyHeaderShelf { + let { contentType, items } = shelf; + return contentType === 'privacyHeader' && Array.isArray(items); + } + + function isPrivacyDetailFlowAction( + action: Action, + ): action is PrivacyDetailPageFlowAction { + return isFlowAction(action) && action.page === 'privacyDetail'; + } +</script> + +<script lang="ts"> + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import PrivacyHeaderItem from '~/components/jet/item/PrivacyHeaderItem.svelte'; + import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte'; + import { getI18n } from '~/stores/i18n'; + import { APP_PRIVACY_MODAL_ID } from '~/utils/metrics'; + + export let shelf: PrivacyHeaderShelf; + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const { seeAllAction } = shelf; + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + const handleModalClose = () => modalComponent?.close(); + const handleOpenModalClick = (e: Event) => { + modalTriggerElement = e.target as HTMLElement; + modalComponent?.showModal(); + }; + + const destination = + seeAllAction && isPrivacyDetailFlowAction(seeAllAction) + ? seeAllAction + : undefined; + const pageData = destination?.pageData; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div slot="title" class="title-container"> + {#if shelf.title} + <button on:click={handleOpenModalClick}> + <ShelfTitle title={shelf.title} seeAllAction={destination} /> + </button> + {/if} + + {#if pageData} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + {translateFn} + on:close={handleModalClose} + title={pageData.title || null} + subtitle={null} + targetId={APP_PRIVACY_MODAL_ID} + > + <svelte:fragment slot="content"> + <ul class="modal-content-container"> + {#each pageData.shelves as shelf} + {#if isPrivacyHeaderShelf(shelf)} + {#each shelf.items as item} + <PrivacyHeaderItem {item} /> + {/each} + {/if} + + {#if isPrivacyTypeShelf(shelf)} + {#each shelf.items as item} + <PrivacyTypeItem + {item} + isDetailView={true} + /> + {/each} + {/if} + {/each} + </ul> + </svelte:fragment> + </ContentModal> + </Modal> + {/if} + </div> + + <div class="header-container"> + <div> + <PrivacyHeaderItem item={shelf.items[0]} /> + </div> + </div> +</ShelfWrapper> + +<style> + .title-container { + display: flex; + justify-content: space-between; + padding-top: 16px; + padding-inline-end: var(--bodyGutter); + } + + .header-container { + margin: 0 var(--bodyGutter); + } + + .header-container div { + @media (--range-medium-up) { + width: 66%; + } + } + + .modal-content-container { + font: var(--body-tall); + white-space: normal; + } +</style> diff --git a/src/components/jet/shelf/PrivacyTypeShelf.svelte b/src/components/jet/shelf/PrivacyTypeShelf.svelte new file mode 100644 index 0000000..3817251 --- /dev/null +++ b/src/components/jet/shelf/PrivacyTypeShelf.svelte @@ -0,0 +1,29 @@ +<script lang="ts" context="module"> + import type { PrivacyType, Shelf } from '@jet-app/app-store/api/models'; + + export interface PrivacyTypeShelf extends Shelf { + items: PrivacyType[]; + } + + export function isPrivacyTypeShelf( + shelf: Shelf, + ): shelf is PrivacyTypeShelf { + let { contentType, items } = shelf; + + return contentType === 'privacyType' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte'; + + export let shelf: PrivacyTypeShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <PrivacyTypeItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ProductBadgeShelf.svelte b/src/components/jet/shelf/ProductBadgeShelf.svelte new file mode 100644 index 0000000..cded0b7 --- /dev/null +++ b/src/components/jet/shelf/ProductBadgeShelf.svelte @@ -0,0 +1,59 @@ +<script lang="ts" context="module"> + import type { Badge, Shelf } from '@jet-app/app-store/api/models'; + + interface ProductBadgeShelf extends Shelf { + items: Badge[]; + } + + export function isProductBadgeShelf( + shelf: Shelf, + ): shelf is ProductBadgeShelf { + const { contentType, items } = shelf || {}; + return contentType === 'productBadge' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductBadgeItem from '~/components/jet/item/ProductBadgeItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductBadgeShelf; + + $: shelf.items = shelf.items.filter( + (item) => item.type !== 'friendsPlaying', + ); +</script> + +<ShelfWrapper {shelf} withBottomPadding={false} withTopMargin={true}> + <div class="inforibbon-shelf-wrapper"> + <ShelfItemLayout {shelf} gridType="ProductBadge" let:item> + <ProductBadgeItem {item} /> + </ShelfItemLayout> + </div> +</ShelfWrapper> + +<style lang="scss"> + .inforibbon-shelf-wrapper { + padding-bottom: 16px; + } + + .inforibbon-shelf-wrapper :global(ul) { + display: grid; + + /* + Here we are overriding the grid template styles from `ShelfItemLayout -> Grid`, + to make it so the badge row always takes up the full-width of the browser until + when not in the XS/mobile view. + */ + @media (--range-small-up) { + display: flex; + justify-content: space-between; + } + } + + // prevent collapse of focus outlines + .inforibbon-shelf-wrapper :global(a) { + display: block; + } +</style> diff --git a/src/components/jet/shelf/ProductCapabilityShelf.svelte b/src/components/jet/shelf/ProductCapabilityShelf.svelte new file mode 100644 index 0000000..6a4307a --- /dev/null +++ b/src/components/jet/shelf/ProductCapabilityShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ProductCapability, + } from '@jet-app/app-store/api/models'; + + interface ProductCapabilityShelf extends Shelf { + items: ProductCapability[]; + } + + export function isProductCapabilityShelf( + shelf: Shelf, + ): shelf is ProductCapabilityShelf { + const { contentType, items } = shelf; + return contentType === 'productCapability' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductCapabilityItem from '../item/ProductCapabilityItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductCapabilityShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="SearchLink" let:item> + <ProductCapabilityItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ProductDescriptionShelf.svelte b/src/components/jet/shelf/ProductDescriptionShelf.svelte new file mode 100644 index 0000000..7cddcee --- /dev/null +++ b/src/components/jet/shelf/ProductDescriptionShelf.svelte @@ -0,0 +1,95 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ProductDescription, + } from '@jet-app/app-store/api/models'; + + interface ProductDescriptionShelf extends Shelf { + items: [ProductDescription]; + } + + export function isProductDescriptionShelf( + shelf: Shelf, + ): shelf is ProductDescriptionShelf { + const { contentType, items } = shelf; + + return contentType === 'productDescription' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let shelf: ProductDescriptionShelf; + + const i18n = getI18n(); + const description = shelf.items[0]?.paragraph.text; + const handleMoreClick = () => (isOpen = true); + let isOpen = false; + + function handleLineClampResize(event: CustomEvent) { + if (!event.detail.truncated) { + isOpen = true; + } + } +</script> + +<ShelfWrapper centered> + <article> + <p> + {#if isOpen} + {@html sanitizeHtml(description)} + {:else} + <LineClamp observe clamp={5} on:resize={handleLineClampResize}> + {@html sanitizeHtml(description)} + </LineClamp> + {/if} + + {#if !isOpen} + <button on:click={handleMoreClick}> + {$i18n.t('ASE.Web.AppStore.More')} + </button> + {/if} + </p> + </article> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + p { + white-space: break-spaces; + font: var(--body-tall); + position: relative; + display: flex; + flex-direction: column; + + @media (--range-medium-up) { + width: 66%; + } + } + + button { + --gradient-direction: 270deg; + display: flex; + justify-content: end; + position: absolute; + bottom: 0; + inset-inline-end: 0; + padding-inline-start: 20px; + color: var(--keyColor); + background: linear-gradient( + var(--gradient-direction), + var(--pageBg) 72%, + transparent 100% + ); + + @include rtl { + --gradient-direction: 90deg; + } + } +</style> diff --git a/src/components/jet/shelf/ProductMediaShelf.svelte b/src/components/jet/shelf/ProductMediaShelf.svelte new file mode 100644 index 0000000..f57fee7 --- /dev/null +++ b/src/components/jet/shelf/ProductMediaShelf.svelte @@ -0,0 +1,269 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ProductMedia, + AppPlatform, + MediaType, + MediaPlatform, + } from '@jet-app/app-store/api/models'; + + interface ProductMediaShelf extends Shelf, ProductMedia { + items: ProductMedia['items']; + expandedMedia?: ProductMediaShelf[]; + } + + export function isProductMediaShelf( + shelf: Shelf, + ): shelf is ProductMediaShelf { + const { contentType, items } = shelf; + return contentType === 'productMediaItem' && Array.isArray(items); + } + + const platformToIconNameMap: Record<AppPlatform, string> = { + phone: 'iphone.gen2', + pad: 'ipad.gen2', + tv: 'tv', + watch: 'applewatch', + mac: 'macbook.gen2', + messages: 'message', + vision: 'visionpro', + }; + + const platformToDescriptionMap: Record<AppPlatform, string> = { + phone: 'AppStore.AppPlatform.Phone', + pad: 'AppStore.AppPlatform.Pad', + tv: 'AppStore.AppPlatform.TV', + watch: 'AppStore.AppPlatform.Watch', + mac: 'AppStore.AppPlatform.Mac', + messages: 'AppStore.AppPlatform.Messages', + vision: 'AppStore.AppPlatform.Vision', + }; +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductMediaVisionItem from '~/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte'; + import ProductMediaPhoneItem from '~/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte'; + import ProductMediaMacItem from '~/components/jet/item/ProductMedia/ProductMediaMacItem.svelte'; + import ProductMediaPadItem from '~/components/jet/item/ProductMedia/ProductMediaPadItem.svelte'; + import ProductMediaWatchItem from '~/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte'; + import ProductMediaTVItem from '~/components/jet/item/ProductMedia/ProductMediaTVItem.svelte'; + import ChevronDown from '~/sf-symbols/chevron.down.svg'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { getI18n } from '~/stores/i18n'; + import { slide } from 'svelte/transition'; + import { getJet } from '~/jet'; + + export let shelf: ProductMediaShelf; + export let isExpandedMedia: boolean = false; + + const i18n = getI18n(); + const jet = getJet(); + + let appPlatform: AppPlatform | undefined; + let allPlatforms: MediaPlatform[] | undefined; + let mediaType: MediaType | undefined; + let hasPortraitMedia: boolean = false; + let shouldDisplayExpandedMedia: boolean = false; + + $: { + if (shelf.contentsMetadata.type === 'productMedia') { + ({ hasPortraitMedia, allPlatforms } = shelf.contentsMetadata); + ({ appPlatform, mediaType } = shelf.contentsMetadata.platform); + } + } + + $: allPlatformsDescription = allPlatforms + ?.map(({ appPlatform }) => + $i18n.t(platformToDescriptionMap[appPlatform]), + ) + ?.join($i18n.t('AppStore.AppPlatform.Component.Separator')); + + $: shouldShowPlatform = + isExpandedMedia || + shouldDisplayExpandedMedia || + allPlatforms?.length === 1; + + const displayExpandedMedia = () => { + shouldDisplayExpandedMedia = true; + jet.recordCustomMetricsEvent({ + eventType: 'click', + actionDetails: { type: 'platformSelect' }, + targetType: 'button', + targetId: 'productMediaShelf', + }); + }; +</script> + +<ShelfWrapper {shelf} withBottomPadding={!shelf.expandedMedia}> + {#if appPlatform === 'vision'} + <ShelfItemLayout {shelf} gridType="ScreenshotVision" let:item> + <ProductMediaVisionItem {item} /> + </ShelfItemLayout> + {:else if appPlatform === 'phone' || appPlatform === 'messages'} + <ShelfItemLayout + {shelf} + gridType={hasPortraitMedia ? 'ScreenshotPhone' : 'ScreenshotLarge'} + let:item + > + <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} /> + </ShelfItemLayout> + {:else if appPlatform === 'pad'} + <ShelfItemLayout + {shelf} + gridType={hasPortraitMedia ? 'ScreenshotPad' : 'ScreenshotLarge'} + let:item + > + <ProductMediaPadItem {item} {hasPortraitMedia} {mediaType} /> + </ShelfItemLayout> + {:else if appPlatform === 'mac'} + <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item> + <ProductMediaMacItem {item} /> + </ShelfItemLayout> + {:else if appPlatform === 'tv'} + <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item> + <ProductMediaTVItem {item} /> + </ShelfItemLayout> + {:else if appPlatform === 'watch'} + <ShelfItemLayout {shelf} gridType="ScreenshotPhone" let:item> + <ProductMediaWatchItem {item} {mediaType} /> + </ShelfItemLayout> + {:else} + <ShelfItemLayout {shelf} gridType="A" let:item> + <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} /> + </ShelfItemLayout> + {/if} + + {#if appPlatform && shouldShowPlatform} + <div class="platform-description"> + <div class="icon" aria-hidden="true"> + <SFSymbol name={platformToIconNameMap[appPlatform]} /> + </div> + <div class="platform-label"> + {$i18n.t(platformToDescriptionMap[appPlatform])} + </div> + </div> + {/if} +</ShelfWrapper> + +{#if shelf.expandedMedia && allPlatforms && allPlatforms.length > 1} + <div class="expanded-media"> + {#if !shouldDisplayExpandedMedia} + <button + class="expanded-media-header" + on:click={displayExpandedMedia} + > + <div class="all-platforms"> + <div class="all-platforms-icons"> + {#each allPlatforms as platform} + <div class="icon" aria-hidden="true"> + <SFSymbol + name={platformToIconNameMap[ + platform.appPlatform + ]} + /> + </div> + {/each} + </div> + <div class="all-platforms-names"> + {allPlatformsDescription} + </div> + </div> + <div class="chevron-container icon" aria-hidden="true"> + <ChevronDown /> + </div> + </button> + {/if} + {#if shouldDisplayExpandedMedia} + <div class="expanded-media-content" transition:slide> + {#each shelf.expandedMedia as expandedMediaShelf} + <svelte:self + shelf={expandedMediaShelf} + isExpandedMedia={true} + /> + {/each} + </div> + {/if} + </div> +{/if} + +{#if !isExpandedMedia} + <div class="divider" /> +{/if} + +<style> + .expanded-media { + margin: 15px 0; + } + + .expanded-media-header { + width: 100%; + padding-inline: var(--bodyGutter); + display: inline-flex; + align-items: center; + justify-content: space-between; + } + + .platform-description { + display: inline-flex; + align-items: center; + font: var(--body-reduced-semibold); + color: var(--systemSecondary); + margin-top: 15px; + gap: 10px; + margin-inline: var(--bodyGutter); + } + + .all-platforms { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + color: var(--systemSecondary); + } + + .all-platforms-icons { + display: inline-flex; + gap: 10px; + } + + .all-platforms-names { + font: var(--body-reduced-semibold); + } + + .icon :global(svg) { + overflow: visible; + height: 16px; + max-width: 25px; + fill: var(--systemSecondary); + position: relative; + display: flex; + } + + .divider { + margin: 10px var(--bodyGutter); + border-bottom: 1px solid var(--systemGray4); + } + + .chevron-container { + top: 2px; + } + + .expanded-media-content :global(.shelf:last-of-type) { + padding-bottom: 0; + } + + .expanded-media-header .all-platforms, + .expanded-media-header .chevron-container :global(svg) { + transition-duration: 210ms; + transition-timing-function: ease-out; + transition-property: color, fill; + } + + .expanded-media-header:hover .all-platforms, + .expanded-media-header:hover .chevron-container :global(svg) { + color: var(--systemPrimary); + fill: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/shelf/ProductPageLinkShelf.svelte b/src/components/jet/shelf/ProductPageLinkShelf.svelte new file mode 100644 index 0000000..7b41e80 --- /dev/null +++ b/src/components/jet/shelf/ProductPageLinkShelf.svelte @@ -0,0 +1,59 @@ +<script lang="ts" context="module"> + import type { Shelf, ProductPageLink } from '@jet-app/app-store/api/models'; + + interface ProductPageLinkShelf extends Shelf { + items: ProductPageLink[]; + } + + export function isProductPageLinkShelf( + shelf: Shelf, + ): shelf is ProductPageLinkShelf { + const { contentType, items } = shelf; + return contentType === 'productPageLink' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ProductPageLinkItem from '~/components/jet/item/ProductPageLinkItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductPageLinkShelf; +</script> + +<ShelfWrapper {shelf}> + <div class="product-page-link-shelf"> + {#each shelf.items as item} + <li class="product-page-link-item"> + <ProductPageLinkItem {item} /> + </li> + {/each} + </div> +</ShelfWrapper> + +<style lang="scss"> + $product-page-link-border: 1px solid var(--systemGray4); + + .product-page-link-shelf { + display: flex; + justify-content: center; + align-items: center; + column-gap: 20px; + + @media (--range-xsmall-down) { + flex-direction: column; + align-items: flex-start; + } + } + + @media (--range-xsmall-down) { + .product-page-link-item:first-child { + border-top: $product-page-link-border; + } + + .product-page-link-item { + width: 100%; + border-bottom: $product-page-link-border; + padding: 0 var(--bodyGutter); + } + } +</style> diff --git a/src/components/jet/shelf/ProductRatingsShelf.svelte b/src/components/jet/shelf/ProductRatingsShelf.svelte new file mode 100644 index 0000000..8f09ab5 --- /dev/null +++ b/src/components/jet/shelf/ProductRatingsShelf.svelte @@ -0,0 +1,29 @@ +<script lang="ts" context="module"> + import { type Ratings, type Shelf } from '@jet-app/app-store/api/models'; + + interface ProductRatingsShelf extends Shelf { + items: Ratings[]; + } + + export function isProductRatingsShelf( + shelf: Shelf, + ): shelf is ProductRatingsShelf { + let { contentType, items } = shelf; + + return contentType === 'productRatings' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductRatingsItem from '~/components/jet/item/ProductRatingsItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductRatingsShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <ShelfItemLayout {shelf} gridType="A" let:item> + <ProductRatingsItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ProductReviewShelf.svelte b/src/components/jet/shelf/ProductReviewShelf.svelte new file mode 100644 index 0000000..6bc4ecb --- /dev/null +++ b/src/components/jet/shelf/ProductReviewShelf.svelte @@ -0,0 +1,38 @@ +<script lang="ts" context="module"> + import type { ProductReview, Shelf } from '@jet-app/app-store/api/models'; + + interface ProductReviewShelf extends Shelf { + items: ProductReview[]; + } + + export function isProductReviewShelf( + shelf: Shelf, + ): shelf is ProductReviewShelf { + let { contentType, items } = shelf; + + return contentType === 'productReview' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import EditorsChoiceReviewItem, { + isEditorsChoiceReviewItem, + } from '~/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte'; + import UserReviewItem, { + isUserReviewItem, + } from '~/components/jet/item/ProductReview/UserReviewItem.svelte'; + + export let shelf: ProductReviewShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="A" let:item> + {#if isUserReviewItem(item)} + <UserReviewItem {item} /> + {:else if isEditorsChoiceReviewItem(item)} + <EditorsChoiceReviewItem {item} /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/QuoteShelf.svelte b/src/components/jet/shelf/QuoteShelf.svelte new file mode 100644 index 0000000..3a14f4f --- /dev/null +++ b/src/components/jet/shelf/QuoteShelf.svelte @@ -0,0 +1,80 @@ +<script lang="ts" context="module"> + import type { Quote, Shelf } from '@jet-app/app-store/api/models'; + + interface QuoteShelf extends Shelf { + contentType: 'quote'; + items: [Quote]; + } + + export function isQuoteShelf(shelf: Shelf): shelf is QuoteShelf { + return shelf.contentType === 'quote' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: QuoteShelf; + + $: item = shelf.items[0]; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div class="outer"> + <div class="inner"> + <blockquote> + {item.text} + </blockquote> + <span>{item.credit}</span> + </div> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .outer { + display: flex; + margin-bottom: 24px; + padding: 0 var(--bodyGutter); + gap: 6px; + } + + .outer::before { + content: '❝'; + font-size: 40px; + line-height: 2.2rem; + color: var(--systemSecondary); + + @include rtl { + content: '❞'; + } + } + + .inner { + display: flex; + flex-direction: column; + gap: 6px; + } + + blockquote { + font: var(--large-title-emphasized); + text-wrap: pretty; + } + + blockquote::after { + content: '❞'; + color: var(--systemSecondary); + + @include rtl { + content: '❝'; + } + } + + span { + font: var(--title-3); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/shelf/ReviewsContainerShelf.svelte b/src/components/jet/shelf/ReviewsContainerShelf.svelte new file mode 100644 index 0000000..a55fe40 --- /dev/null +++ b/src/components/jet/shelf/ReviewsContainerShelf.svelte @@ -0,0 +1,84 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ReviewsContainer, + } from '@jet-app/app-store/api/models'; + + export interface ReviewsContainerShelf extends Shelf { + items: [ReviewsContainer]; + } + + export function isReviewsContainerShelf( + shelf: Shelf, + ): shelf is ReviewsContainerShelf { + return ( + shelf.contentType === 'reviewsContainer' && + Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte'; + + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import Grid from '~/components/Grid.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getJet } from '~/jet/svelte'; + + const i18n = getI18n(); + const jet = getJet(); + + export let shelf: ReviewsContainerShelf; + + $: reviewsContainer = shelf.items[0]; + $: ({ productAction, ratings } = reviewsContainer); + + $: numberOfRatings = jet.localization.formattedCount( + ratings.totalNumberOfRatings, + ); +</script> + +<ShelfWrapper {shelf}> + <header slot="title"> + {#if productAction} + <div class="product-action"> + <LinkWrapper action={productAction}> + {productAction.title} + </LinkWrapper> + </div> + {/if} + + <ShelfTitle title={shelf.title ?? ''} /> + + <Grid gridType="A" items={[1]}> + <div class="rating"> + <RatingComponent + averageRating={ratings.ratingAverage} + ratingCount={ratings.totalNumberOfRatings} + ratingCountText={$i18n.t( + 'ASE.Web.AppStore.Ratings.CountText', + { + numberOfRatings, + }, + )} + ratingCountsList={ratings.ratingCounts} + totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')} + /> + </div> + </Grid> + </header> +</ShelfWrapper> + +<style> + .product-action { + --linkColor: var(--keyColor); + margin: 0 var(--bodyGutter) 6px; + } + + .rating { + --ratingBarColor: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/shelf/ReviewsShelf.svelte b/src/components/jet/shelf/ReviewsShelf.svelte new file mode 100644 index 0000000..8304444 --- /dev/null +++ b/src/components/jet/shelf/ReviewsShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { + Shelf, + Review as ReviewModel, + } from '@jet-app/app-store/api/models'; + + export interface ReviewsShelf extends Shelf { + items: ReviewModel[]; + } + + export function isReviewsShelf(shelf: Shelf): shelf is ReviewsShelf { + return shelf.contentType === 'reviews' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ReviewItem from '~/components/jet/item/ReviewItem.svelte'; + + export let shelf: ReviewsShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="A" let:item> + <ReviewItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/RibbonBarShelf.svelte b/src/components/jet/shelf/RibbonBarShelf.svelte new file mode 100644 index 0000000..44a8ae9 --- /dev/null +++ b/src/components/jet/shelf/RibbonBarShelf.svelte @@ -0,0 +1,135 @@ +<script lang="ts" context="module"> + import type { Shelf, RibbonBarItem } from '@jet-app/app-store/api/models'; + + interface RibbonBarShelf extends Shelf { + items: RibbonBarItem[]; + } + + export function isRibbonBarShelf(shelf: Shelf): shelf is RibbonBarShelf { + return shelf.contentType === 'ribbonBar' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let shelf: RibbonBarShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false} withPaddingTop={false}> + <div class="scroll"> + <ul> + {#each shelf.items as ribbonBarItem} + {@const action = ribbonBarItem.clickAction} + {@const artwork = ribbonBarItem.artwork} + {@const title = ribbonBarItem.title} + <li> + <LinkWrapper {action}> + {#if artwork} + <div + class="artwork-container" + style:--aspect-ratio={artwork.width / + artwork.height} + > + {#if isSystemImageArtwork(artwork)} + <SystemImage {artwork} /> + {:else} + <Artwork + {artwork} + profile={getNaturalProfile(artwork, [ + 17, + ])} + hasTransparentBackground + /> + {/if} + </div> + {/if} + {title} + </LinkWrapper> + </li> + {/each} + </ul> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .scroll { + --gradient-direction: 90deg; + overflow-x: auto; + scrollbar-width: none; + padding-inline-start: var(--bodyGutter); + margin-inline-end: var(--bodyGutter); + // A small gradient that fades out the ribbon, to indicate that there is more + mask-image: linear-gradient( + var(--gradient-direction), + black calc(100% - 8px), + transparent 100% + ); + + @include rtl { + --gradient-direction: -90deg; + } + } + + ul { + font: var(--body-emphasized); + display: flex; + gap: 4px; + padding-bottom: 16px; + padding-top: 13px; + } + + li { + display: flex; + margin-inline-end: 8px; + flex-shrink: 0; + } + + li:last-of-type { + padding-inline-end: 8px; + } + + li :global(a) { + position: relative; + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + background: var(--pageBG); + border-radius: var(--global-border-radius-small); + padding: 6px 10px; + + &::after { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--global-border-radius-small); + box-shadow: var(--shadow-small); + z-index: calc(var(--z-default) - 1); + } + + @media (prefers-color-scheme: dark) { + background: var(--systemGray5-default_IC); + } + } + + .artwork-container { + --artwork-override-height: 17px; + flex-shrink: 0; + aspect-ratio: var(--aspect-ratio); + height: 17px; + } +</style> diff --git a/src/components/jet/shelf/SearchLinkShelf.svelte b/src/components/jet/shelf/SearchLinkShelf.svelte new file mode 100644 index 0000000..6b29780 --- /dev/null +++ b/src/components/jet/shelf/SearchLinkShelf.svelte @@ -0,0 +1,26 @@ +<script lang="ts" context="module"> + import type { Shelf, SearchLink } from '@jet-app/app-store/api/models'; + + interface SearchLinkShelf extends Shelf { + items: SearchLink[]; + } + + export function isSearchLinkShelf(shelf: Shelf): shelf is SearchLinkShelf { + const { contentType, items } = shelf; + return contentType === 'searchLink' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import SearchLinkItem from '~/components/jet/item/SearchLinkItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SearchLinkShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="SearchLink" let:item> + <SearchLinkItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SearchResultShelf.svelte b/src/components/jet/shelf/SearchResultShelf.svelte new file mode 100644 index 0000000..9c15d3e --- /dev/null +++ b/src/components/jet/shelf/SearchResultShelf.svelte @@ -0,0 +1,49 @@ +<script lang="ts" context="module"> + import type { + AppSearchResult, + Shelf, + SearchResult, + AppEventSearchResult, + } from '@jet-app/app-store/api/models'; + + import AppSearchResultItem, { + isAppSearchResult, + isAppEventSearchResult, + } from '~/components/jet/item/SearchResult/AppSearchResultItem.svelte'; + + /** + * All sub-classes of {@linkcode SearchResult} that this component can handle rendering + */ + type RenderableSearchResult = AppSearchResult | AppEventSearchResult; + + interface SearchResultShelf extends Shelf { + items: SearchResult[]; + } + + export function isSearchResultShelf( + shelf: Shelf, + ): shelf is SearchResultShelf { + return ( + shelf.contentType === 'searchResult' && Array.isArray(shelf.items) + ); + } + + export function isRenderableInSearchResultsShelf( + item: SearchResult, + ): item is RenderableSearchResult { + return isAppSearchResult(item) || isAppEventSearchResult(item); + } +</script> + +<script lang="ts"> + import Grid from '~/components/Grid.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SearchResultShelf; +</script> + +<ShelfWrapper {shelf}> + <Grid gridType="SearchResult" items={shelf.items} let:item> + <AppSearchResultItem {item} /> + </Grid> +</ShelfWrapper> diff --git a/src/components/jet/shelf/Shelf.svelte b/src/components/jet/shelf/Shelf.svelte new file mode 100644 index 0000000..6cbb0f6 --- /dev/null +++ b/src/components/jet/shelf/Shelf.svelte @@ -0,0 +1,320 @@ +<script lang="ts"> + import type { Shelf } from '@jet-app/app-store/api/models'; + + // Components for specific types of `Shelf` + import AccessibilityHeaderShelf, { + isAccessibilityHeaderShelf, + } from '~/components/jet/shelf/AccessibilityHeaderShelf.svelte'; + import AccessibilityFeaturesShelf, { + isAccessibilityFeaturesShelf, + } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte'; + import AccessibilityDeveloperLinkShelf, { + isAccessibilityDeveloperLinkShelf, + } from './AccessibilityDeveloperLinkShelf.svelte'; + import ActionShelf, { + isActionShelf, + } from '~/components/jet/shelf/ActionShelf.svelte'; + import AnnotationShelf, { + isAnnotationShelf, + } from '~/components/jet/shelf/AnnotationShelf.svelte'; + import AppEventDetailShelf, { + isAppEventDetailShelf, + } from '~/components/jet/shelf/AppEventDetailShelf.svelte'; + import AppPromotionShelf, { + isAppPromotionShelf, + } from '~/components/jet/shelf/AppPromotionShelf.svelte'; + import AppShowcaseShelf, { + isAppShowcaseShelf, + } from '~/components/jet/shelf/AppShowcaseShelf.svelte'; + import AppTrailerLockupShelf, { + isAppTrailerLockupShelf, + } from '~/components/jet/shelf/AppTrailerLockupShelf.svelte'; + import ArcadeFooterShelf, { + isArcadeFooterShelf, + } from '~/components/jet/shelf/ArcadeFooterShelf.svelte'; + import { isBannerShelf } from '~/components/jet/shelf/BannerShelf.svelte'; + import BrickShelf, { + isBrickShelf, + } from '~/components/jet/shelf/BrickShelf.svelte'; + import CategoryBrickShelf, { + isCategoryBrickShelf, + } from '~/components/jet/shelf/CategoryBrickShelf.svelte'; + import EditorialCardShelf, { + isEditorialCardShelf, + } from '~/components/jet/shelf/EditorialCardShelf.svelte'; + import EditorialLinkShelf, { + isEditorialLinkShelf, + } from '~/components/jet/shelf/EditorialLinkShelf.svelte'; + import FramedArtworkShelf, { + isFramedArtworkShelf, + } from '~/components/jet/shelf/FramedArtworkShelf.svelte'; + import FramedVideoShelf, { + isFramedVideoShelf, + } from '~/components/jet/shelf/FramedVideoShelf.svelte'; + import HeroCarouselShelf, { + isHeroCarouselShelf, + } from '~/components/jet/shelf/HeroCarouselShelf.svelte'; + import HorizontalRuleShelf, { + isHorizontalRuleShelf, + } from '~/components/jet/shelf/HorizontalRuleShelf.svelte'; + import InAppPurchaseLockupShelf, { + isInAppPurchaseLockupShelf, + } from '~/components/jet/shelf/InAppPurchaseLockupShelf.svelte'; + import LargeHeroBreakoutShelf, { + isLargeHeroBreakoutShelf, + } from '~/components/jet/shelf/LargeHeroBreakoutShelf.svelte'; + import LargeBrickShelf, { + isLargeBrickShelf, + } from '~/components/jet/shelf/LargeBrickShelf.svelte'; + import LargeImageLockupShelf, { + isLargeImageLockupShelf, + } from '~/components/jet/shelf/LargeImageLockupShelf.svelte'; + import LargeLockupShelf, { + isLargeLockupShelf, + } from '~/components/jet/shelf/LargeLockupShelf.svelte'; + import LargeStoryCardShelf, { + isLargeStoryCardShelf, + } from '~/components/jet/shelf/LargeStoryCardShelf.svelte'; + import LinkableTextShelf, { + isLinkableTextShelf, + } from '~/components/jet/shelf/LinkableTextShelf.svelte'; + import { + isMarkerShelf, + type MarkerShelf as MarkerShelfModel, + } from '~/components/jet/shelf/MarkerShelf.svelte'; + import MediumImageLockupShelf, { + isMediumImageLockupShelf, + } from '~/components/jet/shelf/MediumImageLockupShelf.svelte'; + import MediumLockupShelf, { + isMediumLockupShelf, + } from '~/components/jet/shelf/MediumLockupShelf.svelte'; + import MediumStoryCardShelf, { + isMediumStoryCardShelf, + } from '~/components/jet/shelf/MediumStoryCardShelf.svelte'; + import ProductBadgeShelf, { + isProductBadgeShelf, + } from '~/components/jet/shelf/ProductBadgeShelf.svelte'; + import PageHeaderShelf, { + isPageHeaderShelf, + } from '~/components/jet/shelf/PageHeaderShelf.svelte'; + import ParagraphShelf, { + isParagraphShelf, + } from '~/components/jet/shelf/ParagraphShelf.svelte'; + import PosterLockupShelf, { + isPosterLockupShelf, + } from '~/components/jet/shelf/PosterLockupShelf.svelte'; + import ProductMediaShelf, { + isProductMediaShelf, + } from '~/components/jet/shelf/ProductMediaShelf.svelte'; + import ProductDescriptionShelf, { + isProductDescriptionShelf, + } from '~/components/jet/shelf/ProductDescriptionShelf.svelte'; + import ProductRatingsShelf, { + isProductRatingsShelf, + } from '~/components/jet/shelf/ProductRatingsShelf.svelte'; + import ProductReviewShelf, { + isProductReviewShelf, + } from '~/components/jet/shelf/ProductReviewShelf.svelte'; + import RibbonBarShelf, { + isRibbonBarShelf, + } from '~/components/jet/shelf/RibbonBarShelf.svelte'; + import SearchLinkShelf, { + isSearchLinkShelf, + } from '~/components/jet/shelf/SearchLinkShelf.svelte'; + import SearchResultShelf, { + isSearchResultShelf, + } from '~/components/jet/shelf/SearchResultShelf.svelte'; + import SmallBreakoutShelf, { + isSmallBreakoutShelf, + } from '~/components/jet/shelf/SmallBreakoutShelf.svelte'; + import SmallBrickShelf, { + isSmallBrickShelf, + } from '~/components/jet/shelf/SmallBrickShelf.svelte'; + import SmallLockupShelf, { + isSmallLockupShelf, + } from '~/components/jet/shelf/SmallLockupShelf.svelte'; + import SmallStoryCardShelf, { + isSmallStoryCardShelf, + } from '~/components/jet/shelf/SmallStoryCardShelf.svelte'; + import PrivacyHeaderShelf, { + isPrivacyHeaderShelf, + } from '~/components/jet/shelf/PrivacyHeaderShelf.svelte'; + import PrivacyFooterShelf, { + isPrivacyFooterShelf, + } from '~/components/jet/shelf/PrivacyFooterShelf.svelte'; + import PrivacyTypeShelf, { + isPrivacyTypeShelf, + } from '~/components/jet/shelf/PrivacyTypeShelf.svelte'; + import ProductCapabilityShelf, { + isProductCapabilityShelf, + } from '~/components/jet/shelf/ProductCapabilityShelf.svelte'; + import ProductPageLinkShelf, { + isProductPageLinkShelf, + } from './ProductPageLinkShelf.svelte'; + import QuoteShelf, { + isQuoteShelf, + } from '~/components/jet/shelf/QuoteShelf.svelte'; + import ReviewsContainerShelf, { + isReviewsContainerShelf, + } from '~/components/jet/shelf/ReviewsContainerShelf.svelte'; + import ReviewsShelf, { + isReviewsShelf, + } from '~/components/jet/shelf/ReviewsShelf.svelte'; + import TitledParagraphShelf, { + isTitledParagraphShelf, + } from '~/components/jet/shelf/TitledParagraphShelf.svelte'; + import TodayCardShelf, { + isTodayCardShelf, + } from '~/components/jet/shelf/TodayCardShelf.svelte'; + import UberShelf, { + isUberShelf, + } from '~/components/jet/shelf//UberShelf.svelte'; + import FallbackShelf, { + isFallbackShelf, + } from '~/components/jet/shelf/FallbackShelf.svelte'; + + interface $$Slots { + /** + * If the `shelf` is recognized to be a {@linkcode MarkerShelfModel}, this + * slot is rendered with the `shelf` as data rather than rendering the + * shelf directly. + * + * This is done because "marker" shelves need the whole "page" definition to + * be rendered, which is not available at this level of the UI. Rather than + * having to pass that data down to this level, we yield rendering back to + * the "parent" component that can provide that data directly. + */ + 'marker-shelf': { + shelf: MarkerShelfModel; + }; + } + + export let shelf: Shelf; +</script> + +<!-- +@component +Render a generic `Shelf` + +This component is responsible for rendering any kind of `Shelf` that +the App Store is capable of rendering. It primarily does this by trying +to narrow the generic `Shelf` down to a more-specific type and then +rendering a component specifically made for it +--> + +{#if isAccessibilityHeaderShelf(shelf)} + <AccessibilityHeaderShelf {shelf} /> +{:else if isAccessibilityFeaturesShelf(shelf)} + <AccessibilityFeaturesShelf {shelf} /> +{:else if isAccessibilityDeveloperLinkShelf(shelf)} + <AccessibilityDeveloperLinkShelf {shelf} /> +{:else if isActionShelf(shelf)} + <ActionShelf {shelf} /> +{:else if isAnnotationShelf(shelf)} + <AnnotationShelf {shelf} /> +{:else if isAppEventDetailShelf(shelf)} + <AppEventDetailShelf {shelf} /> +{:else if isAppPromotionShelf(shelf)} + <AppPromotionShelf {shelf} /> +{:else if isAppShowcaseShelf(shelf)} + <AppShowcaseShelf {shelf} /> +{:else if isAppTrailerLockupShelf(shelf)} + <AppTrailerLockupShelf {shelf} /> +{:else if isArcadeFooterShelf(shelf)} + <ArcadeFooterShelf {shelf} /> +{:else if isBannerShelf(shelf)} + <!-- a no-op until we determine if we actually want to support these banners --> + <!-- <BannerShelf {shelf} /> --> +{:else if isBrickShelf(shelf)} + <BrickShelf {shelf} /> +{:else if isCategoryBrickShelf(shelf)} + <CategoryBrickShelf {shelf} /> +{:else if isEditorialCardShelf(shelf)} + <EditorialCardShelf {shelf} /> +{:else if isEditorialLinkShelf(shelf)} + <EditorialLinkShelf {shelf} /> +{:else if isFramedArtworkShelf(shelf)} + <FramedArtworkShelf {shelf} /> +{:else if isFramedVideoShelf(shelf)} + <FramedVideoShelf {shelf} /> +{:else if isHeroCarouselShelf(shelf)} + <HeroCarouselShelf {shelf} /> +{:else if isHorizontalRuleShelf(shelf)} + <HorizontalRuleShelf {shelf} /> +{:else if isInAppPurchaseLockupShelf(shelf)} + <InAppPurchaseLockupShelf {shelf} /> +{:else if isLargeHeroBreakoutShelf(shelf)} + <LargeHeroBreakoutShelf {shelf} /> +{:else if isLargeBrickShelf(shelf)} + <LargeBrickShelf {shelf} /> +{:else if isLargeImageLockupShelf(shelf)} + <LargeImageLockupShelf {shelf} /> +{:else if isLargeLockupShelf(shelf)} + <LargeLockupShelf {shelf} /> +{:else if isLargeStoryCardShelf(shelf)} + <LargeStoryCardShelf {shelf} /> +{:else if isLinkableTextShelf(shelf)} + <LinkableTextShelf {shelf} /> +{:else if isProductDescriptionShelf(shelf)} + <ProductDescriptionShelf {shelf} /> +{:else if isMediumImageLockupShelf(shelf)} + <MediumImageLockupShelf {shelf} /> +{:else if isMediumLockupShelf(shelf)} + <MediumLockupShelf {shelf} /> +{:else if isMediumStoryCardShelf(shelf)} + <MediumStoryCardShelf {shelf} /> +{:else if isPosterLockupShelf(shelf)} + <PosterLockupShelf {shelf} /> +{:else if isProductBadgeShelf(shelf)} + <ProductBadgeShelf {shelf} /> +{:else if isPageHeaderShelf(shelf)} + <PageHeaderShelf {shelf} /> +{:else if isParagraphShelf(shelf)} + <ParagraphShelf {shelf} /> +{:else if isPrivacyHeaderShelf(shelf)} + <PrivacyHeaderShelf {shelf} /> +{:else if isPrivacyFooterShelf(shelf)} + <PrivacyFooterShelf {shelf} /> +{:else if isPrivacyTypeShelf(shelf)} + <PrivacyTypeShelf {shelf} /> +{:else if isProductMediaShelf(shelf)} + <ProductMediaShelf {shelf} /> +{:else if isProductRatingsShelf(shelf)} + <ProductRatingsShelf {shelf} /> +{:else if isProductReviewShelf(shelf)} + <ProductReviewShelf {shelf} /> +{:else if isRibbonBarShelf(shelf)} + <RibbonBarShelf {shelf} /> +{:else if isSearchLinkShelf(shelf)} + <SearchLinkShelf {shelf} /> +{:else if isSearchResultShelf(shelf)} + <SearchResultShelf {shelf} /> +{:else if isSmallBreakoutShelf(shelf)} + <SmallBreakoutShelf {shelf} /> +{:else if isSmallBrickShelf(shelf)} + <SmallBrickShelf {shelf} /> +{:else if isSmallStoryCardShelf(shelf)} + <SmallStoryCardShelf {shelf} /> +{:else if isSmallLockupShelf(shelf)} + <SmallLockupShelf {shelf} /> +{:else if isProductCapabilityShelf(shelf)} + <ProductCapabilityShelf {shelf} /> +{:else if isProductPageLinkShelf(shelf)} + <ProductPageLinkShelf {shelf} /> +{:else if isQuoteShelf(shelf)} + <QuoteShelf {shelf} /> +{:else if isReviewsContainerShelf(shelf)} + <ReviewsContainerShelf {shelf} /> +{:else if isReviewsShelf(shelf)} + <ReviewsShelf {shelf} /> +{:else if isTodayCardShelf(shelf)} + <TodayCardShelf {shelf} /> +{:else if isTitledParagraphShelf(shelf)} + <TitledParagraphShelf {shelf} /> +{:else if isUberShelf(shelf)} + <UberShelf {shelf} /> +{:else if isMarkerShelf(shelf)} + <slot name="marker-shelf" {shelf} /> +{:else if isFallbackShelf(shelf)} + <FallbackShelf {shelf} /> +{/if} diff --git a/src/components/jet/shelf/SmallBreakoutShelf.svelte b/src/components/jet/shelf/SmallBreakoutShelf.svelte new file mode 100644 index 0000000..095cf7f --- /dev/null +++ b/src/components/jet/shelf/SmallBreakoutShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { + LargeHeroBreakout, + Shelf, + SmallBreakout, + } from '@jet-app/app-store/api/models'; + + interface SmallBreakoutShelf extends Shelf { + items: SmallBreakout[]; + } + + export function isSmallBreakoutShelf( + shelf: Shelf, + ): shelf is SmallBreakoutShelf { + const { contentType, items } = shelf; + return contentType === 'smallBreakout' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import SmallBreakoutItem from '~/components/jet/item/SmallBreakoutItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SmallBreakoutShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="Spotlight" let:item> + <SmallBreakoutItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SmallBrickShelf.svelte b/src/components/jet/shelf/SmallBrickShelf.svelte new file mode 100644 index 0000000..34426cf --- /dev/null +++ b/src/components/jet/shelf/SmallBrickShelf.svelte @@ -0,0 +1,26 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface SmallBrickShelf extends Shelf { + items: Brick[]; + } + + export function isSmallBrickShelf(shelf: Shelf): shelf is SmallBrickShelf { + const { contentType, items } = shelf; + return contentType === 'smallBrick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import BrickItem from '~/components/jet/item/BrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SmallBrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="C" let:item> + <BrickItem {item} shouldOverlayDescription /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SmallLockupShelf.svelte b/src/components/jet/shelf/SmallLockupShelf.svelte new file mode 100644 index 0000000..e286671 --- /dev/null +++ b/src/components/jet/shelf/SmallLockupShelf.svelte @@ -0,0 +1,54 @@ +<script lang="ts" context="module"> + import type { Lockup, Shelf } from '@jet-app/app-store/api/models'; + + interface SmallLockupShelf extends Shelf { + items: Lockup[]; + } + + export function isSmallLockupShelf( + shelf: Shelf, + ): shelf is SmallLockupShelf { + const { contentType, items } = shelf; + return contentType === 'smallLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + import SmallLockupWithOrdinalItem, { + isSmallLockupWithOrdinalItem, + } from '~/components/jet/item/SmallLockupWithOrdinalItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SmallLockupShelf; + + $: ({ isArticleContext = false } = shelf.presentationHints ?? {}); + $: itemHasOrdinal = shelf.items.some((item) => item.ordinal); + $: gridType = (() => { + if (itemHasOrdinal) { + return 'SmallLockupWithOrdinal'; + } + + if (isArticleContext) { + return 'Spotlight'; + } + + return 'SmallLockup'; + })(); +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout + {shelf} + {gridType} + rowsPerColumnOverride={gridType === 'SmallLockup' ? 3 : null} + let:item + > + {#if isSmallLockupWithOrdinalItem(item)} + <SmallLockupWithOrdinalItem {item} /> + {:else} + <SmallLockupItem {item} --margin-inline-end="16px" /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SmallStoryCardShelf.svelte b/src/components/jet/shelf/SmallStoryCardShelf.svelte new file mode 100644 index 0000000..c1a85ad --- /dev/null +++ b/src/components/jet/shelf/SmallStoryCardShelf.svelte @@ -0,0 +1,66 @@ +<script lang="ts" context="module"> + import type { Shelf, TodayCard } from '@jet-app/app-store/api/models'; + + interface SmallStoryCardShelf extends Shelf { + contentType: 'smallStoryCard'; + items: TodayCard[]; + } + + export function isSmallStoryCardShelf( + shelf: Shelf, + ): shelf is SmallStoryCardShelf { + const { contentType, items } = shelf; + return contentType === 'smallStoryCard' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import SmallStoryCardWithMediaItem, { + isSmallStoryCardWithMediaItem, + } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte'; + import SmallStoryCardWithArtworkItem, { + isSmallStoryCardWithArtworkItem, + } from '~/components/jet/item/SmallStoryCardWithArtworkItem.svelte'; + import SmallStoryCardWithMediaRiver, { + isSmallStoryCardWithMediaRiver, + } from '~/components/jet/item/SmallStoryCardWithMediaRiver.svelte'; + import SmallStoryCardWithMediaAppIcon, { + isSmallStoryCardWithMediaAppIcon, + } from '~/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte'; + import SmallStoryCardMediaBrandedSingleApp, { + isSmallStoryCardMediaBrandedSingleApp, + } from '~/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + + export let shelf: SmallStoryCardShelf; + + $: ({ isArticleContext = false } = shelf.presentationHints ?? {}); + $: gridType = (() => { + if (isArticleContext) { + return 'SmallStoryCard'; + } + + if (shelf.items.some(isSmallStoryCardWithArtworkItem)) { + return 'D'; + } + + return 'B'; + })(); +</script> + +<ShelfWrapper {shelf} withBottomPadding={!isArticleContext}> + <ShelfItemLayout {shelf} {gridType} let:item> + {#if isSmallStoryCardWithMediaRiver(item)} + <SmallStoryCardWithMediaRiver {item} /> + {:else if isSmallStoryCardWithMediaAppIcon(item)} + <SmallStoryCardWithMediaAppIcon {item} /> + {:else if isSmallStoryCardMediaBrandedSingleApp(item)} + <SmallStoryCardMediaBrandedSingleApp {item} /> + {:else if isSmallStoryCardWithMediaItem(item)} + <SmallStoryCardWithMediaItem {item} /> + {:else if isSmallStoryCardWithArtworkItem(item)} + <SmallStoryCardWithArtworkItem {item} /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/TitledParagraphShelf.svelte b/src/components/jet/shelf/TitledParagraphShelf.svelte new file mode 100644 index 0000000..41c1d74 --- /dev/null +++ b/src/components/jet/shelf/TitledParagraphShelf.svelte @@ -0,0 +1,118 @@ +<script lang="ts" context="module"> + import { + type Action, + type FlowAction, + type GenericPage, + type Shelf, + type TitledParagraph, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + interface TitledParagraphShelf extends Shelf { + items: [TitledParagraph]; + } + + interface VersionHistoryPage extends FlowAction { + page: 'versionHistory'; + pageData: GenericPage; + } + + export function isTitledParagraphShelf( + shelf: Shelf, + ): shelf is TitledParagraphShelf { + const { contentType, items } = shelf; + + return contentType === 'titledParagraph' && Array.isArray(items); + } + + function isVersionHistoryFlowAction( + action: Action, + ): action is VersionHistoryPage { + return isFlowAction(action) && action.page === 'versionHistory'; + } +</script> + +<script lang="ts"> + import { createEventDispatcher, type SvelteComponent } from 'svelte'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import TitledParagraphItem, { + isTitledParagraphItem, + } from '~/components/jet/item/TitledParagraphItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getJetPerform } from '~/jet'; + import { VERSION_HISTORY_MODAL_ID } from '~/utils/metrics'; + + const perform = getJetPerform(); + export let shelf: TitledParagraphShelf; + + let modalComponent: SvelteComponent; + let modalTriggerElement: HTMLElement | null = null; + + const { seeAllAction } = shelf; + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + const handleModalClose = () => modalComponent.close(); + const handleOpenModalClick = (e: Event) => { + modalTriggerElement = e.target as HTMLElement; + modalComponent?.showModal(); + perform(destination); + }; + + const destination = + seeAllAction && isVersionHistoryFlowAction(seeAllAction) + ? seeAllAction + : undefined; + + const pageData = destination?.pageData; +</script> + +<ShelfWrapper {shelf}> + <div slot="title" class="title-container"> + {#if shelf.title} + <button on:click={handleOpenModalClick}> + <ShelfTitle title={shelf.title} seeAllAction={destination} /> + </button> + {/if} + + {#if pageData} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleModalClose} + title={pageData.title || null} + subtitle={null} + targetId={VERSION_HISTORY_MODAL_ID} + > + <svelte:fragment slot="content"> + <ul> + {#each pageData.shelves as shelf} + {#each shelf.items || [] as item} + {#if isTitledParagraphItem(item)} + <li> + <TitledParagraphItem {item} /> + </li> + {/if} + {/each} + {/each} + </ul> + </svelte:fragment> + </ContentModal> + </Modal> + {/if} + </div> + + {#each shelf.items as item} + <TitledParagraphItem {item} /> + {/each} +</ShelfWrapper> + +<style> + .title-container { + display: flex; + justify-content: space-between; + padding-top: 16px; + padding-inline-end: var(--bodyGutter); + } +</style> diff --git a/src/components/jet/shelf/TodayCardShelf.svelte b/src/components/jet/shelf/TodayCardShelf.svelte new file mode 100644 index 0000000..e872112 --- /dev/null +++ b/src/components/jet/shelf/TodayCardShelf.svelte @@ -0,0 +1,187 @@ +<script lang="ts" context="module"> + import type { + Shelf, + TodayCard as TodayCardModel, + } from '@jet-app/app-store/api/models'; + + export interface TodayCardShelf extends Shelf { + contentType: 'todayCard'; + + items: TodayCardModel[]; + } + + export function isTodayCardShelf(shelf: Shelf): shelf is TodayCardShelf { + return shelf.contentType === 'todayCard' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import TodayCard from '~/components/jet/today-card/TodayCard.svelte'; + + import { getTodayCardLayoutConfiguration } from '~/context/today-card-layout'; + + export let shelf: TodayCardShelf; + + $: ({ + wrap: { shouldStretchFirstCard: shouldStretchFirstCardWrap }, + nowrap: { shouldStretchFirstCard: shouldStretchFirstCardNoWrap }, + } = getTodayCardLayoutConfiguration(shelf)); +</script> + +<ShelfWrapper {shelf}> + <div> + <div + class="today-card-row" + class:today-card-row__stretch-first-wrap={shouldStretchFirstCardWrap && + shelf.items.length >= 2} + class:today-card-row__stretch-first-nowrap={shouldStretchFirstCardNoWrap && + shelf.items.length >= 2} + class:today-card-row__stretch-last-wrap={!shouldStretchFirstCardWrap && + shelf.items.length >= 2} + class:today-card-row__stretch-last-nowrap={!shouldStretchFirstCardNoWrap && + shelf.items.length >= 2} + class:today-card-row__1-card={shelf.items.length == 1} + class:today-card-row__2-card={shelf.items.length == 2} + class:today-card-row__3-card={shelf.items.length == 3} + class:today-card-row__4-card={shelf.items.length >= 4} + > + {#each shelf.items.slice(0, 4) as card} + <div class="today-card-wrapper"> + <TodayCard {card} /> + </div> + {/each} + </div> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + @use 'amp/stylekit/core/mixins/browser-targets' as *; + + @mixin stretch-card($flex-shrink: 1) { + aspect-ratio: unset; + justify-self: stretch; + align-self: stretch; + width: auto; + flex-shrink: $flex-shrink; + flex-grow: 1; + } + + .today-card-row { + --card-default-width: 407px; + --card-default-height: 534px; + --card-row-gap: 16px; + min-width: min(var(--card-default-width), 100vw); + padding: 0 25px; + display: flex; + flex-direction: column; + gap: var(--card-row-gap); + + @media (--range-medium-up) { + padding: 0 40px; + flex-direction: row; + flex-wrap: wrap; + } + } + + .today-card-wrapper { + --artworkShadowInset: 0; + --afterShadowBorderRadius: 0px; + aspect-ratio: 3 / 4; + width: 100%; + flex-shrink: 0; + max-height: 600px; + min-height: 100px; + + > :global(a) { + display: block; + width: 100%; + height: 100%; + } + + @include target-safari { + @media screen and (760px <= width) { + height: 600px; + aspect-ratio: unset; + } + } + + @media (--range-medium-up) { + width: auto; + height: var(--card-default-height); + aspect-ratio: 3 / 4; + } + } + + @media (--range-medium-up) { + .today-card-row__1-card .today-card-wrapper { + @include stretch-card; + } + } + + @media (--range-medium-up) and (--range-large-down) { + .today-card-row__2-card { + &.today-card-row__stretch-first-wrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child { + @include stretch-card; + } + } + + .today-card-row__3-card { + .today-card-wrapper:first-child { + flex-basis: 100%; + + @include stretch-card(0); + } + + &.today-card-row__stretch-first-wrap + .today-card-wrapper:nth-child(2), + &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child { + @include stretch-card; + } + } + } + + @media (--range-medium-up) { + .today-card-row__4-card { + &.today-card-row__stretch-first-wrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-first-wrap .today-card-wrapper:last-child, + &.today-card-row__stretch-last-wrap + .today-card-wrapper:nth-child(2), + &.today-card-row__stretch-last-wrap + .today-card-wrapper:nth-child(3) { + flex-basis: calc( + 100% - var(--card-default-width) - var(--card-row-gap) + ); + + @include stretch-card; + } + } + } + + @media (--range-xlarge-up) { + .today-card-row__2-card { + &.today-card-row__stretch-first-nowrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-last-nowrap + .today-card-wrapper:last-child { + @include stretch-card; + } + } + + .today-card-row__3-card { + &.today-card-row__stretch-first-nowrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-last-nowrap + .today-card-wrapper:last-child { + @include stretch-card; + } + } + } +</style> diff --git a/src/components/jet/shelf/UberShelf.svelte b/src/components/jet/shelf/UberShelf.svelte new file mode 100644 index 0000000..6cdf004 --- /dev/null +++ b/src/components/jet/shelf/UberShelf.svelte @@ -0,0 +1,40 @@ +<script lang="ts" context="module"> + import type { Shelf, Uber } from '@jet-app/app-store/api/models'; + + interface UberShelf extends Shelf { + contentType: 'uber'; + items: [Uber]; + } + + export function isUberShelf(shelf: Shelf): shelf is UberShelf { + return shelf.contentType === 'uber' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: UberShelf; + + $: uber = shelf.items[0]; + $: artwork = uber.artwork; +</script> + +{#if artwork} + <ShelfWrapper withPaddingTop={false} withBottomPadding={false}> + <div class="artwork-container"> + <Artwork {artwork} profile="uber-shelf" /> + </div> + </ShelfWrapper> +{/if} + +<style> + .artwork-container { + border-bottom: 1px solid var(--systemQuaternary-onDark); + + @media (--range-xlarge-only) { + border: 1px solid var(--systemQuaternary-onDark); + } + } +</style> diff --git a/src/components/jet/today-card/TodayCard.svelte b/src/components/jet/today-card/TodayCard.svelte new file mode 100644 index 0000000..84d760f --- /dev/null +++ b/src/components/jet/today-card/TodayCard.svelte @@ -0,0 +1,401 @@ +<script lang="ts"> + import type { TodayCard } from '@jet-app/app-store/api/models'; + + import Artwork, { + type Profile, + getNaturalProfile, + } from '~/components/Artwork.svelte'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import TodayCardMedia from '~/components/jet/today-card/TodayCardMedia.svelte'; + import TodayCardOverlay from '~/components/jet/today-card/TodayCardOverlay.svelte'; + import { isTodayCardMediaList } from '~/components/jet/today-card/media/TodayCardMediaList.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + import { colorAsString } from '~/utils/color'; + import { bestBackgroundColor } from './background-color-utils'; + + export let card: TodayCard; + + /** + * When set to `true`, this component will not enable the `clickAction` provided by the + * `card` + * + * This can be useful on the "story" page, where the card will link back to the page + * currently being viewed + */ + export let suppressClickAction: boolean = false; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; + + let useProtectionLayer: boolean; + let useBlurryProtectionLayer: boolean; + let useGradientProtectionLayer: boolean; + let useListStyle: boolean; + let accentColor: string; + + $: ({ + heading, + title, + inlineDescription, + titleArtwork, + overlay, + media, + editorialDisplayOptions, + style = 'light', + clickAction, + } = card); + $: action = suppressClickAction ? undefined : clickAction; + + $: { + const isAppEvent = media?.kind === 'appEvent'; + const isList = !!media && isTodayCardMediaList(media); + + useListStyle = isList; + useProtectionLayer = + editorialDisplayOptions?.useTextProtectionColor || + editorialDisplayOptions?.useMaterialBlur || + false; + useBlurryProtectionLayer = useProtectionLayer && !isAppEvent && !isList; + useGradientProtectionLayer = useProtectionLayer && isAppEvent; + accentColor = colorAsString(bestBackgroundColor(card.media)); + } +</script> + +<!-- + We don't wrap the entire card with an action if there is an `overlay`, since the overlay has + it's own link / action (and we don't want nesting `a` tags, of course). +--> +<LinkWrapper action={overlay || useListStyle ? null : action}> + <div + class="today-card" + class:light={style === 'light'} + class:dark={style === 'dark'} + class:white={style === 'white'} + class:list={useListStyle} + class:with-overlay={overlay} + style:--today-card-accent-color={accentColor} + > + {#if media && !useListStyle} + <TodayCardMedia {media} {artworkProfile} /> + {/if} + + <div class="wrapper"> + <div + class="information-layer" + class:with-gradient={useGradientProtectionLayer} + class:with-action={!!action} + > + <LinkWrapper action={useListStyle ? null : action}> + <div class="content-container"> + {#if useBlurryProtectionLayer} + <div class="protection-layer" /> + {/if} + + <div class="title-container"> + {#if heading && !titleArtwork} + <p class="badge"> + <LineClamp clamp={1}> + {heading} + </LineClamp> + </p> + {/if} + + {#if titleArtwork} + <div class="title-artwork-container"> + <Artwork + artwork={titleArtwork} + profile={getNaturalProfile( + titleArtwork, + )} + /> + </div> + {/if} + + {#if title && !titleArtwork} + <h3 class="title"> + <LinkWrapper + action={useListStyle ? action : null} + > + {@html sanitizeHtml(title)} + </LinkWrapper> + </h3> + {/if} + + {#if inlineDescription} + <LineClamp clamp={2}> + <p class="description"> + {@html sanitizeHtml(inlineDescription)} + </p> + </LineClamp> + {/if} + </div> + </div> + </LinkWrapper> + + {#if overlay} + <div + class="overlay" + class:blur-only={!useProtectionLayer} + class:dark={useProtectionLayer && style !== 'dark'} + class:light={useProtectionLayer && style === 'dark'} + > + <TodayCardOverlay + {overlay} + buttonVariant={useProtectionLayer + ? 'transparent' + : 'dark-gray'} + --text-color="var(--today-card-text-color)" + --text-accent-color="var(--today-card-text-accent-color)" + --text-accent-blend-mode="var(--today-card-text-accent-blend-mode)" + /> + </div> + {/if} + </div> + </div> + + {#if media && useListStyle} + <TodayCardMedia {media} {artworkProfile} /> + {/if} + </div> +</LinkWrapper> + +<style lang="scss"> + @property --gradient-color { + syntax: '<color>'; + inherits: true; + initial-value: #000; + } + + .today-card { + --today-card-gutter: 16px; + --today-card-border-radius: var( + --border-radius, + var(--global-border-radius-large) + ); + --protection-layer-bottom-offset: 0px; + --gradient-color: var(--today-card-accent-color); + background-color: var(--today-card-accent-color); + position: relative; + display: flex; + align-items: end; + height: 100%; + overflow: hidden; + color: var(--today-card-text-color); + container-type: size; + container-name: today-card; + border-radius: var(--today-card-border-radius); + box-shadow: var(--shadow-small); + } + + .today-card.with-overlay { + --protection-layer-bottom-offset: 80px; + } + + .today-card.light, + .today-card.dark { + --today-card-text-color: rgb(255, 255, 255); + --today-card-text-accent-color: rgba(255, 255, 255, 0.56); + --today-card-text-accent-blend-mode: plus-lighter; + --today-card-background-tint-color: rgba(0, 0, 0, 0.18); + } + + .today-card.white { + --today-card-text-color: var(--systemPrimary-onLight); + --today-card-text-accent-color: rgba(0, 0, 0, 0.56); + --today-card-background-tint-color: rgba(255, 255, 255, 0.33); + --today-card-text-accent-blend-mode: revert; + } + + .today-card :global(.artwork-component) { + z-index: unset; + } + + .wrapper { + position: absolute; + display: flex; + width: 100%; + height: 100%; + } + + .content-container { + position: relative; + } + + .information-layer { + position: relative; + display: flex; + flex-direction: column; + justify-content: end; + align-self: flex-end; + width: 100%; + height: 100%; + border-radius: var(--today-card-border-radius); + overflow: hidden; + } + + .information-layer > :global(a) { + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: end; + } + + .information-layer.with-gradient { + // A smooth bottom-to-top gradient with an intermediate stop at 60% of the accent color's + // opacity to ease the hard transition. + --gradient-color-end-position: 22%; + --gradient-fade-end-position: 50%; + background: linear-gradient( + 0deg, + var(--gradient-color) var(--gradient-color-end-position), + color-mix(in srgb, var(--gradient-color) 60%, transparent) + calc( + ( + var(--gradient-color-end-position) + + var(--gradient-fade-end-position) + ) / 2 + ), + transparent var(--gradient-fade-end-position) + ); + transition: --accent-color-end 500ms ease-out, --fade-end 350ms ease-out, + --gradient-color 350ms ease-out; + } + + .information-layer.with-gradient.with-action:has(> a:hover) { + // Darkens the color used in the gradient on hover + --gradient-color: color-mix( + in srgb, + var(--today-card-accent-color) 93%, + black + ); + } + + @container today-card (aspect-ratio >= 16/9) { + .information-layer.with-gradient { + --accent-color-end: 30%; + } + } + + .protection-layer { + --brightness: 0.95; + position: absolute; + width: 100%; + // On cards with overlays (app lockups at the bottom), we increase the height of the + // protection layer and shift it downward the same amount, so it is aligned to bottom + // of the overlay. + height: calc(100% + var(--protection-layer-bottom-offset) + 60px); + bottom: calc(-1 * var(--protection-layer-bottom-offset)); + background: var(--today-card-background-tint-color); + backdrop-filter: blur(34px) brightness(var(--brightness)) saturate(1.6) + contrast(1.1); + mask-image: linear-gradient( + to top, + black 30%, + rgba(0, 0, 0, 0.75) 70%, + rgba(0, 0, 0, 0.4) 86%, + transparent 100% + ); + transition: backdrop-filter 210ms ease-in; + } + + .information-layer:has(> a:hover) .protection-layer { + --brightness: 0.88; + } + + .badge { + font: var(--callout-emphasized); + margin-bottom: 4px; + mix-blend-mode: var(--today-card-text-accent-blend-mode); + color: var(--today-card-text-accent-color); + } + + .title-container { + width: auto; + position: relative; + padding: 0 var(--today-card-gutter) var(--today-card-gutter); + } + + @container today-card (orientation: landscape) { + .title-artwork-container { + width: 33%; + min-width: 200px; + max-width: 300px; + padding-bottom: 8px; + } + } + + @container today-card (orientation: portrait) { + .title-artwork-container { + max-width: 75%; + padding-bottom: 8px; + } + } + + .title { + font: var(--header-emphasized); + color: var(--today-card-text-color); + text-wrap: pretty; + } + + .description { + font: var(--body); + padding-top: calc(var(--today-card-gutter) / 2); + mix-blend-mode: var(--today-card-text-accent-blend-mode); + color: var(--today-card-text-accent-color); + text-wrap: pretty; + z-index: 1; + position: relative; + } + + .overlay { + z-index: 1; + position: relative; + padding: var(--today-card-gutter); + } + + .overlay.blur-only { + backdrop-filter: blur(50px); + } + + .overlay.light { + background-image: linear-gradient(rgba(225, 225, 225, 0.15) 0 0); + } + + .overlay.dark { + background-image: linear-gradient(rgba(0, 0, 0, 0.15) 0 0); + } + + .list { + background: var(--systemPrimary-onDark); + padding: var(--today-card-gutter) 0; + width: 100%; + flex-direction: column; + + @media (prefers-color-scheme: dark) { + --title-color: var(--systemPrimary); + background: var(--systemQuaternary); + + .title { + --today-card-text-color: var(--systemPrimary); + } + + .badge { + --today-card-text-accent-color: var(--systemSecondary); + } + } + } + + .list .wrapper { + position: relative; + height: auto; + width: 100%; + } + + .list .information-layer { + padding-top: 0; + } +</style> diff --git a/src/components/jet/today-card/TodayCardMedia.svelte b/src/components/jet/today-card/TodayCardMedia.svelte new file mode 100644 index 0000000..99f444f --- /dev/null +++ b/src/components/jet/today-card/TodayCardMedia.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import type { TodayCardMedia } from '@jet-app/app-store/api/models'; + + import type { Profile } from '~/components/Artwork.svelte'; + import TodayCardMediaAppEvent, { + isTodayCardMediaAppEvent, + } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte'; + import TodayCardMediaAppIcon, { + isTodayCardMediAppIcon, + } from '~/components/jet/today-card/media/TodayCardMediaAppIcon.svelte'; + import TodayCardMediaBrandedSingleApp, { + isTodayCardMediaBrandedSingleApp, + } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte'; + import TodayCardMediaList, { + isTodayCardMediaList, + } from '~/components/jet/today-card/media/TodayCardMediaList.svelte'; + import TodayCardMediaRiver, { + isTodayCardMediaRiver, + } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte'; + import TodayCardMediaWithArtwork, { + isTodayCardMediaWithArtwork, + } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo, { + isTodayCardMediaVideo, + } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + + export let media: TodayCardMedia; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; +</script> + +{#if isTodayCardMediaAppEvent(media)} + <TodayCardMediaAppEvent {media} {artworkProfile} /> +{:else if isTodayCardMediAppIcon(media)} + <TodayCardMediaAppIcon {media} /> +{:else if isTodayCardMediaBrandedSingleApp(media)} + <TodayCardMediaBrandedSingleApp {media} {artworkProfile} /> +{:else if isTodayCardMediaList(media)} + <TodayCardMediaList {media} /> +{:else if isTodayCardMediaWithArtwork(media)} + <TodayCardMediaWithArtwork {media} {artworkProfile} /> +{:else if isTodayCardMediaRiver(media)} + <TodayCardMediaRiver {media} /> +{:else if isTodayCardMediaVideo(media)} + <TodayCardMediaVideo {media} {artworkProfile} /> +{/if} diff --git a/src/components/jet/today-card/TodayCardOverlay.svelte b/src/components/jet/today-card/TodayCardOverlay.svelte new file mode 100644 index 0000000..4e3c405 --- /dev/null +++ b/src/components/jet/today-card/TodayCardOverlay.svelte @@ -0,0 +1,48 @@ +<script lang="ts" context="module"> + import type { + TodayCardOverlay, + TodayCardLockupOverlay, + } from '@jet-app/app-store/api/models'; + + export function isLockupOverlay( + overlay: TodayCardOverlay, + ): overlay is TodayCardLockupOverlay { + return overlay.kind === 'lockup'; + } +</script> + +<script lang="ts"> + import TodayCardLockupListOverlay, { + isLockupListOverlay, + } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + + export let overlay: TodayCardOverlay; + export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'transparent'; +</script> + +{#if isLockupOverlay(overlay)} + <div class="small-lockup-item-config"> + <SmallLockupItem + {buttonVariant} + item={overlay.lockup} + titleLineCount={1} + appIconProfile="app-icon" + /> + </div> +{:else if isLockupListOverlay(overlay)} + <TodayCardLockupListOverlay {overlay} /> +{/if} + +<style> + .small-lockup-item-config { + --title-color: var(--text-color, currentColor); + --subtitle-color: var(--text-accent-color, currentColor); + --linkColor: currentColor; + --eyebrow-color: var(--text-accent-color, currentColor); + --button-blend-mode: var(--text-accent-blend-mode); + --subtitle-blend-mode: var(--text-accent-blend-mode); + --eyebrow-blend-mode: var(--text-accent-blend-mode); + display: contents; + } +</style> diff --git a/src/components/jet/today-card/background-color-utils.ts b/src/components/jet/today-card/background-color-utils.ts new file mode 100644 index 0000000..c2c0fe6 --- /dev/null +++ b/src/components/jet/today-card/background-color-utils.ts @@ -0,0 +1,54 @@ +import { type Optional, isSome } from '@jet/environment/types/optional'; +import type { + Color, + TodayCardMedia, + TodayCardMediaWithArtwork, +} from '@jet-app/app-store/api/models'; + +import { isTodayCardMediaBrandedSingleApp } from './media/TodayCardMediaBrandedSingleApp.svelte'; +import { isTodayCardMediaAppEvent } from './media/TodayCardMediaAppEvent.svelte'; +import { isTodayCardMediaWithArtwork } from './media/TodayCardMediaWithArtwork.svelte'; + +const DEFAULT_COLOR: Color = { + type: 'named', + name: 'defaultBackground', +}; + +function getBackgroundFromMediaWithArtwork( + media: TodayCardMediaWithArtwork, +): Optional<Color> { + return ( + media.videos[0]?.preview.backgroundColor ?? + media.artworks[0]?.backgroundColor + ); +} + +/** + * Onyx App Store alternative to the `bestBackgroundColor` method that exists on + * the {@linkcode TodayCardMedia} type + * + * This is necessary because the functions on those class instances are not + * carried over to the client when serializing the view-model, making them + * impossible to call in a consistent way from our codebase + */ +export function bestBackgroundColor(media: Optional<TodayCardMedia>): Color { + if (isSome(media)) { + if (isTodayCardMediaAppEvent(media)) { + return media.tintColor; + } + + if (isTodayCardMediaBrandedSingleApp(media)) { + return ( + getBackgroundFromMediaWithArtwork(media) ?? + media.icon.backgroundColor ?? + DEFAULT_COLOR + ); + } + + if (isTodayCardMediaWithArtwork(media)) { + return getBackgroundFromMediaWithArtwork(media) ?? DEFAULT_COLOR; + } + } + + return DEFAULT_COLOR; +} diff --git a/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte new file mode 100644 index 0000000..1faa933 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaAppEvent, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaAppEvent( + media: TodayCardMedia, + ): media is TodayCardMediaAppEvent { + return media.kind === 'appEvent'; + } +</script> + +<script lang="ts"> + import type { Profile } from '~/components/Artwork.svelte'; + import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + + export let media: TodayCardMediaAppEvent; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; +</script> + +<div class="event-container"> + <span class="time-container"> + <AppEventDate formattedDates={media.formattedDates} /> + </span> + + <div class="artwork-container"> + {#if media.videos.length > 0} + <TodayCardMediaVideo {media} {artworkProfile} /> + {:else if media.artworks.length > 0} + <TodayCardMediaWithArtwork {media} {artworkProfile} /> + {/if} + </div> +</div> + +<style> + .event-container { + --today-card-border-width: 4px; + border: var(--today-card-border-width) solid + var(--today-card-accent-color); + border-radius: var(--today-card-border-radius); + position: relative; + aspect-ratio: 0.75; + width: 100%; + height: 100%; + overflow: hidden; + } + + @container (orientation: landscape) { + .event-container { + aspect-ratio: 16/9; + } + } + + .artwork-container { + height: 100%; + border-radius: calc( + var(--today-card-border-radius) - var(--today-card-border-width) + ); + } + + .time-container :global(time), + .time-container :global(span) { + background: var(--today-card-accent-color); + border-end-end-radius: var(--today-card-border-radius); + font: var(--headline); + padding: 6px 10px 6px 8px; + position: absolute; + top: 0; + z-index: 3; + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte new file mode 100644 index 0000000..a6db985 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte @@ -0,0 +1,62 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaAppIcon, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediAppIcon( + media: TodayCardMedia, + ): media is TodayCardMediaAppIcon { + return media.kind === 'appIcon'; + } +</script> + +<script lang="ts"> + import AppIcon from '~/components/AppIcon.svelte'; + import { colorAsString } from '~/utils/color'; + + export let media: TodayCardMediaAppIcon; + + $: backgroundColor = media.icon.backgroundColor + ? colorAsString(media.icon.backgroundColor) + : null; +</script> + +<div class="container" style:--background-color={backgroundColor}> + <div class="artwork-container"> + <AppIcon + icon={media.icon} + profile="app-icon-xlarge" + fixedWidth={false} + /> + </div> +</div> + +<style> + .container { + height: 100%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--background-color); + border-radius: var(--today-card-border-radius); + } + + .artwork-container { + width: 50%; + height: 50%; + } + + @container (orientation: landscape) { + .container { + align-items: start; + padding-top: 5%; + } + + .artwork-container { + width: 30%; + height: 30%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte new file mode 100644 index 0000000..dfdaa0f --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte @@ -0,0 +1,41 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaBrandedSingleApp, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaBrandedSingleApp( + media: TodayCardMedia, + ): media is TodayCardMediaBrandedSingleApp { + return media.kind === 'brandedSingleApp'; + } +</script> + +<script lang="ts"> + import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + import type { Profile } from '~/components/Artwork.svelte'; + + export let media: TodayCardMediaBrandedSingleApp; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; + + // There is a small but non-zero set of old legacy Today Cards that can appear on the Today page, + // and those cards have their safe area on the left side of the artwork, rather than the center, + // like all the modern artwork. For those cases, we pin the artwork to the left edge of the card. + $: pinnedToLeft = + media.artworkLayoutsWithMetrics[0].ltr.collapsedLayoutInsets.left < 0; +</script> + +{#if media.videos.length > 0} + <TodayCardMediaVideo {media} {artworkProfile} /> +{:else if media.artworks.length > 0} + <TodayCardMediaWithArtwork + {media} + {artworkProfile} + pinArtworkToLeft={pinnedToLeft} + /> +{/if} diff --git a/src/components/jet/today-card/media/TodayCardMediaList.svelte b/src/components/jet/today-card/media/TodayCardMediaList.svelte new file mode 100644 index 0000000..00f8688 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaList.svelte @@ -0,0 +1,86 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaList, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaList( + media: TodayCardMedia, + ): media is TodayCardMediaList { + return media.kind === 'list'; + } +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + + export let media: TodayCardMediaList; + + let container: HTMLDivElement; + let fadeTop = '0%'; + let fadeBottom = '0%'; + + function calculateFadeAmounts() { + const { scrollTop, scrollHeight, clientHeight } = container; + + fadeTop = scrollTop > 0 ? '10%' : `${scrollTop}%`; + fadeBottom = scrollTop + clientHeight < scrollHeight - 1 ? '15%' : '0%'; + } + + onMount(() => { + calculateFadeAmounts(); + container.addEventListener('scroll', calculateFadeAmounts); + + return () => + container.removeEventListener('scroll', calculateFadeAmounts); + }); +</script> + +<div + class="container" + style:--fade-top-size={fadeTop} + style:--fade-bottom-size={fadeBottom} + bind:this={container} +> + <ul> + {#each media.lockups as item} + <li> + <SmallLockupItem {item} /> + </li> + {/each} + </ul> +</div> + +<style> + @property --fade-top-size { + syntax: '<percentage>'; + inherits: false; + initial-value: 0%; + } + + @property --fade-bottom-size { + syntax: '<percentage>'; + inherits: false; + initial-value: 0%; + } + + .container { + width: 100%; + overflow: scroll; + padding: 0 var(--today-card-gutter); + mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--fade-top-size), + black calc(100% - var(--fade-bottom-size)), + transparent 100% + ); + transition: --fade-top-size 105ms cubic-bezier(0.5, 1, 0.89, 1), + --fade-bottom-size 420ms cubic-bezier(0.45, 0, 0.55, 1); + } + + li { + margin-bottom: 16px; + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaRiver.svelte b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte new file mode 100644 index 0000000..d3f9666 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaRiver, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaRiver( + media: TodayCardMedia, + ): media is TodayCardMediaRiver { + return media.kind === 'river'; + } +</script> + +<script lang="ts"> + import { + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + + /** + * The actual properties of {@linkcode TodayCardMediaRiver} that are required + * to render this component + */ + type TodayCardMediaRiverRequirements = Pick<TodayCardMediaRiver, 'lockups'>; + + export let media: TodayCardMediaRiverRequirements; + + $: icons = media.lockups.map((lockup) => lockup.icon); + $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks( + icons, + { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + }, + ); +</script> + +<div class="container" style={backgroundGradientCssVars}> + {#if icons.length} + <AppIconRiver {icons} /> + {/if} +</div> + +<style> + .container { + --app-icon-river-icon-width: 96px; + height: 100%; + width: 100%; + padding-top: 10%; + overflow: hidden; + border-radius: var(--today-card-border-radius); + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) 20%, + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) 40%, + transparent 80% + ), + radial-gradient( + circle at 140% -50%, + var(--top-right, #000) 60%, + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) 50%, + transparent 100% + ); + + @media (--range-small-only) { + padding-top: 5%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaVideo.svelte b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte new file mode 100644 index 0000000..f2524c6 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte @@ -0,0 +1,72 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaVideo, + Video as VideoModel, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaVideo( + media: TodayCardMedia, + ): media is TodayCardMediaVideo { + return ( + media.kind === 'video' || + (media.kind === 'artwork' && 'videos' in media) + ); + } +</script> + +<script lang="ts"> + import mediaQueries from '~/utils/media-queries'; + import type { Profile as ArtworkProfile } from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import { colorAsString } from '~/utils/color'; + + export let media: TodayCardMediaVideo; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: ArtworkProfile | undefined = undefined; + + let videoToDisplay: VideoModel | undefined; + $: videoToDisplay = media.videos[0]; + + let profile: ArtworkProfile; + $: profile = + artworkProfile ?? + ($mediaQueries === 'small' ? 'card' : 'card-horizontal'); + $: backgroundColor = videoToDisplay?.preview.backgroundColor + ? colorAsString(videoToDisplay?.preview.backgroundColor) + : null; +</script> + +{#if videoToDisplay} + <div class="video-wrapper" style:--background-color={backgroundColor}> + <Video + autoplay + loop + {profile} + useControls={false} + video={videoToDisplay} + /> + </div> +{/if} + +<style> + .video-wrapper { + background: black; + aspect-ratio: 3/4; + width: 100%; + position: relative; + overflow: hidden; + border-radius: var(--today-card-border-radius); + background-color: var(--background-color); + } + + @container (orientation: landscape) { + .video-wrapper { + aspect-ratio: 16/9; + height: 100%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte new file mode 100644 index 0000000..e604708 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte @@ -0,0 +1,100 @@ +<script lang="ts" context="module"> + import type { + Artwork as ArtworkModel, + TodayCardMedia, + TodayCardMediaWithArtwork, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaWithArtwork( + media: TodayCardMedia, + ): media is TodayCardMediaWithArtwork { + return ( + media.kind === 'artwork' && + 'artworks' in media && + Array.isArray(media.artworks) && + media.artworks.length > 0 + ); + } +</script> + +<script lang="ts"> + import Artwork, { + type Profile as ArtworkProfile, + } from '~/components/Artwork.svelte'; + + export let media: TodayCardMediaWithArtwork; + + export let pinArtworkToLeft: boolean = false; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: ArtworkProfile | undefined = undefined; + + let artworkToDisplay: ArtworkModel; + // Today Card artwork comes back from Jet with a width of 800px, even though the source artwork + // is _much_ larger. The shared `Artwork` component doesn't let us render an image beyond the + // artwork's `width` and `height` properties, and we absolutely need to render these images + // larger than 800px wide, so we are forcing these new upper bounds for the artworks dimensions. + // Eventually, we should rethink this and have the proper dimensions come back from Jet: + // rdar://148730199 (Bigger images for TodayCard) + $: artworkToDisplay = Object.assign({}, media.artworks[0], { + width: 3840, + height: 2160, + }); +</script> + +{#if artworkProfile} + <Artwork profile={artworkProfile} artwork={artworkToDisplay} /> +{:else} + <div class="wrapper"> + <div class="artwork-container portrait"> + <Artwork profile="card" artwork={artworkToDisplay} /> + </div> + + <div + class="artwork-container landscape" + class:pinned-to-left={pinArtworkToLeft} + > + <Artwork profile="card-horizontal" artwork={artworkToDisplay} /> + </div> + </div> +{/if} + +<style> + .wrapper, + .artwork-container { + height: 100%; + width: 100%; + } + + .wrapper .artwork-container :global(.artwork-component), + .wrapper .artwork-container :global(img) { + object-fit: cover; + height: 100%; + } + + .pinned-to-left { + --artwork-override-object-position: left; + } + + @container (orientation: landscape) { + .artwork-container.landscape { + display: block; + } + + .artwork-container.portrait { + display: none; + } + } + + @container (orientation: portrait) { + .artwork-container.landscape { + display: none; + } + + .artwork-container.portrait { + display: block; + } + } +</style> diff --git a/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte new file mode 100644 index 0000000..1e7d297 --- /dev/null +++ b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte @@ -0,0 +1,42 @@ +<script lang="ts" context="module"> + import type { + TodayCardOverlay, + TodayCardLockupListOverlay, + } from '@jet-app/app-store/api/models'; + + export function isLockupListOverlay( + overlay: TodayCardOverlay, + ): overlay is TodayCardLockupListOverlay { + return overlay.kind === 'lockupList'; + } +</script> + +<script lang="ts"> + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let overlay: TodayCardLockupListOverlay; +</script> + +<div class="lockup-list"> + {#each overlay.lockups as lockup} + <LinkWrapper action={lockup.clickAction}> + <AppIcon icon={lockup.icon} /> + </LinkWrapper> + {/each} +</div> + +<style> + .lockup-list { + display: flex; + gap: 12px; + + @media (--range-xsmall-only) and (--sidebar-visible) { + gap: 10px; + } + + @media (--range-small-up) { + gap: 16px; + } + } +</style> diff --git a/src/components/jet/web-navigation/CategoryTabItem.svelte b/src/components/jet/web-navigation/CategoryTabItem.svelte new file mode 100644 index 0000000..61f2570 --- /dev/null +++ b/src/components/jet/web-navigation/CategoryTabItem.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import Item from '@amp/web-app-components/src/components/Navigation/Item.svelte'; + import ItemContent from '@amp/web-app-components/src/components/Navigation/ItemContent.svelte'; + + const dispatch = createEventDispatcher(); + + export let item: any; + export let selected: boolean = false; + export let translateFn: (key: string) => string; + $$props; // lets the other props automatically passed to navigation item components enter without being delcared explicitly + + const itemClicked = (): void => { + dispatch('selectItem', item); + }; + + $: backgroundImage = item.artwork + ? buildSrc( + item.artwork.template, + { + crop: 'bb', + width: 40, + height: 40, + fileType: 'webp', + }, + {}, + ) + : undefined; +</script> + +<Item {item} {selected} {translateFn}> + <!-- svelte-ignore a11y-click-events-have-key-events --> + <!-- svelte-ignore a11y-no-static-element-interactions --> + <a + href={item.url} + class="navigation-item__link" + role="button" + aria-pressed={selected} + on:click|preventDefault={itemClicked} + > + <ItemContent label={item.label}> + <div + slot="icon" + aria-hidden={true} + class="icon" + style:--background-image={`url(${backgroundImage})`} + /> + </ItemContent> + </a> +</Item> + +<style> + .icon { + display: flex; + align-self: center; + width: 20px; + height: 20px; + background: var(--keyColor); + mask: var(--background-image) center / contain no-repeat; + + @media (--sidebar-visible) { + width: 18px; + height: 18px; + } + } +</style> diff --git a/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte new file mode 100644 index 0000000..f0fe666 --- /dev/null +++ b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte @@ -0,0 +1,88 @@ +<script lang="ts"> + import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation'; + + import SFSymbol from '~/components/SFSymbol.svelte'; + import PlatformSelectorItem from '~/components/jet/web-navigation/PlatformSelectorItem.svelte'; + import { getI18n } from '~/stores/i18n'; + import Menu from '~/components/Menu.svelte'; + import { getJet } from '~/jet'; + + export let platformSelectors: WebNavigationLink[]; + + const i18n = getI18n(); + const jet = getJet(); + + $: activeSelector = platformSelectors.find((selector) => selector.isActive); + + const handleShowMenu = () => { + jet.recordCustomMetricsEvent({ + eventType: 'click', + actionType: 'open', + targetType: 'button', + targetId: 'PlatformSelector', + }); + }; +</script> + +{#if activeSelector} + <nav> + <Menu options={platformSelectors} forcedXPosition={25} {handleShowMenu}> + <svelte:fragment slot="trigger"> + <span + class="platform-selector-text" + id="platform-selector-text" + aria-labelledby="app-store-icon-contianer platform-selector-text" + aria-haspopup="menu" + > + {$i18n.t( + 'ASE.Web.AppStore.Navigation.PlatformSelectorText', + { + platform: activeSelector.action.title, + }, + )} + + <SFSymbol name="chevron.down" /> + </span> + </svelte:fragment> + + <svelte:fragment slot="option" let:option> + <PlatformSelectorItem platformSelector={option} /> + </svelte:fragment> + </Menu> + </nav> +{/if} + +<style> + nav { + --menu-item-padding: 0; + --menu-item-margin: 0 0 8px 0; + --menu-popover-padding: 8px; + --menu-common-padding: 8px; + --menu-trigger-padding: 0; + --menu-popover-background-color: var(--pageBg); + --menu-popover-box-shadow: 10px 10px 10px 0 + var(--systemQuaternary-onLight); + --menu-popover-border-radius: 14px; + --menu-popover-border: 1px solid var(--systemQuaternary); + --menu-popover-z-index: calc(var(--z-web-chrome) + 1); + } + + .platform-selector-text { + display: flex; + align-items: center; + gap: var(--platform-selector-trigger-gap, 4px); + font: var(--title-2); + white-space: nowrap; + } + + .platform-selector-text :global(svg) { + height: 0.7em; + position: relative; + top: 2px; + fill: var(--systemPrimary); + } + + nav :global(.menu-popover) { + width: 211px; + } +</style> diff --git a/src/components/jet/web-navigation/PlatformSelectorItem.svelte b/src/components/jet/web-navigation/PlatformSelectorItem.svelte new file mode 100644 index 0000000..9b72fda --- /dev/null +++ b/src/components/jet/web-navigation/PlatformSelectorItem.svelte @@ -0,0 +1,97 @@ +<script lang="ts"> + import { isSome } from '@jet/environment/types/optional'; + import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation'; + import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; + + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let platformSelector: WebNavigationLink; + + const i18n = getI18n(); + + $: ({ action, isActive } = platformSelector); + $: ({ artwork } = action); +</script> + +<FlowAction destination={action}> + <span class="platform-selector" class:is-active={isActive}> + {#if isSome(artwork) && isSystemImageArtwork(artwork)} + <div class="icon-container"> + <SystemImage {artwork} /> + </div> + {/if} + + <span + class="platform-title" + aria-label={$i18n.t( + 'ASE.Web.AppStore.Navigation.AX.PlatformSelectorItem', + { + platform: action.title, + }, + )} + > + {action.title} + </span> + + {#if action.destination && isSearchResultsPageIntent(action.destination)} + <span aria-hidden={true} class="search-icon-container"> + <SFSymbol name="magnifyingglass" /> + </span> + {/if} + </span> +</FlowAction> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + .platform-selector { + display: flex; + border-radius: var(--global-border-radius-medium); + padding: 8px; + margin-bottom: 4px; + gap: 10px; + transition: background-color 175ms ease-in; + } + + .platform-selector:not(.is-active):hover { + background-color: rgba(45, 45, 45, 0.035); + + @media (prefers-color-scheme: dark) { + background-color: rgba(45, 45, 45, 0.35); + } + } + + .platform-selector.is-active { + background-color: var(--systemQuinary); + } + + .icon-container { + display: flex; + justify-content: center; + padding-inline-end: 2px; + } + + .icon-container :global(svg) { + max-height: 16px; + width: 23px; + } + + .search-icon-container { + display: flex; + } + + .search-icon-container :global(svg) { + fill: var(--systemSecondary); + width: 16px; + } + + .platform-title { + font: var(--body); + flex-grow: 1; + } +</style> diff --git a/src/components/navigation/Navigation.svelte b/src/components/navigation/Navigation.svelte new file mode 100644 index 0000000..0114d4c --- /dev/null +++ b/src/components/navigation/Navigation.svelte @@ -0,0 +1,423 @@ +<script lang="ts"> + import { writable } from 'svelte/store'; + import { isSome } from '@jet/environment/types/optional'; + import type { + WebNavigation, + WebNavigationLink, + } from '@jet-app/app-store/api/models/web-navigation'; + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; + + import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + + import AppStoreLogo from '~/components/icons/AppStoreLogo.svg'; + import PlatformSelectorDropdown from '~/components/jet/web-navigation/PlatformSelectorDropdown.svelte'; + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import SearchInput from '~/components/navigation/SearchInput.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + + import { getJetPerform } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + import { + type NavigationItemWithTab, + navigationIdFromLink, + makeNavLinks, + } from '~/components/navigation/navigation-items'; + import mediaQueries from '~/utils/media-queries'; + + import { fade, type EasingFunction } from 'svelte/transition'; + import { circOut } from 'svelte/easing'; + import { flyAndBlur } from '~/utils/transition'; + import { makeCategoryTabsIntent } from '@jet-app/app-store/api/intents/category-tabs-intent'; + import { getJet } from '~/jet'; + import { getPlatformFromPage } from '~/utils/seo/common'; + import type { NavigationId } from '@amp/web-app-components/src/types'; + + const i18n = getI18n(); + const perform = getJetPerform(); + const jet = getJet(); + + const categoryTabsCache: Record<string, WebNavigationLink[]> = {}; + let categoryTabLinks: WebNavigationLink[] = []; + let currentTabStore = writable<NavigationId | null>(null); + + export let webNavigation: WebNavigation; + + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: searchAction = webNavigation.searchAction as WebSearchFlowAction; + // Mobile first means the inline items are hidden + // However, we still want the list visible in SSR (which is fine for mobile + // since the menu won't be expanded by default) + $: inlinePlatformItems = + isXSmallViewport || typeof window === 'undefined' + ? webNavigation.platforms + : []; + + $: if (webNavigation && typeof window !== 'undefined') { + fetchCategoryTabs(webNavigation); + } + + async function fetchCategoryTabs(nav: WebNavigation) { + const platform = getPlatformFromPage({ + webNavigation: nav, + }); + + if (!platform) { + categoryTabLinks = []; + return; + } + + if (categoryTabsCache[platform]) { + categoryTabLinks = updateActiveStates(categoryTabsCache[platform]); + } else { + try { + const data = await jet.dispatch( + makeCategoryTabsIntent({ + platform, + }), + ); + + categoryTabsCache[platform] = data; + categoryTabLinks = updateActiveStates(data); + } catch (error) { + categoryTabLinks = []; + } + } + + updateCurrentTab(); + } + + function updateActiveStates( + tabs: WebNavigationLink[], + ): WebNavigationLink[] { + return tabs.map((link) => ({ + ...link, + isActive: link.action?.destination?.id + ? window.location.pathname.includes(link.action.destination.id) + : false, + })); + } + + function updateCurrentTab() { + const allLinks: WebNavigationLink[] = [ + ...categoryTabLinks, + ...webNavigation.tabs, + ]; + + const activeLink = allLinks.find((link) => link.isActive); + currentTabStore.set( + activeLink ? navigationIdFromLink(activeLink) : null, + ); + } + + function handleMenuItemClick(event: CustomEvent<NavigationItemWithTab>) { + const navigationItem = event.detail; + const tab = navigationItem.tab; + + perform(tab.action); + } + + const BASE_DELAY = 80; + const BASE_DURATION = 150; + const DURATION_SPREAD = 300; + + // Returns an eased duration for a list item based on its index, e.g. items later in the list + // get longer durations, between BASE_DURATION and BASE_DURATION + DURATION_SPREAD. + function getEasedDuration({ + i, + totalNumberOfItems, + easing = circOut, + }: { + i: number; + totalNumberOfItems: number; + easing?: EasingFunction; + }) { + const t = i / (totalNumberOfItems - 1); + return BASE_DURATION + easing(t) * DURATION_SPREAD; + } +</script> + +<div class="navigation-wrapper"> + <Navigation + translateFn={$i18n.t} + items={makeNavLinks(webNavigation.tabs, { + shouldShowSearchTab: $sidebarIsHidden, + })} + personalizedItemsHeader={$i18n.t( + 'ASE.Web.AppStore.Navigation.Categories.Title', + )} + personalizedItems={makeNavLinks(categoryTabLinks, { + shouldShowSearchTab: $sidebarIsHidden, + })} + currentTab={currentTabStore} + libraryItems={[]} + on:menuItemClick={handleMenuItemClick} + > + <div slot="logo" class="platform-selector-container"> + <span + id="app-store-icon-contianer" + class="app-store-icon-container" + role="img" + aria-label={$i18n.t( + 'ASE.Web.AppStore.Navigation.AX.AppStoreLogo', + )} + > + <AppStoreLogo focusable={false} /> + </span> + + {#if !$sidebarIsHidden && !isXSmallViewport} + <PlatformSelectorDropdown + platformSelectors={webNavigation.platforms} + /> + {/if} + </div> + + <svelte:fragment slot="search"> + <div class="search-input-container"> + <SearchInput {searchAction} /> + </div> + </svelte:fragment> + + <div slot="after-navigation-items" class="platform-selector-inline"> + {#if isXSmallViewport} + <h3 in:fade out:fade={{ delay: 250, duration: BASE_DURATION }}> + {$i18n.t('ASE.Web.AppStore.Navigation.PlatformHeading')} + </h3> + {/if} + + <ul> + {#each inlinePlatformItems as platformSelector, i (platformSelector.action.title)} + {@const { action, isActive } = platformSelector} + {@const artwork = action.artwork} + {@const totalNumberOfItems = inlinePlatformItems.length} + <li + in:flyAndBlur={{ + y: -50, + delay: i * BASE_DELAY, + duration: getEasedDuration({ + i, + totalNumberOfItems, + }), + }} + out:flyAndBlur={{ + y: i * -5, + delay: + // This delay is calculated in a negative/backwards manner, + // which makes it so the items build out from the bottom to the top. + (totalNumberOfItems - i - 1) * (BASE_DELAY / 2), + duration: BASE_DURATION, + }} + > + <FlowAction destination={action}> + <span class="platform" class:is-active={isActive}> + {#if isSome(artwork) && isSystemImageArtwork(artwork)} + <div + class="icon-container" + aria-hidden="true" + > + <SystemImage {artwork} /> + </div> + {/if} + + <span class="platform-title"> + {action.title} + </span> + + {#if action.destination && isSearchResultsPageIntent(action.destination)} + <span + aria-hidden={true} + class="search-icon-container" + > + <SFSymbol name="magnifyingglass" /> + </span> + {/if} + </span> + </FlowAction> + </li> + {/each} + </ul> + </div> + </Navigation> +</div> + +<style lang="scss"> + .navigation-wrapper { + display: contents; + } + + .platform-selector-container { + --header-gap: 3px; + --platform-selector-trigger-gap: var(--header-gap); + display: flex; + gap: var(--header-gap); + position: relative; + + @media (--sidebar-visible) { + padding: 19px 25px 14px; + } + } + + // Japanese and Catalonian both require scaling down the platform selector in order to make it + // fit cleanly in the sidebar, due to their longer character lengths. + .platform-selector-container:lang(ja), + .platform-selector-container:lang(ca) { + --scale-factor: 0.1; + z-index: 3; + transform: scale(calc(1 - var(--scale-factor))); + transform-origin: center left; + + & :global(dialog) { + top: 60px; + // Since the `dialog` is a child of `platform-selector-container, we re-scale it back + // to it's original size by applying the inverse scale transformation. + transform: scale(calc(1 + var(--scale-factor))); + transform-origin: center left; + } + } + + .app-store-icon-container { + display: flex; + align-items: center; + gap: var(--header-gap); + font: var(--title-1); + font-weight: 600; + } + + .app-store-icon-container :global(svg) { + height: 18px; + position: relative; + top: 0.33px; + width: auto; + + @media (--sidebar-visible) and (--range-xsmall-only) { + height: 22px; + width: auto; + } + } + + .search-input-container { + margin: 0 25px; + } + + .navigation-wrapper :global(.navigation__header) { + @media (--sidebar-visible) { + display: flex; + flex-direction: column; + } + } + + .navigation-wrapper :global(.navigation-item__link) { + height: 100%; + display: flex; + } + + .navigation-wrapper :global(.navigation-item__icon) { + --navigation-item-icon-size: 32px; + width: var(--navigation-item-icon-size); + height: var(--navigation-item-icon-size); + display: flex; + justify-content: center; + + @media (--sidebar-visible) { + --navigation-item-icon-size: 24px; + } + } + + // Our SVG icons for the landing pages are sized differently than other Onyx apps, + // so we have to reach into the navigation component and style them so they look + // visually similar to the other Onyx apps + .navigation-wrapper :global(.navigation-item__icon svg) { + color: var(--keyColor); + width: 20px; + + @media (--sidebar-visible) { + width: 18px; + } + } + + // Below is styling for the "inline" version of the Platform Selector + .platform-selector-inline { + margin: 8px 32px; + } + + ul { + display: flex; + flex-direction: column; + gap: 5px; + } + + h3 { + color: var(--systemTertiary); + font: var(--body-emphasized); + margin: 0 0 10px; + padding-top: 20px; + + @media (--sidebar-visible) { + font: var(--footnote-emphasized); + margin: 0 0 6px; + padding-top: 7px; + } + } + + .platform { + display: flex; + gap: 10px; + padding: 8px 0; + color: var(--systemTertiary); + + @media (prefers-color-scheme: dark) { + color: var(--systemSecondary); + } + } + + .platform, + .platform :global(svg) { + transition: color 210ms ease-out; + } + + .platform:not(.is-active):hover, + .platform:not(.is-active):hover :global(svg) { + color: var(--systemPrimary); + } + + .platform.is-active { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .platform.is-active :global(svg) { + color: currentColor; + } + + .icon-container { + display: flex; + } + + .icon-container :global(svg) { + color: var(--systemTertiary); + width: 18px; + max-height: 16px; + + @media (prefers-color-scheme: dark) { + color: var(--systemSecondary); + } + } + + .search-icon-container { + display: flex; + } + + .search-icon-container :global(svg) { + fill: var(--systemSecondary); + width: 16px; + } + + .platform-title { + font: var(--body); + flex-grow: 1; + } +</style> diff --git a/src/components/navigation/SearchInput.svelte b/src/components/navigation/SearchInput.svelte new file mode 100644 index 0000000..a04fa4b --- /dev/null +++ b/src/components/navigation/SearchInput.svelte @@ -0,0 +1,82 @@ +<script lang="ts" context="module"> + import type { ComponentProps } from 'svelte'; + import { writable } from 'svelte/store'; + + import SearchInput from '@amp/web-app-components/src/components/SearchInput/SearchInput.svelte'; + + type UnusedSearchInputProps = Pick< + ComponentProps<SearchInput>, + 'currentTab' | 'menuItem' + >; + + // `SearchInput` requires a bunch of properties that are unnecessary + // for our use-case; they're defined here to keep them grouped together + const UNNEEDED_SEARCH_INPUT_PROPS: UnusedSearchInputProps = { + currentTab: writable(null), + menuItem: { + id: { type: 'search' }, + // @ts-expect-error the `menuItem` is not relevant to us; we don't + // need to provide an icon for this + icon: null, + }, + }; +</script> + +<script lang="ts"> + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + import { makeCanonicalSearchResultsPageUrl } from '@jet-app/app-store/common/search/search-page-url'; + + import { getJet } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); + const jet = getJet(); + + export let searchAction: WebSearchFlowAction; + export let big: boolean = false; + + function dispatchSearchAction(event: CustomEvent<{ term: string }>) { + const { term } = event.detail; + + searchAction.destination.term = term; + + searchAction.pageUrl = makeCanonicalSearchResultsPageUrl( + jet.objectGraph, + searchAction.destination, + ); + + jet.perform(searchAction); + } +</script> + +<div class="search-input-wrapper" class:big> + <SearchInput + {...UNNEEDED_SEARCH_INPUT_PROPS} + defaultValue={searchAction?.destination?.term} + translateFn={(key) => $i18n.t(key)} + on:makeSearchQueryFromInput={dispatchSearchAction} + /> +</div> + +<style> + .search-input-wrapper { + --searchBoxIconFill: var(--keyColor); + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + } + + .search-input-wrapper.big :global(.search-input__text-field) { + height: 48px; + padding-inline-start: 40px; + font: var(--title-2); + border-radius: 8px; + } + + .search-input-wrapper.big :global(.search-svg) { + width: 16px; + height: auto; + inset: 16px 0 0 13px; + } +</style> diff --git a/src/components/navigation/Skeleton.svelte b/src/components/navigation/Skeleton.svelte new file mode 100644 index 0000000..508e523 --- /dev/null +++ b/src/components/navigation/Skeleton.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import { writable } from 'svelte/store'; + + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + + import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte'; + import AppStoreLogo from '~/components/icons/AppStoreLogo.svg'; + import SearchInput from '~/components/navigation/SearchInput.svelte'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); + + $: searchAction = {} as WebSearchFlowAction; +</script> + +<div class="navigation-wrapper"> + <Navigation + translateFn={$i18n.t} + items={[]} + currentTab={writable(null)} + libraryItems={[]} + personalizedItems={[]} + > + <div slot="logo" class="platform-selector-container"> + <span class="app-store-icon-container"> + <AppStoreLogo /> + </span> + </div> + + <svelte:fragment slot="search"> + <div class="search-input-container"> + <SearchInput {searchAction} /> + </div> + </svelte:fragment> + </Navigation> +</div> + +<style lang="scss"> + .navigation-wrapper { + display: contents; + } + + .platform-selector-container { + @media (--sidebar-visible) { + padding: 19px 25px 14px; + } + } + + .app-store-icon-container { + display: flex; + align-items: center; + padding: 2px 0; + } + + .app-store-icon-container :global(svg) { + height: 18px; + position: relative; + top: 0.33px; + width: auto; + + @media (--sidebar-visible) and (--range-xsmall-only) { + height: 22px; + width: auto; + } + } + + .search-input-container { + margin: 0 25px; + } + + .navigation-wrapper :global(.navigation-item__link) { + height: 100%; + display: flex; + } + + .navigation-wrapper :global(.navigation-item__icon) { + --navigation-item-icon-size: 32px; + width: var(--navigation-item-icon-size); + height: var(--navigation-item-icon-size); + + @media (--sidebar-visible) { + --navigation-item-icon-size: 24px; + } + } +</style> diff --git a/src/components/navigation/navigation-items.ts b/src/components/navigation/navigation-items.ts new file mode 100644 index 0000000..8692765 --- /dev/null +++ b/src/components/navigation/navigation-items.ts @@ -0,0 +1,79 @@ +import { + isSome, + unwrapOptional as unwrap, +} from '@jet/environment/types/optional'; + +import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types'; +import type { NavigationId } from '@amp/web-app-components/src/types'; +import type { + WebNavigation, + WebNavigationLink, +} from '@jet-app/app-store/api/models/web-navigation'; + +import { + isSystemImageArtwork, + getIconNameFromTemplate, +} from '~/components/SystemImage.svelte'; +import { getIconComponentByName } from '../SFSymbol.svelte'; +import type { Artwork } from '@jet-app/app-store/api/models'; +import CategoryTabItem from '~/components/jet/web-navigation/CategoryTabItem.svelte'; + +/** + * A {@linkcode NavigationItem} that includes the original {@linkcode WebNavigationLink} + * it was defined from, which is needed for the + */ +export interface NavigationItemWithTab extends NavigationItem { + tab: WebNavigationLink; + artwork?: Artwork; + isActive?: boolean; +} + +export function navigationIdFromLink(link: WebNavigationLink): NavigationId { + const intent = unwrap(link.action.destination); + + return { + type: intent.$kind, + // `intent.$kind` will be unique for each `Intent` used here as they are + // each a different `LandingPageIntent` + resourceId: link.action.pageUrl ?? intent.$kind, + }; +} + +/** + * Transform the "tabs" in the `WebNavigation` into a shape that works with our + * shared navigation side-bar components. + */ +export function makeNavLinks( + tabs: WebNavigationLink[], + { shouldShowSearchTab = false }, +): NavigationItemWithTab[] { + return tabs + .filter((tab) => { + const isSearchTab = + tab.action?.destination?.['$kind'].includes('search_Intent'); + + // Allows for filtering our the search tab, which we use when the sidebar is visible, + // since there is a search input in the sidebar + return isSearchTab ? shouldShowSearchTab : true; + }) + .map((tab) => { + const { action, artwork: tabArtwork } = tab; + const { artwork } = action || {}; + const hasSystemImageArtwork = + isSome(artwork) && isSystemImageArtwork(artwork); + + return { + id: navigationIdFromLink(tab), + label: unwrap(tab.action.title), + url: action.pageUrl ?? undefined, + icon: hasSystemImageArtwork + ? getIconComponentByName( + getIconNameFromTemplate(artwork.template), + ) + : undefined, + artwork: tabArtwork, + component: !hasSystemImageArtwork ? CategoryTabItem : undefined, + tab, + }; + }); +} diff --git a/src/components/pages/AppEventDetailPage.svelte b/src/components/pages/AppEventDetailPage.svelte new file mode 100644 index 0000000..a2b798e --- /dev/null +++ b/src/components/pages/AppEventDetailPage.svelte @@ -0,0 +1,44 @@ +<script lang="ts" context="module"> + import type { DefaultPageRequirements } from './DefaultPage.svelte'; +</script> + +<script lang="ts"> + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + + export let page: DefaultPageRequirements; +</script> + +<div class="app-event-detail-page-container"> + <div class="shelf-container"> + {#each page.shelves as shelf} + <ShelfComponent {shelf} /> + {/each} + </div> +</div> + +<style> + .app-event-detail-page-container { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 0 var(--bodyGutter); + } + + .shelf-container { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + max-width: 900px; + margin: 0 auto; + + @media (--range-small-up) { + justify-content: center; + } + } + + .shelf-container :global(.shelf) { + margin: 0; + padding: var(--bodyGutter) 0 0; + } +</style> diff --git a/src/components/pages/ArticlePage.svelte b/src/components/pages/ArticlePage.svelte new file mode 100644 index 0000000..32cacb0 --- /dev/null +++ b/src/components/pages/ArticlePage.svelte @@ -0,0 +1,141 @@ +<script lang="ts" context="module"> + import type { ArticlePage } from '@jet-app/app-store/api/models'; + + import type { DefaultPageRequirements } from './DefaultPage.svelte'; + + /** + * Just the `Page` props that we actually need to render this component + */ + export type ArticlePageRequirements = DefaultPageRequirements & { + card: ArticlePage['card']; + footerLockup: ArticlePage['footerLockup']; + }; +</script> + +<script lang="ts"> + import TodayCard from '~/components/jet/today-card/TodayCard.svelte'; + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + import FooterLockupItem from '~/components/jet/item/FooterLockupItem.svelte'; + export let page: ArticlePageRequirements; + + $: ({ card } = page); +</script> + +<div class="article-page-container" data-testid="article-page-container"> + <div class="article-layout"> + {#if card} + <div class="card-container"> + <TodayCard {card} suppressClickAction /> + </div> + {/if} + + <div class="story-container"> + {#each page.shelves as shelf} + {#if !shelf.isHidden} + <ShelfComponent {shelf} /> + {/if} + {/each} + + {#if page.footerLockup} + <div class="footer-lockup-container"> + <FooterLockupItem item={page.footerLockup} /> + </div> + {/if} + </div> + </div> +</div> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .article-page-container { + flex-grow: 1; + width: 100%; + margin: 0 auto; + } + + .article-layout { + --article-page-padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--article-page-padding); + max-width: 1600px; + margin: 0 auto; + + @media (--range-small-up) { + padding: 2em var(--bodyGutter); + } + + @media (--range-small-only) { + --article-page-padding: 40px; + } + + @media (--range-medium-up) { + align-items: flex-start; + flex-direction: row; + } + + @media (--range-medium-only) { + --article-page-padding: 20px; + } + + @media (--range-large-up) { + --article-page-padding: 40px; + } + } + + .card-container { + flex-shrink: 0; + aspect-ratio: 3/4; + width: 100%; + + @media (--range-xsmall-only) { + --border-radius: 0; + } + + @media (--range-small-only) { + aspect-ratio: 16/9; + } + + @media (--range-small-up) { + width: 100%; + } + + @media (--range-medium-up) { + position: sticky; + top: 2em; + aspect-ratio: 3 / 4; + height: min(calc(100vh - 80px), calc(33vw * 4 / 3)); + min-height: 420px; + max-height: 700px; + width: auto; + } + } + + .story-container { + width: 100%; + margin-top: 20px; + padding-bottom: var(--bodyGutter); + + @media (--range-small-up) { + width: calc(100%); + margin-top: 0; + } + + @media (--range-medium-up) { + min-width: calc(50% - calc(var(--article-page-padding))); + } + } + + .story-container :global(.shelf:first-of-type) { + padding-top: 0; + padding-bottom: 13px; + } + + .footer-lockup-container { + margin: var(--bodyGutter); + } +</style> diff --git a/src/components/pages/ChartsHubPage.svelte b/src/components/pages/ChartsHubPage.svelte new file mode 100644 index 0000000..a75cb64 --- /dev/null +++ b/src/components/pages/ChartsHubPage.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import type { ChartsHubPage } from '@jet-app/app-store/api/models'; + + import TopChartsPage from './TopChartsPage.svelte'; + + export let page: ChartsHubPage; +</script> + +{#each page.charts as chart} + <TopChartsPage page={chart} /> +{/each} diff --git a/src/components/pages/DefaultPage.svelte b/src/components/pages/DefaultPage.svelte new file mode 100644 index 0000000..7905b07 --- /dev/null +++ b/src/components/pages/DefaultPage.svelte @@ -0,0 +1,173 @@ +<script lang="ts" context="module"> + import type { + PagePresentationOptions, + Shelf, + } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + /** + * Just the `Page` props that we actually need to render this component + */ + export interface DefaultPageRequirements extends WebRenderablePage { + shelves: Shelf[]; + presentationOptions?: PagePresentationOptions; + } +</script> + +<script lang="ts"> + import type { MarkerShelf } from '~/components/jet/shelf/MarkerShelf.svelte'; + import { isUberShelf } from '~/components/jet/shelf/UberShelf.svelte'; + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + import { partition } from '~/utils/array'; + import { carouselMediaStyle } from '~/stores/carousel-media-style'; + import mediaQueries from '~/utils/media-queries'; + import { isHeroCarouselShelf } from '../jet/shelf/HeroCarouselShelf.svelte'; + import { isRtl } from '~/utils/locale'; + + interface $$Slots { + 'before-shelves': {}; + + /** + * If {@linkcode ShelfComponent}` recognizes a shelf to be a {@linkcode MarkerShelf}, + * this slot will be rendered so that the "page" data can be supplied by a "parent" + * component + */ + 'marker-shelf': { + shelf: MarkerShelf; + }; + } + + export let page: DefaultPageRequirements; + + $: ({ title, presentationOptions = [] } = page); + + // Some shelves are meant to be rendered above the title, rather than below it + $: [aboveTitleShelves, belowTitleShelves] = partition( + page.shelves, + (shelf) => { + // Some "uber" shelves might be placed above the title + if (isUberShelf(shelf)) { + const [uber] = shelf.items; + return uber.style === 'above'; + } + + // Everything else should be below it + return false; + }, + ); + + $: prefersHiddenPageTitle = presentationOptions.includes( + 'prefersHiddenPageTitle', + ); + $: prefersLargeTitle = presentationOptions.includes('prefersLargeTitle'); + $: prefersOverlayedPageHeader = + $mediaQueries === 'xsmall' && + presentationOptions.includes('prefersOverlayedPageHeader'); + $: isOnDarkBackground = + prefersOverlayedPageHeader && $carouselMediaStyle === 'dark'; + + $: isTitleDuplicatedInHero = (() => { + const firstShelf = page.shelves?.[0]; + + if ( + !firstShelf || + !isHeroCarouselShelf(firstShelf) || + firstShelf.items?.length !== 1 + ) { + return false; + } + + const { items: ltrItems, rtlItems } = firstShelf.items?.[0] ?? {}; + const firstItem = isRtl() && rtlItems?.length ? rtlItems : ltrItems; + const firstTitle = firstItem?.[0]?.overlay?.titleText; + + return title === firstTitle; + })(); +</script> + +<div + class="default-page-container" + data-testid="default-page-container" + class:with-overlaid-title={prefersOverlayedPageHeader} + class:with-title-in-hero={isTitleDuplicatedInHero} +> + {#each aboveTitleShelves as shelf} + <ShelfComponent {shelf}> + <slot name="marker-shelf" slot="marker-shelf" let:shelf {shelf} /> + </ShelfComponent> + {/each} + + {#if title && !prefersHiddenPageTitle && !isTitleDuplicatedInHero} + <h1 + data-test-id="page-title" + class:large-title={prefersLargeTitle} + class:overlaid={prefersOverlayedPageHeader} + class:on-dark-background={isOnDarkBackground} + > + {title} + </h1> + {/if} + + <slot name="before-shelves" /> + + {#each belowTitleShelves as shelf} + {#if !shelf.isHidden} + <ShelfComponent {shelf}> + <slot + name="marker-shelf" + slot="marker-shelf" + let:shelf + {shelf} + /> + </ShelfComponent> + {/if} + {/each} +</div> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .default-page-container { + flex-grow: 1; + width: 100%; + max-width: viewport-content-for(xlarge); + margin: 0 auto; + } + + .default-page-container.with-overlaid-title { + margin-top: -13px; + } + + .default-page-container.with-title-in-hero { + @media (--range-small-up) { + margin-top: 10px; + } + } + + h1 { + padding: 11px var(--bodyGutter); + font: var(--large-title-emphasized); + letter-spacing: -0.5px; + word-wrap: break-word; + color: var(--systemPrimary, #000); + position: relative; + z-index: 1; + transition: color 210ms ease-in; + } + + h1.large-title { + font: var(--large-title-emphasized-tall); + } + + h1.overlaid { + position: absolute; + z-index: 3; + padding: var(--bodyGutter) var(--bodyGutter) 0; + color: var(--systemPrimary-onLight, #000); + } + + h1.on-dark-background { + color: var(--systemPrimary-onDark); + } +</style> diff --git a/src/components/pages/ErrorPage.svelte b/src/components/pages/ErrorPage.svelte new file mode 100644 index 0000000..5756d78 --- /dev/null +++ b/src/components/pages/ErrorPage.svelte @@ -0,0 +1,23 @@ +<script lang="ts" context="module"> + import type { ErrorPage } from '~/jet/models'; +</script> + +<script lang="ts"> + import SharedErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let page: ErrorPage; + + const i18n = getI18n(); +</script> + +<div class="error-page-container"> + <SharedErrorPage translateFn={$i18n.t} error={page.error} /> +</div> + +<style> + .error-page-container :global(.page-error) { + /* -50px compensates for the global footer */ + top: calc(50% - 50px); + } +</style> diff --git a/src/components/pages/ProductPage.svelte b/src/components/pages/ProductPage.svelte new file mode 100644 index 0000000..30b0ad8 --- /dev/null +++ b/src/components/pages/ProductPage.svelte @@ -0,0 +1,77 @@ +<script lang="ts"> + import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models'; + import { isFlowAction } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + import DefaultPage, { + type DefaultPageRequirements, + } from '~/components/pages/DefaultPage.svelte'; + import MarkerShelf from '~/components/jet/shelf/MarkerShelf.svelte'; + import ProductPageArcadeFooter from '~/components/ProductPageArcadeFooter.svelte'; + import { getProductPageShelvesWithExpandedMedia } from '~/utils/shelves'; + import { setAccessibilityLayoutContext } from '~/context/accessibility-layout'; + import { getJet } from '~/jet'; + import { isProductPageLinkShelf } from '~/components/jet/shelf/ProductPageLinkShelf.svelte'; + import { isEulaPageIntent } from '@jet-app/app-store/api/intents/eula-page-intent'; + export let page: ShelfBasedProductPage & WebRenderablePage; + + const jet = getJet(); + + $: ({ presentationOptions, webNavigation } = page); + + $: shelves = getProductPageShelvesWithExpandedMedia(page); + + let defaultPageRequirements: DefaultPageRequirements; + + $: defaultPageRequirements = { + shelves, + presentationOptions, + webNavigation, + }; + + // Set up accessibility layout context for neighbor shelf detection + $: { + setAccessibilityLayoutContext({ shelves }); + + /** + * We suppport "deep linking" to the product page with the License Agreement modal open by + * default, based on the presence of the `lic` query parameter. No other modals support + * opening via deep link, which is why there isn't a more robust solution for this use case. + * Instead, we are just firing off the click action from the license agreement shelf. + */ + if (page.canonicalURL) { + const canonicalUrl = new URL(page.canonicalURL); + const hasLic = canonicalUrl.searchParams.has('lic'); + + if (hasLic && shelves) { + const eulaItem = shelves + .find(isProductPageLinkShelf) + ?.items.find( + ({ clickAction }) => + isFlowAction(clickAction) && + clickAction.destination && + isEulaPageIntent(clickAction.destination), + ); + + if (eulaItem) { + jet.perform(eulaItem.clickAction); + } + } + } + } + + // TODO: replace with `supportsArcade` from Jet + // rdar://143706610 (Support `supportsArcade` attribute) + $: supportsArcade = + page.lockup.offerDisplayProperties?.offerType === 'arcadeApp'; +</script> + +<DefaultPage page={defaultPageRequirements}> + <svelte:fragment slot="marker-shelf" let:shelf> + <MarkerShelf {shelf} {page} /> + </svelte:fragment> +</DefaultPage> + +{#if supportsArcade} + <ProductPageArcadeFooter /> +{/if} diff --git a/src/components/pages/SearchLandingPage.svelte b/src/components/pages/SearchLandingPage.svelte new file mode 100644 index 0000000..3594ece --- /dev/null +++ b/src/components/pages/SearchLandingPage.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import type { SearchLandingPage } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + import { unwrapOptional as unwrap } from '@jet/environment/types/optional'; + + type SearchPage = SearchLandingPage; + + import DefaultPage from './DefaultPage.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SearchInput from '~/components/navigation/SearchInput.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let page: SearchPage; + + const i18n = getI18n(); + + $: webNavigation = unwrap((page as WebRenderablePage).webNavigation); + $: searchAction = webNavigation.searchAction as WebSearchFlowAction; + $: hasShelves = !!page.shelves.filter(({ items }) => items?.length).length; + + $: pageWithoutEmptyShelves = { + ...page, + shelves: hasShelves ? page.shelves : [], + title: $i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'), + }; +</script> + +<DefaultPage page={pageWithoutEmptyShelves}> + <ShelfWrapper slot="before-shelves" centered> + <SearchInput {searchAction} big /> + </ShelfWrapper> +</DefaultPage> diff --git a/src/components/pages/SearchResultsPage.svelte b/src/components/pages/SearchResultsPage.svelte new file mode 100644 index 0000000..c17b644 --- /dev/null +++ b/src/components/pages/SearchResultsPage.svelte @@ -0,0 +1,113 @@ +<script lang="ts"> + import type { SearchResultsPage } from '@jet-app/app-store/api/models'; + + import type { Size } from '@amp/web-app-components/src/types'; + import { ShelfConfig } from '@amp/web-app-components/config/components/shelf'; + + import DefaultPage from './DefaultPage.svelte'; + import { getI18n } from '~/stores/i18n'; + import mediaQueries from '~/utils/media-queries'; + import { + isSearchResultShelf, + isRenderableInSearchResultsShelf, + } from '~/components/jet/shelf/SearchResultShelf.svelte'; + import { getPlatformFromPage } from '~/utils/seo/common'; + + export let page: SearchResultsPage; + + const i18n = getI18n(); + + $: resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null; + + $: renderableItems = (resultsShelf?.items ?? []).filter( + isRenderableInSearchResultsShelf, + ); + + $: columnConfig = ShelfConfig.get().GRID_VALUES.SearchResult; + $: numberOfColumns = columnConfig[$mediaQueries as Size] || 3; + $: numberOfRows = Math.ceil(renderableItems.length / numberOfColumns); + $: middleRow = Math.floor(numberOfRows / 2); + $: insertAt = middleRow * numberOfColumns; + + /** + * This is unfortunate but only these three platforms support the transparency link. + * This link is enabled via the `transparencyLawEditorialItemId` bag key, but when defining + * bag keys, we do not have access to the platform being viewed, so we can't opt-out there. + * We could do this platform check in the Jet layer, but adding two forms of opting into this + * link felt cumbersome and unintuitive, so we can just do it here. + */ + $: transparencyLink = + page.transparencyLink && + ['iphone', 'ipad', 'mac'].includes( + getPlatformFromPage(page).toLowerCase(), + ); + + /** + * Here we are building constructing a new array of shelves _if_ there is a result shelf _and_ + * a transparency link. This creates three shelves: + * 1) the search results before the transparency banner in the linkable text shelf + * 2) the transparency banner + * 3) the search results after the transparency banner + */ + $: shelves = resultsShelf + ? transparencyLink && renderableItems.length + ? [ + insertAt > 0 && { + ...resultsShelf, + items: renderableItems.slice(0, insertAt), + title: null, + isValid: () => true, + }, + { + contentType: 'linkableText', + items: [page.transparencyLink], + }, + { + ...resultsShelf, + items: renderableItems.slice(insertAt), + title: null, + isValid: () => true, + }, + ] + : [{ ...resultsShelf, items: renderableItems, title: null }] + : []; +</script> + +<DefaultPage + page={{ + shelves, + title: renderableItems.length > 0 ? resultsShelf?.title : null, + }} +> + <svelte:fragment slot="before-shelves"> + {#if renderableItems.length === 0} + <div> + <h1> + {$i18n.t('ASE.Web.AppStore.Search.NoResults.FirstLine')} + </h1> + <p> + {$i18n.t('ASE.Web.AppStore.Search.NoResults.SecondLine', { + term: page.searchTermContext?.term, + })} + </p> + </div> + {/if} + </svelte:fragment> +</DefaultPage> + +<style> + div { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 3px; + height: 70vh; + margin: var(--bodyGutter); + } + + p { + font: var(--title-3); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/pages/SeeAllPage.svelte b/src/components/pages/SeeAllPage.svelte new file mode 100644 index 0000000..d401f32 --- /dev/null +++ b/src/components/pages/SeeAllPage.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import type { SeeAllPage } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + import DefaultPage from '~/components/pages/DefaultPage.svelte'; + import { getProductPageShelvesForOrdering } from '~/utils/shelves'; + import { setAccessibilityLayoutContext } from '~/context/accessibility-layout'; + import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte'; + import { isProductReviewShelf } from '~/components/jet/shelf/ProductReviewShelf.svelte'; + import { isProductRatingsShelf } from '~/components/jet/shelf/ProductRatingsShelf.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let page: SeeAllPage & WebRenderablePage; + + $: shelves = getProductPageShelvesForOrdering(page, 'notPurchasedOrdering') + .filter((shelf) => { + const isShelfForReviewPage = + isProductReviewShelf(shelf) || isProductRatingsShelf(shelf); + + return ( + isSmallLockupShelf(shelf) || + (isShelfForReviewPage && page.seeAllType === 'reviews') + ); + }) + .map((shelf) => { + shelf.isHorizontal = false; + shelf.seeAllAction = null; + return shelf; + }); + + $: { + setAccessibilityLayoutContext({ shelves }); + } +</script> + +<DefaultPage page={{ shelves, title: null }}> + <svelte:fragment slot="before-shelves"> + <h1> + <LinkWrapper action={page.lockup.clickAction}> + {page.lockup.title} + </LinkWrapper> + </h1> + </svelte:fragment> +</DefaultPage> + +<style> + h1 { + font: var(--title-1); + color: var(--keyColor); + margin: 13px var(--bodyGutter) 0; + } + + h1 :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/pages/StaticMessagePage.svelte b/src/components/pages/StaticMessagePage.svelte new file mode 100644 index 0000000..45c1a36 --- /dev/null +++ b/src/components/pages/StaticMessagePage.svelte @@ -0,0 +1,113 @@ +<script lang="ts" context="module"> + import type { StaticMessagePage } from '~/jet/models'; +</script> + +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + + export let page: StaticMessagePage; + + const i18n = getI18n(); +</script> + +<div class="static-message-page-container"> + <div class="static-message-text-wrapper"> + {#if page.titleLocKey} + <h1>{$i18n.t(page.titleLocKey)}</h1> + {/if} + + <section> + {#if page.contentType === 'win-back' || page.contentType === 'contingent-price'} + <p> + {$i18n.t('ASE.Web.AppStore.WinBack.Subhead')} + </p> + + <p> + <b> + {$i18n.t('ASE.Web.AppStore.WinBack.DirectionalTitle')} + </b> + </p> + + <ul> + <li> + {$i18n.t('ASE.Web.AppStore.WinBack.Update.iOS')} + </li> + <li> + {$i18n.t('ASE.Web.AppStore.WinBack.Update.macOS')} + </li> + </ul> + + <p> + {$i18n.t('ASE.Web.AppStore.WinBack.Body')} + </p> + {:else if page.contentType === 'carrier'} + <p class="carrier__instructions"> + {$i18n.t('ASE.Web.AppStore.Carrier.Update.iOS')} + </p> + <p> + {$i18n.t('ASE.Web.AppStore.Carrier.Body')} + </p> + {:else if page.contentType === 'invoice'} + <p class="invoice__instructions"> + {$i18n.t('ASE.Web.AppStore.Invoice.Body')} + </p> + {/if} + </section> + </div> +</div> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .static-message-page-container { + display: flex; + flex-grow: 1; + width: 100%; + max-width: viewport-content-for(xlarge); + margin: 0 auto; + align-items: center; + } + + @media (--range-sidebar-visible-up) { + .static-message-page-container { + height: 100%; + } + } + + .static-message-text-wrapper { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + width: auto; + margin: 0 auto; + } + + .static-message-page-container h1 { + padding: 13px var(--bodyGutter) 0; + font: var(--header-emphasized); + color: var(--systemPrimary, #000); + position: relative; + z-index: 1; + margin-bottom: 16px; + } + + .static-message-page-container section { + margin: 0 var(--bodyGutter); + font: var(--title-3); + } + + .static-message-page-container li { + list-style-type: disc; + } + + .static-message-page-container p, + .static-message-page-container ul { + margin-bottom: 16px; + text-wrap: pretty; + } + + .static-message-page-container ul { + padding-inline-start: 1em; + } +</style> diff --git a/src/components/pages/TodayPage.svelte b/src/components/pages/TodayPage.svelte new file mode 100644 index 0000000..3d38932 --- /dev/null +++ b/src/components/pages/TodayPage.svelte @@ -0,0 +1,22 @@ +<!-- +@component +Page component for the "Today Page" + +This is required so that the correct layout of the cards within each `TodayCardShelf` +can be computed at the page level, as the algorithm for stretching the correct cards +in each shelf requires knowledge of the previously-rendered shelf +--> +<script lang="ts"> + import type { TodayPage } from '@jet-app/app-store/api/models'; + + import DefaultPage from '~/components/pages/DefaultPage.svelte'; + import { setTodayCardLayoutContext } from '~/context/today-card-layout'; + + export let page: TodayPage; + + $: { + setTodayCardLayoutContext(page); + } +</script> + +<DefaultPage {page} /> diff --git a/src/components/pages/TopChartsPage.svelte b/src/components/pages/TopChartsPage.svelte new file mode 100644 index 0000000..4a3e7b7 --- /dev/null +++ b/src/components/pages/TopChartsPage.svelte @@ -0,0 +1,218 @@ +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + import { isSome } from '@jet/environment/types/optional'; + import type { TopChartsPage } from '@jet-app/app-store/api/models'; + + import DefaultPage from '~/components/pages/DefaultPage.svelte'; + import Shelf from '~/components/Shelf/Wrapper.svelte'; + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import Menu from '~/components/Menu.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + + export let page: TopChartsPage; + + const i18n = getI18n(); + + $: ({ categories, categoriesButtonTitle, segments, initialSegmentIndex } = + page); + $: segment = segments[initialSegmentIndex]; +</script> + +<DefaultPage page={{ shelves: segment.shelves, title: page.title }}> + <Shelf slot="before-shelves" centered> + <header> + <div class="dropdown-container"> + {#if categoriesButtonTitle} + <Menu options={categories}> + <svelte:fragment slot="trigger"> + <span class="menu-trigger-contents"> + {categoriesButtonTitle} + + <SFSymbol name="chevron.down" /> + </span> + </svelte:fragment> + + <svelte:fragment slot="option" let:option> + {@const { artwork, chartSelectAction, name } = + option} + + <LinkWrapper action={chartSelectAction}> + <div + class="category-menu-item" + class:active={name === + categoriesButtonTitle} + > + {#if isSome(artwork)} + <div class="artwork-container"> + <Artwork + {artwork} + profile={getNaturalProfile( + artwork, + [24], + )} + /> + </div> + {/if} + + <span>{name}</span> + </div> + </LinkWrapper> + </svelte:fragment> + </Menu> + {/if} + </div> + + <div class="segment-selector" aria-label={categoriesButtonTitle}> + {#each segments as segment, index} + {@const { segmentSelectAction } = segment} + {@const isSelected = initialSegmentIndex === index} + {@const filterLabel = $i18n.t( + isSelected + ? 'ASE.Web.AppStore.SelectedFilterApps.AX.Label' + : 'ASE.Web.AppStore.FilterApps.AX.Label', + { filterName: segment.shortName }, + )} + + <LinkWrapper + action={segmentSelectAction} + label={filterLabel} + > + <span class="segment" class:selected={isSelected}> + {segment.shortName} + </span> + </LinkWrapper> + {/each} + </div> + </header> + </Shelf> +</DefaultPage> + +<style> + header { + --pill-button-border-radius: 1000px; /* Arbitrary large value for "pill-style" rounded sides */ + --menu-item-padding: 0; + --menu-item-margin: 0 0 8px 0; + --menu-popover-padding: 12px 16px; + --menu-common-padding: 0; + --menu-trigger-border-radius: var(--pill-button-border-radius); + --menu-trigger-background-color: var(--systemPrimary-onDark); + --menu-trigger-padding: 6px 16px; + --menu-trigger-font: var(--body-semibold-tall); + --menu-popover-background-color: white; + --menu-popover-box-shadow: 10px 10px 10px 0 + var(--systemQuaternary-onLight); + --menu-popover-border-radius: 14px; + --menu-popover-border: 1px solid var(--systemQuaternary); + --menu-popover-z-index: calc(var(--z-web-chrome) + 1); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + + @media (--range-small-up) { + display: grid; + align-items: center; + justify-items: start; + grid-template-columns: 1fr max-content 1fr; + } + + @media (prefers-color-scheme: dark) { + --menu-trigger-background-color: var(--systemQuaternary); + --menu-popover-background-color: var(--systemQuaternary-vibrant); + } + } + + .segment-selector { + display: flex; + justify-self: end; + gap: 4px; + padding: 2px; + background: var(--systemQuaternary); + border-radius: var(--pill-button-border-radius); + + @media (--range-small-up) { + align-items: center; + justify-self: center; + grid-column: 2; + } + } + + .segment-selector :global(a) { + display: contents; + } + + .segment { + border-radius: var(--pill-button-border-radius); + font: var(--body-semibold-tall); + padding: 6px 16px; + } + + .segment.selected { + background-color: var(--systemPrimary-onDark); + color: var(--systemPrimary); + + @media (prefers-color-scheme: dark) { + background-color: var(--systemQuaternary); + } + } + + .dropdown-container { + justify-self: start; + + @media (--range-small-up) { + grid-column: 1; + } + } + + .menu-trigger-contents { + display: flex; + align-items: center; + gap: 4px; + } + + .menu-trigger-contents :global(svg) { + height: 0.7em; + } + + .menu-trigger-contents :global(path:not([fill='none'])) { + fill: currentColor; + } + + .category-menu-item { + display: flex; + align-items: center; + padding: 8px; + border-radius: 10px; + height: 40px; + transition: background 150ms ease-in; + } + + .category-menu-item.active { + background: var(--systemQuinary); + } + + .category-menu-item:not(.active):hover { + background: rgba(0, 0, 0, 0.035); + } + + .artwork-container { + width: 24px; + margin-inline-end: 8px; + flex-shrink: 0; + } + + .category-menu-item span { + text-overflow: ellipsis; + overflow: hidden; + } + + .dropdown-container :global(.menu-popover) { + max-width: 600px; + width: 100%; + column-count: 2; + + @media (--range-medium-up) { + column-count: 3; + } + } +</style> diff --git a/src/components/pages/VisionProPage.svelte b/src/components/pages/VisionProPage.svelte new file mode 100644 index 0000000..c87ee09 --- /dev/null +++ b/src/components/pages/VisionProPage.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + import type { GenericPage } from '@jet-app/app-store/api/models'; + + import DefaultPage from './DefaultPage.svelte'; + import VisionProFooter from '~/components/structure/VisionProFooter.svelte'; + + export let page: GenericPage; +</script> + +<DefaultPage {page} /> + +<VisionProFooter /> diff --git a/src/components/structure/Fonts.svelte b/src/components/structure/Fonts.svelte new file mode 100644 index 0000000..63af7b6 --- /dev/null +++ b/src/components/structure/Fonts.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { BASE, getFontURL } from '@amp/web-apps-fonts'; + + export let language: string; + + $: fontURL = getFontURL(language); +</script> + +<svelte:head> + <link rel="preconnect" href={BASE} crossorigin="anonymous" /> + + <link + rel="stylesheet" + as="style" + href={fontURL} + type="text/css" + referrerpolicy="strict-origin-when-cross-origin" + /> +</svelte:head> diff --git a/src/components/structure/Footer.svelte b/src/components/structure/Footer.svelte new file mode 100644 index 0000000..ceabfec --- /dev/null +++ b/src/components/structure/Footer.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + import Footer, { + type Translate, + } from '@amp/web-app-components/src/components/Footer/Footer.svelte'; + import LocaleSwitcherButton from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte'; + import { items } from '~/constants/footer-items'; + import { getLocale } from '~/utils/locale'; + import { + regions, + languages, + storefrontNameTranslations, + } from '~/utils/storefront-data'; + + const i18n = getI18n(); + const locale = getLocale(); + + const translate: Translate = (key, options) => $i18n.t(key, options); +</script> + +<section class="footer-container"> + <Footer footerItems={items} translateFn={translate}> + <LocaleSwitcherButton + slot="secondary-content" + translateFn={translate} + {regions} + {languages} + {locale} + {storefrontNameTranslations} + defaultRoute="iphone/today" + /> + </Footer> +</section> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .footer-container { + background-color: var(--footerBg); + } + + .footer-container :global(footer) { + max-width: calc(viewport-content-for(xlarge)); + margin: 0 auto; + } +</style> diff --git a/src/components/structure/MetaTags.svelte b/src/components/structure/MetaTags.svelte new file mode 100644 index 0000000..11b9477 --- /dev/null +++ b/src/components/structure/MetaTags.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import type { Opt } from '@jet/environment/types/optional'; + import type { Organization, WithContext } from 'schema-dts'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + import MetaTags from '@amp/web-app-components/src/components/MetaTags/MetaTags.svelte'; + import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + import { getLocale } from '@amp/web-app-components/src/utils/internal/locale'; + import { getPageDir } from '@amp/web-apps-localization/src'; + + import { getI18n } from '~/stores/i18n'; + + export let page: WebRenderablePage; + + const i18n = getI18n(); + const locale = getLocale(); + + const organizationSchema: WithContext<Organization> = { + '@context': 'https://schema.org', + '@id': 'https://apps.apple.com/#organization', + '@type': 'Organization', + name: 'App Store', + url: 'https://apps.apple.com', + logo: 'https://apps.apple.com/assets/app-store.png', + sameAs: [ + 'https://www.wikidata.org/wiki/Q368215', + 'https://twitter.com/AppStore', + 'https://www.instagram.com/appstore/', + 'https://www.facebook.com/appstore/', + ], + parentOrganization: { + '@type': 'Organization', + name: 'Apple', + '@id': 'https://www.apple.com/#organization', + url: 'https://www.apple.com/', + }, + }; + + // This cast of `.seoData` is technically a little risky, but our app fully + // defines this property, which should make it fairly safe. Whatever is returned + // for the page from the `SEO` dependency on the Object Graph will be the value + // reflected here. + $: seoData = (page.seoData as Opt<SeoData>) ?? undefined; + + // Provide default title for pages not yet set up with SEO data + $: defaultTitle = $i18n.t('ASE.Web.AppStore.Meta.SiteName'); + $: pageDir = getPageDir(locale.language) ?? 'ltr'; +</script> + +<MetaTags + {defaultTitle} + {locale} + {pageDir} + {seoData} + origin={'https://apps.apple.com/'} +> + <svelte:fragment slot="schemaOrganizationData"> + {#if import.meta.env.SSR} + <svelte:element + this="script" + id="organization" + type="application/ld+json" + > + {JSON.stringify(organizationSchema)} + </svelte:element> + {/if} + </svelte:fragment> +</MetaTags> diff --git a/src/components/structure/VisionProFooter.svelte b/src/components/structure/VisionProFooter.svelte new file mode 100644 index 0000000..59dcd5b --- /dev/null +++ b/src/components/structure/VisionProFooter.svelte @@ -0,0 +1,142 @@ +<script lang="ts"> + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import Grid from '~/components/Grid.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getLocale } from '~/utils/locale'; + + const locale = getLocale(); + const i18n = getI18n(); + + let links: Record<string, string>; + + function getAboutAppStoreUrl(storefront: string, language: string) { + let storefrontSlug = `${storefront}/`; + + if (storefront === 'us') { + storefrontSlug = ''; + } else if (storefront === 'gb') { + // The UK "About App Store" link is https://www.apple.com/uk/app-store/, not https://www.apple.com/gb/app-store/. + storefrontSlug = 'uk/'; + } else if (storefront === 'ae' && language === 'ar') { + storefrontSlug = 'ae-ar/'; + } + + return `https://www.apple.com/${storefrontSlug}app-store/`; + } + + $: storefront = locale.storefront; + $: links = { + 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutAppStore': + getAboutAppStoreUrl(storefront, locale.language), + 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutPurchases': `https://apps.apple.com/${storefront}/story/id1436214772`, + 'ASE.Web.AppStore.VisionPro.Footer.Links.RequestRefund': `https://www.apple.com/${storefront}/shop/goto/help/sales_refunds`, + 'ASE.Web.AppStore.VisionPro.Footer.Links.PaymentMethods': `https://support.apple.com/118429`, + }; + + $: if (storefront === 'fr') { + links[ + 'AppStore.QuickLinks.AboutFrenchAppStore.Title' + ] = `https://apps.apple.com/${storefront}/story/1700848501`; + } +</script> + +<ShelfWrapper centered={false} withBottomPadding={false}> + <section data-test-id="vision-footer"> + <p class="blurb"> + {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Blurb')} + </p> + + <article class="quick-links-container"> + <ShelfTitle + title={$i18n.t('ASE.Web.AppStore.VisionPro.Footer.LinksTitle')} + /> + + <navigation> + <Grid + items={Object.entries(links)} + gridType="FooterLink" + let:item + > + {@const [title, href] = item} + <a {href}>{$i18n.t(title)}</a> + </Grid> + </navigation> + </article> + + <article class="disclaimer-container"> + <p> + {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Disclaimer')} + </p> + </article> + </section> +</ShelfWrapper> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + section { + font: var(--body-tall); + } + + .blurb { + flex-grow: 1; + width: 100%; + max-width: calc(viewport-content-for(xlarge) * 0.66); + margin: 40px auto 50px; + padding: 0 var(--shelfGridPaddingInline, 40px); + text-align: center; + } + + .quick-links-container { + max-width: viewport-content-for(xlarge); + margin: 50px auto; + padding: 0 var(--bodyGutter); + } + + a { + display: block; + padding: var(--grid-column-gap-medium) 0 var(--grid-column-gap-medium); + word-break: break-all; + font: var(--title-2); + color: var(--keyColor); + border-bottom: 1px solid var(--systemQuinary); + + @media (--range-xsmall-down) { + padding: var(--grid-column-gap-xsmall) 0 + var(--grid-column-gap-xsmall); + } + } + + @media (--range-medium-up) { + .quick-links-container li:nth-child(n + 4) a { + border-bottom: none; + } + } + + @media (--small) { + .quick-links-container li:nth-child(n + 5) a { + border-bottom: none; + } + } + + @media (--range-xsmall-down) { + .quick-links-container li:last-child a { + border-bottom: none; + } + } + + .disclaimer-container { + flex-grow: 1; + width: 100%; + color: var(--systemTertiary); + background-color: var(--footerBg); + } + + .disclaimer-container p { + max-width: viewport-content-for(xlarge); + margin: 0 auto; + padding: 32px var(--bodyGutter, 40px); + } +</style> |
