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/item | |
init commit
Diffstat (limited to 'src/components/jet/item')
54 files changed, 5866 insertions, 0 deletions
diff --git a/src/components/jet/item/AccessibilityFeaturesItem.svelte b/src/components/jet/item/AccessibilityFeaturesItem.svelte new file mode 100644 index 0000000..bcbeb6c --- /dev/null +++ b/src/components/jet/item/AccessibilityFeaturesItem.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import type { AccessibilityFeatures } from '@jet-app/app-store/api/models'; + + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let item: AccessibilityFeatures; + export let isDetailView: boolean = false; +</script> + +<article + class:is-detail-view={isDetailView} + role={isDetailView ? 'presentation' : 'article'} +> + {#if !isDetailView} + {#if item.artwork && isSystemImageArtwork(item.artwork)} + <span class="icon-container" aria-hidden="true"> + <SystemImage artwork={item.artwork} /> + </span> + {/if} + <h2>{item.title}</h2> + {/if} + + <ul class:grid={item.features.length > 1 && !isDetailView}> + {#each item.features as feature} + <li> + {#if isSystemImageArtwork(feature.artwork)} + <span class="feature-icon-container" aria-hidden="true"> + <SystemImage artwork={feature.artwork} /> + </span> + {/if} + <div class="feature-content"> + <h3 class="feature-title">{feature.title}</h3> + {#if feature.description} + <span class="feature-description"> + {feature.description} + </span> + {/if} + </div> + </li> + {/each} + </ul> +</article> + +<style lang="scss"> + @use 'amp/stylekit/core/border-radiuses' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + display: flex; + flex-direction: column; + height: 100%; + padding: 30px; + gap: 8px; + text-align: center; + font: var(--body-tall); + border-radius: $global-border-radius-rounded-large; + background-color: var(--systemQuinary); + + &.is-detail-view { + padding: 0; + text-align: start; + background-color: transparent; + } + } + + .icon-container { + width: 30px; + margin: 0 auto; + } + + .icon-container :global(svg) { + width: 100%; + fill: var(--keyColor); + } + + h2 { + font: var(--title-3-emphasized); + margin-bottom: 8px; + } + + ul { + display: flex; + flex-direction: column; + gap: 25px; + } + + ul.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + li { + display: flex; + align-items: center; + justify-content: center; + text-align: start; + padding: 4px 0; + gap: 8px; + + .is-detail-view & { + gap: 10px; + justify-content: start; + align-items: flex-start; + } + } + + .grid li { + justify-content: start; + } + + .feature-icon-container { + display: inline-flex; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + + .is-detail-view & { + display: flex; + align-items: center; + + @media (prefers-color-scheme: dark) { + filter: none; + } + } + } + + .feature-icon-container :global(svg) { + width: 20px; + + .is-detail-view & { + width: 30px; + fill: var(--keyColor); + } + } + + .feature-content { + display: flex; + flex-direction: column; + gap: 6px; + } + + .feature-title { + font: var(--body-tall); + + .is-detail-view & { + color: var(--systemPrimary); + font: var(--title-2-emphasized); + } + } + + .feature-description { + color: var(--systemSecondary); + font: var(--body); + } +</style> diff --git a/src/components/jet/item/AccessibilityParagraphItem.svelte b/src/components/jet/item/AccessibilityParagraphItem.svelte new file mode 100644 index 0000000..836b52f --- /dev/null +++ b/src/components/jet/item/AccessibilityParagraphItem.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import type { AccessibilityParagraph } from '@jet-app/app-store/api/models'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let item: AccessibilityParagraph; +</script> + +<div> + <p> + <LinkableTextItem item={item.text} /> + </p> +</div> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/item/Annotation/AnnotationItem.svelte b/src/components/jet/item/Annotation/AnnotationItem.svelte new file mode 100644 index 0000000..38bb269 --- /dev/null +++ b/src/components/jet/item/Annotation/AnnotationItem.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { type Annotation } from '@jet-app/app-store/api/models'; + import ModernAnnotationItemRenderer from '~/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte'; + import LegacyAnnotationRenderer from '~/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte'; + + export let item: Annotation; + + $: ({ items, items_V3, linkAction, summary } = item); + + $: shouldRenderModernAnnotation = items_V3.length > 0; +</script> + +{#if shouldRenderModernAnnotation} + <ModernAnnotationItemRenderer items={items_V3} {summary} /> +{:else} + <LegacyAnnotationRenderer {items} {linkAction} /> +{/if} diff --git a/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte new file mode 100644 index 0000000..fc6586f --- /dev/null +++ b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import { isSome } from '@jet/environment'; + import { + type AnnotationItem, + type Action, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem[]; + export let linkAction: Action | undefined; + + const shouldRenderAsDefinitionList = (items: AnnotationItem[]) => + !!items[0]?.heading; + + const shouldRenderAsOrderedList = (items: AnnotationItem[]) => + !!items[0]?.textPairs; + + const shouldRenderAsUnorderedList = (items: AnnotationItem[]) => + !items[0]?.text; + + const shouldRenderAsDefinitionListWithHeading = (items: AnnotationItem[]) => + items[0]?.text && items[1]?.heading; +</script> + +{#if shouldRenderAsDefinitionList(items)} + <dl class="secondary-definition-list"> + {#each items as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> +{:else if shouldRenderAsOrderedList(items)} + <ol> + {#each items as annotationItem} + {#if annotationItem.textPairs} + {#each annotationItem.textPairs as [text, subtext]} + <li> + <span class="text">{text}</span> + <span class="subtext">{subtext}</span> + </li> + {/each} + {:else} + <li>{annotationItem.text}</li> + {/if} + {/each} + </ol> +{:else if shouldRenderAsUnorderedList(items)} + <ul> + {#each items as annotationItem} + <li> + <span class="text"> + {annotationItem.text} + </span> + </li> + {/each} + </ul> +{:else if shouldRenderAsDefinitionListWithHeading(items)} + {@const [heading, ...remainingItems] = items} + <dd> + <p class="secondary-definition-list-heading">{heading.text}</p> + + <dl class="secondary-definition-list"> + {#each remainingItems as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> + </dd> +{:else} + <dd> + <ul> + {#each items as annotationItem} + <li>{annotationItem.text}</li> + {/each} + </ul> + {#if isSome(linkAction) && isFlowAction(linkAction)} + <LinkWrapper action={linkAction}> + {linkAction.title} + </LinkWrapper> + {/if} + </dd> +{/if} + +<style> + dt { + color: var(--systemSecondary); + font: var(--body-tall); + } + + dd { + white-space: pre-line; + font: var(--body-tall); + } + + ol { + counter-reset: section; + } + + ol li { + display: table-row; + font: var(--body-tall); + } + + ol li::before { + counter-increment: section; + content: counter(section) '.'; + display: table-cell; + padding-inline-end: 6px; + } + + ol li .text { + display: table-cell; + width: 100%; + } + + ol li .subtext { + display: table-cell; + } + + .secondary-definition-list-heading { + margin-bottom: 16px; + } + + .secondary-definition-list dt { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .secondary-definition-list dd:not(:last-of-type) { + margin-bottom: 16px; + } + + dd li:not(:last-of-type) { + margin-bottom: 16px; + } + + dd :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + dd :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte new file mode 100644 index 0000000..20611d3 --- /dev/null +++ b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte @@ -0,0 +1,114 @@ +<script lang="ts"> + import type { AnnotationItem_V3 } from '@jet-app/app-store/api/models'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem_V3[]; + export let summary: string | undefined; + + const formatStyledText = (text: string): string => { + return ( + text + // Replace \n with <br> + .replace(/\n/g, '<br>') + // Replace **text** with <strong>text</strong> + .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') + ); + }; +</script> + +<ul> + {#each items as annotationItem} + <li> + {#if annotationItem.$kind === 'textEncapsulation'} + <div class="text-encapsulation"> + {annotationItem.text} + </div> + {:else if annotationItem.$kind === 'linkableText'} + <div class="styled-text"> + {@html sanitizeHtml( + formatStyledText( + annotationItem.linkableText.styledText.rawText, + ), + )} + </div> + {:else if annotationItem.$kind === 'artwork'} + {#if isSystemImageArtwork(annotationItem.artwork)} + <div class="artwork-wrapper" aria-label={summary}> + <SystemImage artwork={annotationItem.artwork} /> + </div> + {/if} + {:else if annotationItem.$kind === 'textPair'} + <div class="text-pair"> + <span>{annotationItem.leadingText}</span> + <span> + {annotationItem.trailingText} + </span> + </div> + {:else if annotationItem.$kind === 'button'} + <div class="button-wrapper"> + <LinkWrapper action={annotationItem.action}> + {annotationItem.action.title} + </LinkWrapper> + </div> + {:else if annotationItem.$kind === 'spacer'} + <div class="spacer" /> + {/if} + </li> + {/each} +</ul> + +<style> + li { + font: var(--body-tall); + } + + .styled-text :global(strong) { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .text-encapsulation { + width: fit-content; + color: var(--keyColor); + border: 1px solid; + border-radius: 3px; + padding-inline: 3px; + border-color: var(--keyColor); + margin-block: 3px; + } + + .artwork-wrapper :global(svg) { + height: 18px; + width: 18px; + margin-top: 4px; + } + + .spacer { + height: 16px; + } + + .button-wrapper :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + .button-wrapper :global(a:hover) { + text-decoration: underline; + } + + .button-wrapper :global(a) :global(.external-link-arrow) { + width: 7px; + height: 7px; + fill: var(--keyColor); + margin-top: 3px; + } + + .text-pair { + display: flex; + justify-content: space-between; + } +</style> diff --git a/src/components/jet/item/AppEventItem.svelte b/src/components/jet/item/AppEventItem.svelte new file mode 100644 index 0000000..c1e5e5a --- /dev/null +++ b/src/components/jet/item/AppEventItem.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import type { AppEvent } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import SmallLockupItem from './SmallLockupItem.svelte'; + + export let item: AppEvent; + export let isArticleContext: boolean = false; + + $: artwork = item.moduleArtwork; + $: video = item.moduleVideo; + $: hasLightArtwork = item.mediaOverlayStyle === 'light'; + $: gradientColor = hasLightArtwork + ? 'rgb(240 240 240 / 48%)' + : 'rgb(83 83 83 / 48%)'; + $: shouldShowLockup = !!item.lockup && !item.hideLockupWhenNotInstalled; +</script> + +<div + class="app-event-item" + class:with-lockup={!!item.lockup && !item.hideLockupWhenNotInstalled} +> + <span class="time-indicator"> + <AppEventDate appEvent={item} /> + </span> + + <div class="lockup-container"> + <HoverWrapper hasChin={shouldShowLockup} --display="block"> + <LinkWrapper action={item.clickAction}> + <div class="text-over-artwork"> + {#if video} + <div class="video-container"> + <Video + {video} + autoplay + loop={true} + useControls={false} + profile="app-promotion" + /> + </div> + {:else if artwork} + <div class="artwork-container"> + <Artwork + {artwork} + profile={isArticleContext + ? 'app-promotion-in-article' + : 'app-promotion'} + /> + </div> + {/if} + + <div class="gradient-container"> + <GradientOverlay + --border-radius={0} + --color={gradientColor} + --height="80%" + shouldDarken={!hasLightArtwork} + /> + </div> + + <div class="text-container" class:dark={hasLightArtwork}> + <h4>{item.kind}</h4> + + <h3>{item.title}</h3> + + <LineClamp clamp={1}> + <p>{item.detail}</p> + </LineClamp> + </div> + </div> + </LinkWrapper> + </HoverWrapper> + + {#if item.lockup && shouldShowLockup} + <div class="small-lockup-container"> + <SmallLockupItem item={item.lockup} appIconProfile="app-icon" /> + </div> + {/if} + </div> +</div> + +<style> + .app-event-item { + height: 100%; + display: grid; + grid-template-areas: + 'time-indicator' + 'lockup'; + grid-template-rows: 1rem 1fr; + gap: 4px; + } + + .time-indicator { + grid-area: time-indicator; + color: var(--keyColor); + font-weight: bold; + } + + .lockup-container { + grid-area: lockup; + } + + .text-over-artwork { + /* Allow artwork, overlay and text containers to overlap by targeting the same grid area */ + display: grid; + grid-template-areas: 'content'; + } + + .artwork-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + } + + .video-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + line-height: 0; + } + + .app-event-item.with-lockup .artwork-container, + .app-event-item.with-lockup .video-container { + border-radius: 0; + } + + .gradient-container { + grid-area: content; + z-index: 1; + position: relative; + } + + .text-container { + color: var(--systemPrimary-onDark); + padding: 12px 16px; + grid-area: content; + z-index: 2; + + /* Float text to the bottom of the lockup */ + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + .text-container.dark { + color: var(--systemPrimary-onLight); + } + + .small-lockup-container { + background: var(--systemPrimary-onDark); + border-radius: 0 0 var(--global-border-radius-large) + var(--global-border-radius-large); + box-shadow: var(--shadow-small); + padding: 12px; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuinary-onDark); + } + } + + h3 { + font: var(--title-2-tall); + } + + h4 { + font: var(--callout-emphasized-tall); + } + + p { + font: var(--callout-emphasized); + } +</style> diff --git a/src/components/jet/item/ArcadeFooterItem.svelte b/src/components/jet/item/ArcadeFooterItem.svelte new file mode 100644 index 0000000..94fe61d --- /dev/null +++ b/src/components/jet/item/ArcadeFooterItem.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import type { + ArcadeFooter, + Artwork, + ImpressionableArtwork, + } from '@jet-app/app-store/api/models'; + import { unwrapOptional as unwrap } from '@jet/environment/types/optional'; + + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: ArcadeFooter; + + $: action = unwrap(item.buttonAction); + + function isImpressionableArtwork( + item: ImpressionableArtwork | Artwork, + ): item is ImpressionableArtwork { + return 'art' in item; + } + + // Sometimes data used to render an app icon is directly on `icon` but other times, in the case + // of `ImpressionableArtwork`, it's on `icon.art`. Here we are plucking the data no matter where it is. + const icons = (item.icons ?? []).map((icon) => + isImpressionableArtwork(icon) ? icon.art : icon, + ); +</script> + +<LinkWrapper {action}> + <article> + {#if icons.length} + <AppIconRiver {icons} /> + {/if} + + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <button class="get-button gray"> + {action.title} + </button> + </div> + </article> +</LinkWrapper> + +<style> + article { + --app-icon-river-speed: 120s; + display: flex; + overflow: hidden; + flex-flow: column; + padding: 20px 0 30px; + margin-bottom: 20px; + text-align: center; + border-radius: var(--global-border-radius-large); + background: var(--footerBg); + + @media (--range-small-down) { + --app-icon-river-icon-width: 88px; + } + + @media (--range-medium-up) { + --get-button-font: var(--title-3-emphasized); + } + } + + .metadata-container { + display: flex; + align-items: center; + flex-flow: column; + gap: 20px; + } + + .logo-container { + width: 128px; + + @media (--range-small-down) { + width: 88px; + } + } +</style> diff --git a/src/components/jet/item/BannerItem.svelte b/src/components/jet/item/BannerItem.svelte new file mode 100644 index 0000000..819f621 --- /dev/null +++ b/src/components/jet/item/BannerItem.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { isFlowAction, type Banner } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: Banner; +</script> + +<div class="banner"> + <p> + {item.message} + {#if isSome(item.action) && isFlowAction(item.action)} + <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> |
