diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /src/components/jet | |
init commit
Diffstat (limited to 'src/components/jet')
134 files changed, 11902 insertions, 0 deletions
diff --git a/src/components/jet/Video.svelte b/src/components/jet/Video.svelte new file mode 100644 index 0000000..8d2e4f3 --- /dev/null +++ b/src/components/jet/Video.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + import type { Video } from '@jet-app/app-store/api/models'; + import VideoPlayer from '../VideoPlayer.svelte'; + import HlsJsDecorator from '../decorators/HlsJSDecorator.svelte'; + import { buildPoster } from '~/utils/video-poster'; + import { generateUuid } from '@amp/web-apps-utils/src'; + import type { NamedProfile } from 'src/config/components/artwork'; + import type { Profile } from '@amp/web-app-components/src/components/Artwork/types'; + import mediaQueries from '~/utils/media-queries'; + import { colorAsString } from '~/utils/color'; + + export let video: Video; + export let autoplay: boolean = false; + export let loop: boolean = false; + export let muted: boolean = true; + export let useControls: boolean = true; + export let profile: NamedProfile | Profile; + export let autoplayVisibilityThreshold: number = 0; + export let videoPlayerRef: InstanceType<typeof VideoPlayer> | null = null; + export let shouldSuperimposePosterImage: boolean = false; + + $: poster = + video.preview && buildPoster(video.preview, profile, $mediaQueries); + $: backgroundColor = video.preview.backgroundColor + ? colorAsString(video.preview.backgroundColor) + : '#f1f1f1'; + + $: metricsTemplate = video?.templateMediaEvent ?? {}; + const uuid = generateUuid(); +</script> + +<HlsJsDecorator let:HLS> + <VideoPlayer + {HLS} + {loop} + {muted} + {autoplay} + {useControls} + {autoplayVisibilityThreshold} + {metricsTemplate} + {shouldSuperimposePosterImage} + id={uuid} + src={video.videoUrl} + poster={poster ?? undefined} + --aspect-ratio={video.preview.width / video.preview.height} + bind:this={videoPlayerRef} + /> + + <div + class="loader" + slot="loading-component" + style:--aspect-ratio={video.preview.width / video.preview.height} + style:--background-image={`url(${poster})`} + style:--background-color={backgroundColor} + /> +</HlsJsDecorator> + +<style> + .loader { + aspect-ratio: var(--aspect-ratio); + width: 100%; + background-image: var(--background-image); + background-color: var(--background-color); + background-size: cover; + } +</style> diff --git a/src/components/jet/action/ExternalUrlAction.svelte b/src/components/jet/action/ExternalUrlAction.svelte new file mode 100644 index 0000000..e8a2ad6 --- /dev/null +++ b/src/components/jet/action/ExternalUrlAction.svelte @@ -0,0 +1,52 @@ +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + import type { ExternalUrlAction } from '@jet-app/app-store/api/models'; + import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg'; + import { getJetPerform } from '~/jet'; + + type AllowedAnchorAttributes = Omit< + HTMLAnchorAttributes, + // The `href` attribute is not allowed because it will be provided + // by the `ExternalUrlAction` + 'href' + >; + + interface $$Props extends AllowedAnchorAttributes { + destination: ExternalUrlAction; + includeArrowIcon?: boolean; + } + + const perform = getJetPerform(); + + export let destination: ExternalUrlAction; + export let includeArrowIcon: boolean = true; + + function handleClickAction() { + perform(destination); + } +</script> + +<a + {...$$restProps} + data-test-id="external-link" + href={destination.url} + target="_blank" + rel="nofollow noopener noreferrer" + on:click={handleClickAction} +> + <slot /> + {#if includeArrowIcon} + <ArrowIcon class="external-link-arrow" aria-hidden="true" /> + {/if} +</a> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + a :global(.external-link-arrow) { + @include rtl { + transform: rotate(-90deg); + } + } +</style> diff --git a/src/components/jet/action/FlowAction.svelte b/src/components/jet/action/FlowAction.svelte new file mode 100644 index 0000000..3e55e82 --- /dev/null +++ b/src/components/jet/action/FlowAction.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + import type { FlowAction } from '@jet-app/app-store/api/models'; + import { getJetPerform } from '~/jet'; + + type AllowedAnchorAttributes = Omit< + HTMLAnchorAttributes, + // The `href` attribute is not allowed because it will be provided + // by the `FlowAction` + 'href' + >; + + interface $$Props extends AllowedAnchorAttributes { + destination: FlowAction; + } + + const perform = getJetPerform(); + + export let destination: FlowAction; + + // Web cannot support internal protocols, so this guard prevents + // them from showing up in anchor tags. + $: pageUrl = destination.pageUrl?.includes('x-as3-internal:') + ? '#' + : destination?.pageUrl; + + function onClick(event: MouseEvent) { + event.preventDefault(); + + perform(destination); + } +</script> + +<a + {...$$restProps} + href={pageUrl} + data-test-id="internal-link" + on:click={onClick} +> + <slot /> +</a> diff --git a/src/components/jet/action/ShelfBasedPageScrollAction.svelte b/src/components/jet/action/ShelfBasedPageScrollAction.svelte new file mode 100644 index 0000000..9c1c13e --- /dev/null +++ b/src/components/jet/action/ShelfBasedPageScrollAction.svelte @@ -0,0 +1,51 @@ +<script lang="ts" context="module"> + import type { + Action, + ShelfBasedPageScrollAction, + } from '@jet-app/app-store/api/models'; + + export function isShelfBasedPageScrollAction( + action: Action, + ): action is ShelfBasedPageScrollAction { + return ( + action.$kind === 'ShelfBasedPageScrollAction' && 'shelfId' in action + ); + } +</script> + +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + + interface $$Props extends HTMLAnchorAttributes { + destination: ShelfBasedPageScrollAction; + } + + export let destination: ShelfBasedPageScrollAction; + + function handleLinkClick(e: Event) { + const anchorElement = e.currentTarget as HTMLAnchorElement; + const hash = anchorElement.hash; + const elementToScrollTo = document.querySelector(hash); + if (!elementToScrollTo) { + return; + } + elementToScrollTo.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + history.replaceState(null, '', hash); + } +</script> + +{#if destination.shelfId} + <a + {...$$restProps} + data-test-id="scroll-link" + href={`#${destination.shelfId}`} + on:click|preventDefault|stopPropagation={handleLinkClick} + > + <slot /> + </a> +{:else} + <slot /> +{/if} diff --git a/src/components/jet/badge/ContentRatingBadge.svelte b/src/components/jet/badge/ContentRatingBadge.svelte new file mode 100644 index 0000000..ff3a2c3 --- /dev/null +++ b/src/components/jet/badge/ContentRatingBadge.svelte @@ -0,0 +1,61 @@ +<script lang="ts" context="module"> + import type { Badge, BadgeType } from '@jet-app/app-store/api/models'; + + const ARTWORK_TYPE: BadgeType = 'artwork'; + const CONTENT_RATING_TYPE: BadgeType = 'contentRating'; + const CONTENT_RATING_KEY = 'contentRating'; + + interface ContentRatingBadge extends Badge { + type: typeof CONTENT_RATING_TYPE; + } + + export function isContentRatingBadge( + badge: Badge, + ): badge is ContentRatingBadge { + return ( + badge.type === CONTENT_RATING_TYPE || + (badge.key === CONTENT_RATING_KEY && badge.type === ARTWORK_TYPE) + ); + } +</script> + +<script lang="ts"> + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let badge: ContentRatingBadge; + + $: ({ artwork, accessibilityTitle } = badge); +</script> + +{#if artwork && isSystemImageArtwork(artwork)} + <div class="pictogram-container" aria-label={accessibilityTitle}> + <SystemImage {artwork} /> + </div> +{:else} + <span> + {badge.content.contentRating} + </span> +{/if} + +<style> + span { + height: 25px; + margin: 4px 0 2px; + font: var(--title-1-emphasized); + color: var(--color); + } + + .pictogram-container { + height: 25px; + padding: 2px; + aspect-ratio: 1/1; + margin: 4px 0 2px; + } + + .pictogram-container :global(svg) { + width: 21px; + height: 21px; + } +</style> diff --git a/src/components/jet/item/AccessibilityFeaturesItem.svelte b/src/components/jet/item/AccessibilityFeaturesItem.svelte new file mode 100644 index 0000000..bcbeb6c --- /dev/null +++ b/src/components/jet/item/AccessibilityFeaturesItem.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import type { AccessibilityFeatures } from '@jet-app/app-store/api/models'; + + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let item: AccessibilityFeatures; + export let isDetailView: boolean = false; +</script> + +<article + class:is-detail-view={isDetailView} + role={isDetailView ? 'presentation' : 'article'} +> + {#if !isDetailView} + {#if item.artwork && isSystemImageArtwork(item.artwork)} + <span class="icon-container" aria-hidden="true"> + <SystemImage artwork={item.artwork} /> + </span> + {/if} + <h2>{item.title}</h2> + {/if} + + <ul class:grid={item.features.length > 1 && !isDetailView}> + {#each item.features as feature} + <li> + {#if isSystemImageArtwork(feature.artwork)} + <span class="feature-icon-container" aria-hidden="true"> + <SystemImage artwork={feature.artwork} /> + </span> + {/if} + <div class="feature-content"> + <h3 class="feature-title">{feature.title}</h3> + {#if feature.description} + <span class="feature-description"> + {feature.description} + </span> + {/if} + </div> + </li> + {/each} + </ul> +</article> + +<style lang="scss"> + @use 'amp/stylekit/core/border-radiuses' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + display: flex; + flex-direction: column; + height: 100%; + padding: 30px; + gap: 8px; + text-align: center; + font: var(--body-tall); + border-radius: $global-border-radius-rounded-large; + background-color: var(--systemQuinary); + + &.is-detail-view { + padding: 0; + text-align: start; + background-color: transparent; + } + } + + .icon-container { + width: 30px; + margin: 0 auto; + } + + .icon-container :global(svg) { + width: 100%; + fill: var(--keyColor); + } + + h2 { + font: var(--title-3-emphasized); + margin-bottom: 8px; + } + + ul { + display: flex; + flex-direction: column; + gap: 25px; + } + + ul.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + li { + display: flex; + align-items: center; + justify-content: center; + text-align: start; + padding: 4px 0; + gap: 8px; + + .is-detail-view & { + gap: 10px; + justify-content: start; + align-items: flex-start; + } + } + + .grid li { + justify-content: start; + } + + .feature-icon-container { + display: inline-flex; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + + .is-detail-view & { + display: flex; + align-items: center; + + @media (prefers-color-scheme: dark) { + filter: none; + } + } + } + + .feature-icon-container :global(svg) { + width: 20px; + + .is-detail-view & { + width: 30px; + fill: var(--keyColor); + } + } + + .feature-content { + display: flex; + flex-direction: column; + gap: 6px; + } + + .feature-title { + font: var(--body-tall); + + .is-detail-view & { + color: var(--systemPrimary); + font: var(--title-2-emphasized); + } + } + + .feature-description { + color: var(--systemSecondary); + font: var(--body); + } +</style> diff --git a/src/components/jet/item/AccessibilityParagraphItem.svelte b/src/components/jet/item/AccessibilityParagraphItem.svelte new file mode 100644 index 0000000..836b52f --- /dev/null +++ b/src/components/jet/item/AccessibilityParagraphItem.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import type { AccessibilityParagraph } from '@jet-app/app-store/api/models'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let item: AccessibilityParagraph; +</script> + +<div> + <p> + <LinkableTextItem item={item.text} /> + </p> +</div> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/item/Annotation/AnnotationItem.svelte b/src/components/jet/item/Annotation/AnnotationItem.svelte new file mode 100644 index 0000000..38bb269 --- /dev/null +++ b/src/components/jet/item/Annotation/AnnotationItem.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { type Annotation } from '@jet-app/app-store/api/models'; + import ModernAnnotationItemRenderer from '~/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte'; + import LegacyAnnotationRenderer from '~/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte'; + + export let item: Annotation; + + $: ({ items, items_V3, linkAction, summary } = item); + + $: shouldRenderModernAnnotation = items_V3.length > 0; +</script> + +{#if shouldRenderModernAnnotation} + <ModernAnnotationItemRenderer items={items_V3} {summary} /> +{:else} + <LegacyAnnotationRenderer {items} {linkAction} /> +{/if} diff --git a/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte new file mode 100644 index 0000000..fc6586f --- /dev/null +++ b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import { isSome } from '@jet/environment'; + import { + type AnnotationItem, + type Action, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem[]; + export let linkAction: Action | undefined; + + const shouldRenderAsDefinitionList = (items: AnnotationItem[]) => + !!items[0]?.heading; + + const shouldRenderAsOrderedList = (items: AnnotationItem[]) => + !!items[0]?.textPairs; + + const shouldRenderAsUnorderedList = (items: AnnotationItem[]) => + !items[0]?.text; + + const shouldRenderAsDefinitionListWithHeading = (items: AnnotationItem[]) => + items[0]?.text && items[1]?.heading; +</script> + +{#if shouldRenderAsDefinitionList(items)} + <dl class="secondary-definition-list"> + {#each items as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> +{:else if shouldRenderAsOrderedList(items)} + <ol> + {#each items as annotationItem} + {#if annotationItem.textPairs} + {#each annotationItem.textPairs as [text, subtext]} + <li> + <span class="text">{text}</span> + <span class="subtext">{subtext}</span> + </li> + {/each} + {:else} + <li>{annotationItem.text}</li> + {/if} + {/each} + </ol> +{:else if shouldRenderAsUnorderedList(items)} + <ul> + {#each items as annotationItem} + <li> + <span class="text"> + {annotationItem.text} + </span> + </li> + {/each} + </ul> +{:else if shouldRenderAsDefinitionListWithHeading(items)} + {@const [heading, ...remainingItems] = items} + <dd> + <p class="secondary-definition-list-heading">{heading.text}</p> + + <dl class="secondary-definition-list"> + {#each remainingItems as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> + </dd> +{:else} + <dd> + <ul> + {#each items as annotationItem} + <li>{annotationItem.text}</li> + {/each} + </ul> + {#if isSome(linkAction) && isFlowAction(linkAction)} + <LinkWrapper action={linkAction}> + {linkAction.title} + </LinkWrapper> + {/if} + </dd> +{/if} + +<style> + dt { + color: var(--systemSecondary); + font: var(--body-tall); + } + + dd { + white-space: pre-line; + font: var(--body-tall); + } + + ol { + counter-reset: section; + } + + ol li { + display: table-row; + font: var(--body-tall); + } + + ol li::before { + counter-increment: section; + content: counter(section) '.'; + display: table-cell; + padding-inline-end: 6px; + } + + ol li .text { + display: table-cell; + width: 100%; + } + + ol li .subtext { + display: table-cell; + } + + .secondary-definition-list-heading { + margin-bottom: 16px; + } + + .secondary-definition-list dt { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .secondary-definition-list dd:not(:last-of-type) { + margin-bottom: 16px; + } + + dd li:not(:last-of-type) { + margin-bottom: 16px; + } + + dd :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + dd :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte new file mode 100644 index 0000000..20611d3 --- /dev/null +++ b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte @@ -0,0 +1,114 @@ +<script lang="ts"> + import type { AnnotationItem_V3 } from '@jet-app/app-store/api/models'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem_V3[]; + export let summary: string | undefined; + + const formatStyledText = (text: string): string => { + return ( + text + // Replace \n with <br> + .replace(/\n/g, '<br>') + // Replace **text** with <strong>text</strong> + .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') + ); + }; +</script> + +<ul> + {#each items as annotationItem} + <li> + {#if annotationItem.$kind === 'textEncapsulation'} + <div class="text-encapsulation"> + {annotationItem.text} + </div> + {:else if annotationItem.$kind === 'linkableText'} + <div class="styled-text"> + {@html sanitizeHtml( + formatStyledText( + annotationItem.linkableText.styledText.rawText, + ), + )} + </div> + {:else if annotationItem.$kind === 'artwork'} + {#if isSystemImageArtwork(annotationItem.artwork)} + <div class="artwork-wrapper" aria-label={summary}> + <SystemImage artwork={annotationItem.artwork} /> + </div> + {/if} + {:else if annotationItem.$kind === 'textPair'} + <div class="text-pair"> + <span>{annotationItem.leadingText}</span> + <span> + {annotationItem.trailingText} + </span> + </div> + {:else if annotationItem.$kind === 'button'} + <div class="button-wrapper"> + <LinkWrapper action={annotationItem.action}> + {annotationItem.action.title} + </LinkWrapper> + </div> + {:else if annotationItem.$kind === 'spacer'} + <div class="spacer" /> + {/if} + </li> + {/each} +</ul> + +<style> + li { + font: var(--body-tall); + } + + .styled-text :global(strong) { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .text-encapsulation { + width: fit-content; + color: var(--keyColor); + border: 1px solid; + border-radius: 3px; + padding-inline: 3px; + border-color: var(--keyColor); + margin-block: 3px; + } + + .artwork-wrapper :global(svg) { + height: 18px; + width: 18px; + margin-top: 4px; + } + + .spacer { + height: 16px; + } + + .button-wrapper :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + .button-wrapper :global(a:hover) { + text-decoration: underline; + } + + .button-wrapper :global(a) :global(.external-link-arrow) { + width: 7px; + height: 7px; + fill: var(--keyColor); + margin-top: 3px; + } + + .text-pair { + display: flex; + justify-content: space-between; + } +</style> diff --git a/src/components/jet/item/AppEventItem.svelte b/src/components/jet/item/AppEventItem.svelte new file mode 100644 index 0000000..c1e5e5a --- /dev/null +++ b/src/components/jet/item/AppEventItem.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import type { AppEvent } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import SmallLockupItem from './SmallLockupItem.svelte'; + + export let item: AppEvent; + export let isArticleContext: boolean = false; + + $: artwork = item.moduleArtwork; + $: video = item.moduleVideo; + $: hasLightArtwork = item.mediaOverlayStyle === 'light'; + $: gradientColor = hasLightArtwork + ? 'rgb(240 240 240 / 48%)' + : 'rgb(83 83 83 / 48%)'; + $: shouldShowLockup = !!item.lockup && !item.hideLockupWhenNotInstalled; +</script> + +<div + class="app-event-item" + class:with-lockup={!!item.lockup && !item.hideLockupWhenNotInstalled} +> + <span class="time-indicator"> + <AppEventDate appEvent={item} /> + </span> + + <div class="lockup-container"> + <HoverWrapper hasChin={shouldShowLockup} --display="block"> + <LinkWrapper action={item.clickAction}> + <div class="text-over-artwork"> + {#if video} + <div class="video-container"> + <Video + {video} + autoplay + loop={true} + useControls={false} + profile="app-promotion" + /> + </div> + {:else if artwork} + <div class="artwork-container"> + <Artwork + {artwork} + profile={isArticleContext + ? 'app-promotion-in-article' + : 'app-promotion'} + /> + </div> + {/if} + + <div class="gradient-container"> + <GradientOverlay + --border-radius={0} + --color={gradientColor} + --height="80%" + shouldDarken={!hasLightArtwork} + /> + </div> + + <div class="text-container" class:dark={hasLightArtwork}> + <h4>{item.kind}</h4> + + <h3>{item.title}</h3> + + <LineClamp clamp={1}> + <p>{item.detail}</p> + </LineClamp> + </div> + </div> + </LinkWrapper> + </HoverWrapper> + + {#if item.lockup && shouldShowLockup} + <div class="small-lockup-container"> + <SmallLockupItem item={item.lockup} appIconProfile="app-icon" /> + </div> + {/if} + </div> +</div> + +<style> + .app-event-item { + height: 100%; + display: grid; + grid-template-areas: + 'time-indicator' + 'lockup'; + grid-template-rows: 1rem 1fr; + gap: 4px; + } + + .time-indicator { + grid-area: time-indicator; + color: var(--keyColor); + font-weight: bold; + } + + .lockup-container { + grid-area: lockup; + } + + .text-over-artwork { + /* Allow artwork, overlay and text containers to overlap by targeting the same grid area */ + display: grid; + grid-template-areas: 'content'; + } + + .artwork-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + } + + .video-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + line-height: 0; + } + + .app-event-item.with-lockup .artwork-container, + .app-event-item.with-lockup .video-container { + border-radius: 0; + } + + .gradient-container { + grid-area: content; + z-index: 1; + position: relative; + } + + .text-container { + color: var(--systemPrimary-onDark); + padding: 12px 16px; + grid-area: content; + z-index: 2; + + /* Float text to the bottom of the lockup */ + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + .text-container.dark { + color: var(--systemPrimary-onLight); + } + + .small-lockup-container { + background: var(--systemPrimary-onDark); + border-radius: 0 0 var(--global-border-radius-large) + var(--global-border-radius-large); + box-shadow: var(--shadow-small); + padding: 12px; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuinary-onDark); + } + } + + h3 { + font: var(--title-2-tall); + } + + h4 { + font: var(--callout-emphasized-tall); + } + + p { + font: var(--callout-emphasized); + } +</style> diff --git a/src/components/jet/item/ArcadeFooterItem.svelte b/src/components/jet/item/ArcadeFooterItem.svelte new file mode 100644 index 0000000..94fe61d --- /dev/null +++ b/src/components/jet/item/ArcadeFooterItem.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import type { + ArcadeFooter, + Artwork, + ImpressionableArtwork, + } from '@jet-app/app-store/api/models'; + import { unwrapOptional as unwrap } from '@jet/environment/types/optional'; + + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: ArcadeFooter; + + $: action = unwrap(item.buttonAction); + + function isImpressionableArtwork( + item: ImpressionableArtwork | Artwork, + ): item is ImpressionableArtwork { + return 'art' in item; + } + + // Sometimes data used to render an app icon is directly on `icon` but other times, in the case + // of `ImpressionableArtwork`, it's on `icon.art`. Here we are plucking the data no matter where it is. + const icons = (item.icons ?? []).map((icon) => + isImpressionableArtwork(icon) ? icon.art : icon, + ); +</script> + +<LinkWrapper {action}> + <article> + {#if icons.length} + <AppIconRiver {icons} /> + {/if} + + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <button class="get-button gray"> + {action.title} + </button> + </div> + </article> +</LinkWrapper> + +<style> + article { + --app-icon-river-speed: 120s; + display: flex; + overflow: hidden; + flex-flow: column; + padding: 20px 0 30px; + margin-bottom: 20px; + text-align: center; + border-radius: var(--global-border-radius-large); + background: var(--footerBg); + + @media (--range-small-down) { + --app-icon-river-icon-width: 88px; + } + + @media (--range-medium-up) { + --get-button-font: var(--title-3-emphasized); + } + } + + .metadata-container { + display: flex; + align-items: center; + flex-flow: column; + gap: 20px; + } + + .logo-container { + width: 128px; + + @media (--range-small-down) { + width: 88px; + } + } +</style> diff --git a/src/components/jet/item/BannerItem.svelte b/src/components/jet/item/BannerItem.svelte new file mode 100644 index 0000000..819f621 --- /dev/null +++ b/src/components/jet/item/BannerItem.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { isFlowAction, type Banner } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: Banner; +</script> + +<div class="banner"> + <p> + {item.message} + {#if isSome(item.action) && isFlowAction(item.action)} + <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> |
