init commit
This commit is contained in:
159
src/components/jet/item/AccessibilityFeaturesItem.svelte
Normal file
159
src/components/jet/item/AccessibilityFeaturesItem.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import type { AccessibilityFeatures } from '@jet-app/app-store/api/models';
|
||||
|
||||
import SystemImage, {
|
||||
isSystemImageArtwork,
|
||||
} from '~/components/SystemImage.svelte';
|
||||
|
||||
export let item: AccessibilityFeatures;
|
||||
export let isDetailView: boolean = false;
|
||||
</script>
|
||||
|
||||
<article
|
||||
class:is-detail-view={isDetailView}
|
||||
role={isDetailView ? 'presentation' : 'article'}
|
||||
>
|
||||
{#if !isDetailView}
|
||||
{#if item.artwork && isSystemImageArtwork(item.artwork)}
|
||||
<span class="icon-container" aria-hidden="true">
|
||||
<SystemImage artwork={item.artwork} />
|
||||
</span>
|
||||
{/if}
|
||||
<h2>{item.title}</h2>
|
||||
{/if}
|
||||
|
||||
<ul class:grid={item.features.length > 1 && !isDetailView}>
|
||||
{#each item.features as feature}
|
||||
<li>
|
||||
{#if isSystemImageArtwork(feature.artwork)}
|
||||
<span class="feature-icon-container" aria-hidden="true">
|
||||
<SystemImage artwork={feature.artwork} />
|
||||
</span>
|
||||
{/if}
|
||||
<div class="feature-content">
|
||||
<h3 class="feature-title">{feature.title}</h3>
|
||||
{#if feature.description}
|
||||
<span class="feature-description">
|
||||
{feature.description}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'amp/stylekit/core/border-radiuses' as *;
|
||||
@use '@amp/web-shared-styles/app/core/globalvars' as *;
|
||||
|
||||
article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 30px;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
font: var(--body-tall);
|
||||
border-radius: $global-border-radius-rounded-large;
|
||||
background-color: var(--systemQuinary);
|
||||
|
||||
&.is-detail-view {
|
||||
padding: 0;
|
||||
text-align: start;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
width: 30px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.icon-container :global(svg) {
|
||||
width: 100%;
|
||||
fill: var(--keyColor);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font: var(--title-3-emphasized);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
}
|
||||
|
||||
ul.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: start;
|
||||
padding: 4px 0;
|
||||
gap: 8px;
|
||||
|
||||
.is-detail-view & {
|
||||
gap: 10px;
|
||||
justify-content: start;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.grid li {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.feature-icon-container {
|
||||
display: inline-flex;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.is-detail-view & {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
filter: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.feature-icon-container :global(svg) {
|
||||
width: 20px;
|
||||
|
||||
.is-detail-view & {
|
||||
width: 30px;
|
||||
fill: var(--keyColor);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font: var(--body-tall);
|
||||
|
||||
.is-detail-view & {
|
||||
color: var(--systemPrimary);
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: var(--systemSecondary);
|
||||
font: var(--body);
|
||||
}
|
||||
</style>
|
||||
22
src/components/jet/item/AccessibilityParagraphItem.svelte
Normal file
22
src/components/jet/item/AccessibilityParagraphItem.svelte
Normal file
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import type { AccessibilityParagraph } from '@jet-app/app-store/api/models';
|
||||
import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
|
||||
|
||||
export let item: AccessibilityParagraph;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
<LinkableTextItem item={item.text} />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
p {
|
||||
font: var(--body-tall);
|
||||
}
|
||||
|
||||
p :global(a) {
|
||||
color: var(--keyColor);
|
||||
}
|
||||
</style>
|
||||
17
src/components/jet/item/Annotation/AnnotationItem.svelte
Normal file
17
src/components/jet/item/Annotation/AnnotationItem.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { type Annotation } from '@jet-app/app-store/api/models';
|
||||
import ModernAnnotationItemRenderer from '~/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte';
|
||||
import LegacyAnnotationRenderer from '~/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte';
|
||||
|
||||
export let item: Annotation;
|
||||
|
||||
$: ({ items, items_V3, linkAction, summary } = item);
|
||||
|
||||
$: shouldRenderModernAnnotation = items_V3.length > 0;
|
||||
</script>
|
||||
|
||||
{#if shouldRenderModernAnnotation}
|
||||
<ModernAnnotationItemRenderer items={items_V3} {summary} />
|
||||
{:else}
|
||||
<LegacyAnnotationRenderer {items} {linkAction} />
|
||||
{/if}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
176
src/components/jet/item/AppEventItem.svelte
Normal file
176
src/components/jet/item/AppEventItem.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import type { AppEvent } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import GradientOverlay from '~/components/GradientOverlay.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import Video from '~/components/jet/Video.svelte';
|
||||
import AppEventDate from '~/components/AppEventDate.svelte';
|
||||
import SmallLockupItem from './SmallLockupItem.svelte';
|
||||
|
||||
export let item: AppEvent;
|
||||
export let isArticleContext: boolean = false;
|
||||
|
||||
$: artwork = item.moduleArtwork;
|
||||
$: video = item.moduleVideo;
|
||||
$: hasLightArtwork = item.mediaOverlayStyle === 'light';
|
||||
$: gradientColor = hasLightArtwork
|
||||
? 'rgb(240 240 240 / 48%)'
|
||||
: 'rgb(83 83 83 / 48%)';
|
||||
$: shouldShowLockup = !!item.lockup && !item.hideLockupWhenNotInstalled;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="app-event-item"
|
||||
class:with-lockup={!!item.lockup && !item.hideLockupWhenNotInstalled}
|
||||
>
|
||||
<span class="time-indicator">
|
||||
<AppEventDate appEvent={item} />
|
||||
</span>
|
||||
|
||||
<div class="lockup-container">
|
||||
<HoverWrapper hasChin={shouldShowLockup} --display="block">
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<div class="text-over-artwork">
|
||||
{#if video}
|
||||
<div class="video-container">
|
||||
<Video
|
||||
{video}
|
||||
autoplay
|
||||
loop={true}
|
||||
useControls={false}
|
||||
profile="app-promotion"
|
||||
/>
|
||||
</div>
|
||||
{:else if artwork}
|
||||
<div class="artwork-container">
|
||||
<Artwork
|
||||
{artwork}
|
||||
profile={isArticleContext
|
||||
? 'app-promotion-in-article'
|
||||
: 'app-promotion'}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gradient-container">
|
||||
<GradientOverlay
|
||||
--border-radius={0}
|
||||
--color={gradientColor}
|
||||
--height="80%"
|
||||
shouldDarken={!hasLightArtwork}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="text-container" class:dark={hasLightArtwork}>
|
||||
<h4>{item.kind}</h4>
|
||||
|
||||
<h3>{item.title}</h3>
|
||||
|
||||
<LineClamp clamp={1}>
|
||||
<p>{item.detail}</p>
|
||||
</LineClamp>
|
||||
</div>
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
</HoverWrapper>
|
||||
|
||||
{#if item.lockup && shouldShowLockup}
|
||||
<div class="small-lockup-container">
|
||||
<SmallLockupItem item={item.lockup} appIconProfile="app-icon" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-event-item {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'time-indicator'
|
||||
'lockup';
|
||||
grid-template-rows: 1rem 1fr;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.time-indicator {
|
||||
grid-area: time-indicator;
|
||||
color: var(--keyColor);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.lockup-container {
|
||||
grid-area: lockup;
|
||||
}
|
||||
|
||||
.text-over-artwork {
|
||||
/* Allow artwork, overlay and text containers to overlap by targeting the same grid area */
|
||||
display: grid;
|
||||
grid-template-areas: 'content';
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
grid-area: content;
|
||||
border-radius: var(--global-border-radius-large);
|
||||
}
|
||||
|
||||
.video-container {
|
||||
grid-area: content;
|
||||
border-radius: var(--global-border-radius-large);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.app-event-item.with-lockup .artwork-container,
|
||||
.app-event-item.with-lockup .video-container {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.gradient-container {
|
||||
grid-area: content;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
color: var(--systemPrimary-onDark);
|
||||
padding: 12px 16px;
|
||||
grid-area: content;
|
||||
z-index: 2;
|
||||
|
||||
/* Float text to the bottom of the lockup */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.text-container.dark {
|
||||
color: var(--systemPrimary-onLight);
|
||||
}
|
||||
|
||||
.small-lockup-container {
|
||||
background: var(--systemPrimary-onDark);
|
||||
border-radius: 0 0 var(--global-border-radius-large)
|
||||
var(--global-border-radius-large);
|
||||
box-shadow: var(--shadow-small);
|
||||
padding: 12px;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: var(--systemQuinary-onDark);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-2-tall);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font: var(--callout-emphasized-tall);
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--callout-emphasized);
|
||||
}
|
||||
</style>
|
||||
83
src/components/jet/item/ArcadeFooterItem.svelte
Normal file
83
src/components/jet/item/ArcadeFooterItem.svelte
Normal file
@@ -0,0 +1,83 @@
|
||||
<script lang="ts">
|
||||
import type {
|
||||
ArcadeFooter,
|
||||
Artwork,
|
||||
ImpressionableArtwork,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
import { unwrapOptional as unwrap } from '@jet/environment/types/optional';
|
||||
|
||||
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
|
||||
import AppIconRiver from '~/components/AppIconRiver.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
|
||||
export let item: ArcadeFooter;
|
||||
|
||||
$: action = unwrap(item.buttonAction);
|
||||
|
||||
function isImpressionableArtwork(
|
||||
item: ImpressionableArtwork | Artwork,
|
||||
): item is ImpressionableArtwork {
|
||||
return 'art' in item;
|
||||
}
|
||||
|
||||
// Sometimes data used to render an app icon is directly on `icon` but other times, in the case
|
||||
// of `ImpressionableArtwork`, it's on `icon.art`. Here we are plucking the data no matter where it is.
|
||||
const icons = (item.icons ?? []).map((icon) =>
|
||||
isImpressionableArtwork(icon) ? icon.art : icon,
|
||||
);
|
||||
</script>
|
||||
|
||||
<LinkWrapper {action}>
|
||||
<article>
|
||||
{#if icons.length}
|
||||
<AppIconRiver {icons} />
|
||||
{/if}
|
||||
|
||||
<div class="metadata-container">
|
||||
<div class="logo-container">
|
||||
<AppleArcadeLogo />
|
||||
</div>
|
||||
|
||||
<button class="get-button gray">
|
||||
{action.title}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
article {
|
||||
--app-icon-river-speed: 120s;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex-flow: column;
|
||||
padding: 20px 0 30px;
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
border-radius: var(--global-border-radius-large);
|
||||
background: var(--footerBg);
|
||||
|
||||
@media (--range-small-down) {
|
||||
--app-icon-river-icon-width: 88px;
|
||||
}
|
||||
|
||||
@media (--range-medium-up) {
|
||||
--get-button-font: var(--title-3-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-flow: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 128px;
|
||||
|
||||
@media (--range-small-down) {
|
||||
width: 88px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
src/components/jet/item/BannerItem.svelte
Normal file
37
src/components/jet/item/BannerItem.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { isFlowAction, type Banner } from '@jet-app/app-store/api/models';
|
||||
import { isSome } from '@jet/environment';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
|
||||
export let item: Banner;
|
||||
</script>
|
||||
|
||||
<div class="banner">
|
||||
<p>
|
||||
{item.message}
|
||||
{#if isSome(item.action) && isFlowAction(item.action)}
|
||||
<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>
|
||||
300
src/components/jet/item/BrickItem.svelte
Normal file
300
src/components/jet/item/BrickItem.svelte
Normal file
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import type { Brick } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import GradientOverlay from '~/components/GradientOverlay.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import {
|
||||
colorAsString,
|
||||
getBackgroundGradientCSSVarsFromArtworks,
|
||||
getLuminanceForRGB,
|
||||
} from '~/utils/color';
|
||||
import { isRtl } from '~/utils/locale';
|
||||
|
||||
export let item: Brick;
|
||||
export let shouldOverlayDescription: boolean = false;
|
||||
|
||||
const rtlArtwork = item.artworks?.[1] || item.rtlArtwork;
|
||||
const artwork = isRtl() && rtlArtwork ? rtlArtwork : item.artworks?.[0];
|
||||
const { collectionIcons } = item;
|
||||
|
||||
const gradientColor: string = artwork?.backgroundColor
|
||||
? colorAsString(artwork.backgroundColor)
|
||||
: 'rgb(0 0 0 / 62%)';
|
||||
|
||||
let backgroundGradientCssVars: string | undefined = undefined;
|
||||
|
||||
if (collectionIcons && collectionIcons.length > 1) {
|
||||
// If there are multiple app icons, we build a string of CSS variables from the icons
|
||||
// background colors to fill as many of the lockups quadrants as possible.
|
||||
backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
|
||||
collectionIcons,
|
||||
{
|
||||
// sorts from darkest to lightest
|
||||
sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
|
||||
shouldRemoveGreys: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<LinkWrapper
|
||||
action={item.clickAction}
|
||||
label={item.accessibilityLabel || item.clickAction?.title}
|
||||
>
|
||||
<div class="container">
|
||||
<HoverWrapper>
|
||||
{#if artwork}
|
||||
<Artwork
|
||||
{artwork}
|
||||
profile={shouldOverlayDescription ? 'small-brick' : 'brick'}
|
||||
/>
|
||||
{:else if backgroundGradientCssVars}
|
||||
<div
|
||||
class="background-gradient"
|
||||
style={backgroundGradientCssVars}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<GradientOverlay --color={gradientColor} />
|
||||
{/if}
|
||||
|
||||
<div class="text-container">
|
||||
<div class="metadata-container">
|
||||
{#if item.caption}
|
||||
<LineClamp clamp={1}>
|
||||
<h4>{item.caption}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<LineClamp clamp={3}>
|
||||
<h3 class="title">
|
||||
{@html sanitizeHtml(item.title)}
|
||||
</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.subtitle}
|
||||
<LineClamp clamp={2}>
|
||||
<p>{item.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if !artwork && collectionIcons}
|
||||
<ul class="app-icons">
|
||||
{#each collectionIcons?.slice(0, 8) as collectionIcon}
|
||||
<li class="app-icon-container">
|
||||
<AppIcon
|
||||
icon={collectionIcon}
|
||||
profile="brick-app-icon"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</HoverWrapper>
|
||||
|
||||
{#if item.shortEditorialDescription}
|
||||
<h3
|
||||
class="editorial-description"
|
||||
class:overlaid={shouldOverlayDescription}
|
||||
>
|
||||
{item.shortEditorialDescription}
|
||||
</h3>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
.container {
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
container-name: container;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
width: 100%;
|
||||
align-self: end;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 20px;
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
margin-inline-end: 5%;
|
||||
}
|
||||
|
||||
.title {
|
||||
font: var(--title-1-emphasized);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 3px;
|
||||
font: var(--callout-emphasized);
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 6px;
|
||||
font: var(--body-emphasized);
|
||||
}
|
||||
|
||||
.editorial-description {
|
||||
margin-top: 8px;
|
||||
font: var(--title-3);
|
||||
}
|
||||
|
||||
.editorial-description.overlaid {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 9px;
|
||||
padding: 0 20px;
|
||||
color: white;
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
|
||||
@property --top-left-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 20%;
|
||||
}
|
||||
|
||||
@property --bottom-left-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 40%;
|
||||
}
|
||||
|
||||
@property --top-right-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 55%;
|
||||
}
|
||||
|
||||
@property --bottom-right-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 50%;
|
||||
}
|
||||
|
||||
.container .background-gradient {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: radial-gradient(
|
||||
circle at 3% -50%,
|
||||
var(--top-left, #000) var(--top-left-stop),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at -50% 120%,
|
||||
var(--bottom-left, #000) var(--bottom-left-stop),
|
||||
transparent 80%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 66% -175%,
|
||||
var(--top-right, #000) var(--top-right-stop),
|
||||
transparent 80%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 62% 100%,
|
||||
var(--bottom-right, #000) var(--bottom-right-stop),
|
||||
transparent 100%
|
||||
);
|
||||
animation: gradient-hover 8s infinite alternate-reverse;
|
||||
animation-play-state: paused;
|
||||
}
|
||||
|
||||
@keyframes gradient-hover {
|
||||
0% {
|
||||
--top-left-stop: 20%;
|
||||
--bottom-left-stop: 40%;
|
||||
--top-right-stop: 55%;
|
||||
--bottom-right-stop: 50%;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
50% {
|
||||
--top-left-stop: 25%;
|
||||
--bottom-left-stop: 15%;
|
||||
--top-right-stop: 70%;
|
||||
--bottom-right-stop: 30%;
|
||||
background-size: 130% 130%;
|
||||
}
|
||||
|
||||
100% {
|
||||
--top-left-stop: 15%;
|
||||
--bottom-left-stop: 20%;
|
||||
--top-right-stop: 55%;
|
||||
--bottom-right-stop: 20%;
|
||||
background-size: 110% 110%;
|
||||
}
|
||||
}
|
||||
|
||||
.container:hover .background-gradient {
|
||||
animation-play-state: running;
|
||||
}
|
||||
|
||||
.app-icons {
|
||||
display: grid;
|
||||
align-self: center;
|
||||
flex-direction: row;
|
||||
width: 44%;
|
||||
grid-template-rows: auto auto;
|
||||
grid-auto-flow: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.app-icons li:nth-child(even) {
|
||||
inset-inline-start: 40px;
|
||||
}
|
||||
|
||||
@container container (max-width: 298px) {
|
||||
.title {
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
|
||||
.text-container {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.editorial-description.overlaid {
|
||||
bottom: 16px;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.app-icons {
|
||||
width: 36%;
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
width: 50px;
|
||||
}
|
||||
}
|
||||
|
||||
@container container (min-width: 440px) {
|
||||
.app-icon-container {
|
||||
width: 83px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
39
src/components/jet/item/ContentModal.svelte
Normal file
39
src/components/jet/item/ContentModal.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import ContentModal from '@amp/web-app-components/src/components/Modal/ContentModal.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { getJet } from '~/jet';
|
||||
|
||||
export let title: string | null;
|
||||
export let subtitle: string | null;
|
||||
export let text: string | null = null;
|
||||
export let dialogTitleId: string | null = null;
|
||||
export let targetId: string = 'close';
|
||||
|
||||
const i18n = getI18n();
|
||||
const jet = getJet();
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
const translateFn = (key: string) => $i18n.t(key);
|
||||
|
||||
const handleCloseModal = () => {
|
||||
dispatch('close');
|
||||
jet.recordCustomMetricsEvent({
|
||||
eventType: 'click',
|
||||
targetId,
|
||||
targetType: 'button',
|
||||
actionType: 'close',
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<ContentModal
|
||||
on:close={handleCloseModal}
|
||||
{translateFn}
|
||||
{title}
|
||||
{subtitle}
|
||||
text={text || undefined}
|
||||
{dialogTitleId}
|
||||
>
|
||||
<slot name="content" slot="content" />
|
||||
</ContentModal>
|
||||
41
src/components/jet/item/EditorialCardItem.svelte
Normal file
41
src/components/jet/item/EditorialCardItem.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { EditorialCard } from '@jet-app/app-store/api/models';
|
||||
|
||||
import Hero from '~/components/hero/Hero.svelte';
|
||||
import AppEventDate from '~/components/AppEventDate.svelte';
|
||||
import AppLockupDetail from '~/components/hero/AppLockupDetail.svelte';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
import { isRtl } from '~/utils/locale';
|
||||
|
||||
export let item: EditorialCard;
|
||||
|
||||
$: isPortraitLayout = $mediaQueries === 'xsmall';
|
||||
</script>
|
||||
|
||||
<Hero
|
||||
action={item.clickAction}
|
||||
artwork={item.artwork}
|
||||
subtitle={item.subtitle}
|
||||
title={item.title}
|
||||
pinArtworkToHorizontalEnd={true}
|
||||
backgroundColor={item.artwork?.backgroundColor}
|
||||
isMediaDark={item.mediaOverlayStyle === 'dark'}
|
||||
profileOverride={isPortraitLayout ? 'large-hero-portrait-iphone' : null}
|
||||
>
|
||||
<svelte:fragment slot="eyebrow">
|
||||
{#if item.appEventFormattedDates}
|
||||
<AppEventDate formattedDates={item.appEventFormattedDates} />
|
||||
{:else}
|
||||
{item.caption}
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
|
||||
<svelte:fragment slot="details">
|
||||
{#if item.lockup}
|
||||
<AppLockupDetail
|
||||
lockup={item.lockup}
|
||||
isOnDarkBackground={item.mediaOverlayStyle === 'dark'}
|
||||
/>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Hero>
|
||||
93
src/components/jet/item/FooterLockupItem.svelte
Normal file
93
src/components/jet/item/FooterLockupItem.svelte
Normal file
@@ -0,0 +1,93 @@
|
||||
<script lang="ts">
|
||||
import type { Lockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let item: Lockup;
|
||||
|
||||
const i18n = getI18n();
|
||||
</script>
|
||||
|
||||
<div class="footer-lockup-item">
|
||||
<LinkWrapper
|
||||
action={item.clickAction}
|
||||
label={`${$i18n.t('ASE.Web.AppStore.View')} ${
|
||||
item.title ? item.title : null
|
||||
}`}
|
||||
>
|
||||
{#if item.icon}
|
||||
<AppIcon icon={item.icon} profile="app-icon-small" />
|
||||
{/if}
|
||||
|
||||
<div>
|
||||
{#if item.heading}
|
||||
<LineClamp clamp={1}>
|
||||
<h4 dir="auto">{item.heading}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<LineClamp clamp={1}>
|
||||
<h3 dir="auto">{item.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.subtitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p dir="auto">{item.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<span class="get-button blue" aria-hidden="true">
|
||||
{$i18n.t('ASE.Web.AppStore.View')}
|
||||
</span>
|
||||
</LinkWrapper>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.footer-lockup-item > :global(a) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
padding: 32px;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
border-radius: var(--global-border-radius-small);
|
||||
background-color: var(--systemQuinary);
|
||||
transition: background-color 210ms ease-out;
|
||||
}
|
||||
|
||||
.footer-lockup-item > :global(a:hover) {
|
||||
--darken-amount: 2%;
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--systemQuinary) calc(100% - var(--darken-amount)),
|
||||
black
|
||||
);
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--darken-amount: 10%;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 4px;
|
||||
font: var(--title-2-emphasized);
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
h4 {
|
||||
text-transform: uppercase;
|
||||
font: var(--subhead-emphasized);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
</style>
|
||||
60
src/components/jet/item/HeroCarouselItem.svelte
Normal file
60
src/components/jet/item/HeroCarouselItem.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
@component
|
||||
Component for rendering a `HeroCarouselItem` view-model from the App Store Client
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { HeroCarouselItem } from '@jet-app/app-store/api/models';
|
||||
|
||||
import Hero from '~/components/hero/Hero.svelte';
|
||||
import HeroAppLockup from '~/components/hero/AppLockupDetail.svelte';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
|
||||
export let item: HeroCarouselItem;
|
||||
|
||||
const {
|
||||
titleText,
|
||||
badgeText,
|
||||
overlayType,
|
||||
callToActionText,
|
||||
lockup: overlayLockup,
|
||||
clickAction,
|
||||
descriptionText,
|
||||
} = item.overlay || {};
|
||||
|
||||
$: artwork = item.artwork || item.video?.preview;
|
||||
$: isXSmallViewport = $mediaQueries === 'xsmall';
|
||||
$: video = isXSmallViewport ? item.portraitVideo : item.video;
|
||||
</script>
|
||||
|
||||
<Hero
|
||||
{artwork}
|
||||
{video}
|
||||
title={titleText}
|
||||
eyebrow={badgeText}
|
||||
action={clickAction}
|
||||
backgroundColor={item.backgroundColor}
|
||||
subtitle={descriptionText}
|
||||
isMediaDark={item.isMediaDark}
|
||||
collectionIcons={item.collectionIcons}
|
||||
>
|
||||
<svelte:fragment slot="details" let:isPortraitLayout>
|
||||
{#if overlayLockup && overlayType === 'singleModule'}
|
||||
<HeroAppLockup lockup={overlayLockup} />
|
||||
{:else if callToActionText && !isPortraitLayout}
|
||||
<div class="button-container">
|
||||
<span class="get-button transparent">
|
||||
{callToActionText}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</Hero>
|
||||
|
||||
<style>
|
||||
.button-container {
|
||||
--get-button-font: var(--title-3-bold);
|
||||
margin-top: 16px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
74
src/components/jet/item/InAppPurchaseLockup.svelte
Normal file
74
src/components/jet/item/InAppPurchaseLockup.svelte
Normal file
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { InAppPurchaseLockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import PlusIcon from '~/sf-symbols/plus.heavy.svg';
|
||||
|
||||
export let item: InAppPurchaseLockup;
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<div class="artwork-container">
|
||||
<PlusIcon class="plus-icon" aria-hidden="true" />
|
||||
<Artwork artwork={item.icon} profile="in-app-purchase" />
|
||||
</div>
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if item.title}
|
||||
<LineClamp clamp={1}>
|
||||
<h3>{item.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.productDescription}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{item.productDescription}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.offerDisplayProperties.titles}
|
||||
<p>
|
||||
{item.offerDisplayProperties.titles.discountUnownedParent ||
|
||||
item.offerDisplayProperties.titles.standard}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.artwork-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 8%;
|
||||
border-radius: var(--global-border-radius-small);
|
||||
background: var(--systemQuinary);
|
||||
}
|
||||
|
||||
.artwork-container :global(.plus-icon) {
|
||||
position: absolute;
|
||||
top: 6%;
|
||||
width: 9%;
|
||||
inset-inline-end: 5%;
|
||||
}
|
||||
|
||||
.artwork-container :global(.artwork-component) {
|
||||
border-radius: var(--global-border-radius-small) 43%
|
||||
var(--global-border-radius-small) var(--global-border-radius-small);
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--body-tall);
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--callout-tall);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
</style>
|
||||
106
src/components/jet/item/LargeBrickItem.svelte
Normal file
106
src/components/jet/item/LargeBrickItem.svelte
Normal file
@@ -0,0 +1,106 @@
|
||||
<script lang="ts">
|
||||
import type { Brick } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import GradientOverlay from '~/components/GradientOverlay.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
import { isRtl } from '~/utils/locale';
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
|
||||
export let item: Brick;
|
||||
const artwork =
|
||||
isRtl() && item.rtlArtwork ? item.rtlArtwork : item.artworks?.[0];
|
||||
const collectionIcon = item.collectionIcons?.[0];
|
||||
let artworkFallbackColor: string | null = null;
|
||||
|
||||
const gradientOverlayColor: string = artwork?.backgroundColor
|
||||
? colorAsString(artwork.backgroundColor)
|
||||
: '#000';
|
||||
|
||||
if (!artwork) {
|
||||
artworkFallbackColor = collectionIcon?.backgroundColor
|
||||
? colorAsString(collectionIcon.backgroundColor)
|
||||
: '#000';
|
||||
}
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<HoverWrapper>
|
||||
{#if artwork}
|
||||
<div class="artwork-container">
|
||||
<Artwork {artwork} profile="large-brick" />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="gradient-container"
|
||||
style={`--color: ${artworkFallbackColor};`}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="text-container">
|
||||
<div class="metadata-container">
|
||||
{#if item.caption}
|
||||
<LineClamp clamp={1}>
|
||||
<h4>{item.caption}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<LineClamp clamp={2}>
|
||||
<h3>{@html sanitizeHtml(item.title)}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.subtitle}
|
||||
<LineClamp clamp={2}>
|
||||
<p>{item.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<GradientOverlay --color={gradientOverlayColor} />
|
||||
</HoverWrapper>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
.artwork-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.gradient-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: var(--color);
|
||||
}
|
||||
|
||||
.text-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 66%;
|
||||
padding-inline: 20px;
|
||||
padding-bottom: 20px;
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-1-emphasized);
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font: var(--callout-emphasized);
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--body-emphasized);
|
||||
margin-top: 6px;
|
||||
}
|
||||
</style>
|
||||
268
src/components/jet/item/LargeHeroBreakoutItem.svelte
Normal file
268
src/components/jet/item/LargeHeroBreakoutItem.svelte
Normal file
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Artwork as JetArtworkType,
|
||||
type LargeHeroBreakout,
|
||||
isFlowAction,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
import { isSome } from '@jet/environment/types/optional';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import SFSymbol from '~/components/SFSymbol.svelte';
|
||||
import Video from '~/components/jet/Video.svelte';
|
||||
import type { NamedProfile } from '~/config/components/artwork';
|
||||
import { colorAsString, isRGBColor, isDark } from '~/utils/color';
|
||||
import { isRtl } from '~/utils/locale';
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
|
||||
export let item: LargeHeroBreakout;
|
||||
|
||||
let profile: NamedProfile;
|
||||
let artwork: JetArtworkType | undefined;
|
||||
let gradientColor: string;
|
||||
|
||||
const {
|
||||
collectionIcons = [],
|
||||
editorialDisplayOptions,
|
||||
rtlArtwork,
|
||||
video,
|
||||
details: { callToActionButtonAction: action },
|
||||
} = item;
|
||||
const canUseRTLArtwork = isRtl() && rtlArtwork;
|
||||
const shouldShowCollectionIcons =
|
||||
collectionIcons?.length > 1 && !editorialDisplayOptions.suppressLockup;
|
||||
|
||||
$: artwork =
|
||||
(canUseRTLArtwork ? rtlArtwork : item.artwork) || video?.preview;
|
||||
$: doesArtworkHaveDarkBackground =
|
||||
artwork?.backgroundColor &&
|
||||
isRGBColor(artwork.backgroundColor) &&
|
||||
isDark(artwork.backgroundColor);
|
||||
$: isBackgroundDark = item.isMediaDark ?? doesArtworkHaveDarkBackground;
|
||||
|
||||
$: profile =
|
||||
$mediaQueries === 'xsmall'
|
||||
? 'large-hero-portrait-iphone'
|
||||
: canUseRTLArtwork
|
||||
? 'large-hero-breakout-rtl'
|
||||
: 'large-hero-breakout';
|
||||
|
||||
$: gradientColor = artwork?.backgroundColor
|
||||
? colorAsString(artwork.backgroundColor)
|
||||
: '#000';
|
||||
</script>
|
||||
|
||||
<LinkWrapper {action}>
|
||||
<HoverWrapper>
|
||||
<div class="artwork-container">
|
||||
{#if video && $mediaQueries !== 'xsmall' && !canUseRTLArtwork}
|
||||
<Video {video} {profile} autoplay loop useControls={false} />
|
||||
{:else if artwork}
|
||||
<Artwork {artwork} {profile} />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="gradient" style="--color: {gradientColor};" />
|
||||
|
||||
<div
|
||||
class="text-container"
|
||||
class:on-dark={isBackgroundDark}
|
||||
class:on-light={!isBackgroundDark}
|
||||
>
|
||||
{#if item.details?.badge}
|
||||
<LineClamp clamp={1}>
|
||||
<h4>{item.details.badge}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.details.title}
|
||||
<LineClamp clamp={2}>
|
||||
<h3>{@html sanitizeHtml(item.details.title)}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.details.description}
|
||||
<LineClamp clamp={3}>
|
||||
<p>{@html sanitizeHtml(item.details.description)}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if isSome(action) && isFlowAction(action)}
|
||||
<span class="link-container">
|
||||
{action.title}
|
||||
<span aria-hidden="true">
|
||||
<SFSymbol name="chevron.forward" />
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowCollectionIcons}
|
||||
<ul class="collection-icons">
|
||||
{#each collectionIcons.slice(0, 6) as collectionIcon}
|
||||
<li class="app-icon-container">
|
||||
<AppIcon icon={collectionIcon} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</HoverWrapper>
|
||||
</LinkWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/helpers' as *;
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
.artwork-container {
|
||||
width: 100%;
|
||||
|
||||
@media (--range-small-up) {
|
||||
aspect-ratio: 8 / 3;
|
||||
}
|
||||
}
|
||||
|
||||
.artwork-container :global(.video-container) {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-inline: 20px;
|
||||
padding-bottom: 20px;
|
||||
text-wrap: pretty;
|
||||
|
||||
@media (--range-small-up) {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
@media (--range-large-up) {
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
|
||||
.text-container.on-dark {
|
||||
color: var(--systemPrimary-onDark);
|
||||
|
||||
h4 {
|
||||
color: var(--systemSecondary-onDark);
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
fill: var(--systemPrimary-onDark);
|
||||
}
|
||||
}
|
||||
|
||||
.text-container.on-light {
|
||||
color: var(--systemPrimary-onLight);
|
||||
|
||||
h4 {
|
||||
color: var(--systemSecondary-onLight);
|
||||
}
|
||||
|
||||
:global(svg) {
|
||||
fill: var(--systemPrimary-onLight);
|
||||
}
|
||||
}
|
||||
|
||||
.link-container {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
font: var(--body-emphasized);
|
||||
|
||||
@media (--range-small-up) {
|
||||
margin-top: 16px;
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
.link-container :global(svg) {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
|
||||
@include rtl {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
@media (--range-small-up) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
text-wrap: balance;
|
||||
font: var(--title-1-emphasized);
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--large-title-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font: var(--subhead-emphasized);
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--callout-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 4px;
|
||||
font: var(--body);
|
||||
|
||||
@media (--range-small-up) {
|
||||
margin-top: 8px;
|
||||
font: var(--title-3);
|
||||
}
|
||||
}
|
||||
|
||||
.collection-icons {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 2px solid var(--systemTertiary-onDark);
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
aspect-ratio: 1/1;
|
||||
}
|
||||
|
||||
.gradient {
|
||||
--rotation: 35deg;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: saturate(1.5) brightness(0.9);
|
||||
background: linear-gradient(
|
||||
var(--rotation),
|
||||
var(--color) 20%,
|
||||
transparent 50%
|
||||
);
|
||||
|
||||
// In non-XS viewports with an RTL text direction, we flip the legibility gradient to
|
||||
// accomodate the right-justified text.
|
||||
@include rtl {
|
||||
@media (--range-small-up) {
|
||||
--rotation: -35deg;
|
||||
}
|
||||
}
|
||||
|
||||
// In XS viewports, this component is renderd in a 3/4 card layout, so we always want the
|
||||
// gradient to be at 0deg rotation, as it goes from botttom to top.
|
||||
@media (--range-xsmall-down) {
|
||||
--rotation: 0deg;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
130
src/components/jet/item/LargeImageLockupItem.svelte
Normal file
130
src/components/jet/item/LargeImageLockupItem.svelte
Normal file
@@ -0,0 +1,130 @@
|
||||
<script lang="ts">
|
||||
import type { ImageLockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import GradientOverlay from '~/components/GradientOverlay.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
|
||||
export let item: ImageLockup;
|
||||
|
||||
const color: string = item.artwork.backgroundColor
|
||||
? colorAsString(item.artwork.backgroundColor)
|
||||
: '#000';
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={item.lockup.clickAction}>
|
||||
<HoverWrapper>
|
||||
<div class="container">
|
||||
<div class="artwork-container">
|
||||
<Artwork artwork={item.artwork} profile="large-image-lockup" />
|
||||
</div>
|
||||
|
||||
{#if item.lockup}
|
||||
<div
|
||||
class="lockup-container"
|
||||
class:on-dark={item.isDark}
|
||||
class:on-light={!item.isDark}
|
||||
>
|
||||
{#if item.lockup.icon}
|
||||
<div class="app-icon-container">
|
||||
<AppIcon icon={item.lockup.icon} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if item.lockup.heading}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{item.lockup.heading}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.lockup.title}
|
||||
<LineClamp clamp={2}>
|
||||
<h3>{item.lockup.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.lockup.subtitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{item.lockup.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="gradient-container">
|
||||
<GradientOverlay --color={color} --height="85%" />
|
||||
</div>
|
||||
</div>
|
||||
</HoverWrapper>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
.artwork-container {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
container-type: inline-size;
|
||||
container-name: container;
|
||||
}
|
||||
|
||||
.gradient-container {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.lockup-container {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.lockup-container.on-dark {
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
.lockup-container.on-light {
|
||||
color: var(--systemPrimary-onLight);
|
||||
}
|
||||
|
||||
@container container (max-width: 260px) {
|
||||
.lockup-container {
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 2px 0;
|
||||
font: var(--title-1-emphasized);
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--callout-emphasized);
|
||||
}
|
||||
|
||||
.lockup-container.on-dark p {
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
</style>
|
||||
121
src/components/jet/item/LargeLockupItem.svelte
Normal file
121
src/components/jet/item/LargeLockupItem.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
isFlowAction,
|
||||
type FlowAction,
|
||||
type Lockup,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
import type { Opt } from '@jet/environment';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let item: Lockup;
|
||||
const i18n = getI18n();
|
||||
const { clickAction } = item;
|
||||
const destination: Opt<FlowAction> = isFlowAction(clickAction)
|
||||
? clickAction
|
||||
: undefined;
|
||||
|
||||
$: secondaryLine = item.editorialTagline || item.subtitle;
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={destination}>
|
||||
<article>
|
||||
<div class="app-icon-container">
|
||||
<AppIcon
|
||||
fixedWidth={false}
|
||||
icon={item.icon}
|
||||
profile="app-icon-large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if item.heading}
|
||||
<LineClamp clamp={2}>
|
||||
<h4>{item.heading}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<LineClamp clamp={2}>
|
||||
<h3>{item.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if !item.heading && secondaryLine}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{secondaryLine}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.tertiaryTitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p class="tertiary-text">{item.tertiaryTitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if destination}
|
||||
<div class="button-container">
|
||||
<span class="get-button gray">
|
||||
{$i18n.t('ASE.Web.AppStore.View')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 290px;
|
||||
padding: 20px;
|
||||
border-radius: var(--global-border-radius-large);
|
||||
background: var(--systemPrimary-onDark);
|
||||
box-shadow: var(--shadow-small);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
article {
|
||||
background: var(--systemQuaternary);
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
--artwork-override-height: 100px;
|
||||
--artwork-override-width: auto;
|
||||
display: flex;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 3px;
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 3px;
|
||||
color: var(--systemSecondary);
|
||||
font: var(--subhead-emphasized);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 3px 0;
|
||||
font: var(--body);
|
||||
color: var(--systemSecondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.tertiary-text {
|
||||
font: var(--callout);
|
||||
color: var(--systemTertiary);
|
||||
}
|
||||
</style>
|
||||
38
src/components/jet/item/LargeStoryCardItem.svelte
Normal file
38
src/components/jet/item/LargeStoryCardItem.svelte
Normal file
@@ -0,0 +1,38 @@
|
||||
<script lang="ts">
|
||||
import type { TodayCard } from '@jet-app/app-store/api/models';
|
||||
|
||||
import Hero from '~/components/hero/Hero.svelte';
|
||||
import type { NamedProfile } from '~/config/components/artwork';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
import { isRtl } from '~/utils/locale';
|
||||
|
||||
export let item: TodayCard;
|
||||
|
||||
let profile: NamedProfile;
|
||||
|
||||
$: isXSmallViewport = $mediaQueries === 'xsmall';
|
||||
$: artwork = item.heroMedia?.artworks[0];
|
||||
$: video = isXSmallViewport ? null : item.heroMedia?.videos[0];
|
||||
$: ({ backgroundColor, clickAction, heading, inlineDescription, title } =
|
||||
item);
|
||||
$: profile = isXSmallViewport
|
||||
? 'large-hero-story-card-portrait'
|
||||
: isRtl()
|
||||
? 'large-hero-story-card-rtl'
|
||||
: 'large-hero-story-card';
|
||||
</script>
|
||||
|
||||
<Hero
|
||||
{artwork}
|
||||
{backgroundColor}
|
||||
{title}
|
||||
{video}
|
||||
action={clickAction}
|
||||
eyebrow={heading}
|
||||
subtitle={inlineDescription}
|
||||
pinArtworkToVerticalMiddle={true}
|
||||
pinArtworkToHorizontalEnd={true}
|
||||
pinTextToVerticalStart={isRtl()}
|
||||
profileOverride={profile}
|
||||
isMediaDark={item.style !== 'white'}
|
||||
/>
|
||||
88
src/components/jet/item/LinkableTextItem.svelte
Normal file
88
src/components/jet/item/LinkableTextItem.svelte
Normal file
@@ -0,0 +1,88 @@
|
||||
<script lang="ts">
|
||||
import type { LinkableText, Action } from '@jet-app/app-store/api/models';
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
|
||||
export let item: LinkableText;
|
||||
|
||||
type Fragment = {
|
||||
text: string;
|
||||
action?: Action;
|
||||
isTrailingPunctuation?: boolean;
|
||||
};
|
||||
|
||||
const {
|
||||
linkedSubstrings = {},
|
||||
styledText: { rawText },
|
||||
} = item;
|
||||
|
||||
// `LinkableText` items contain a `rawText` string, and an object of `linkedSubstrings`,
|
||||
// where the key of the object is the substring to replace in the `rawText` and whose value
|
||||
// is the `Action` that the link should trigger.
|
||||
//
|
||||
// That means we have to render replace the keys from `linkedSubstrings` in the `rawText`.
|
||||
// To do this, we build a regex to match all the strings that are supposed to be linked,
|
||||
// then build an array of objects representing the fully text, with the `Action` appended
|
||||
// to the fragments that need to be linked.
|
||||
const fragmentsToLink = Object.keys(linkedSubstrings);
|
||||
let fragments: Fragment[];
|
||||
|
||||
if (fragmentsToLink.length === 0) {
|
||||
fragments = [{ text: rawText }];
|
||||
} else {
|
||||
// Escapes regex-sensitive characters in the text, so characters like `.` or `+` don't act as regex operators
|
||||
const cleanedFragmentsToLink = fragmentsToLink.map((fragment) =>
|
||||
fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
||||
);
|
||||
|
||||
const pattern = new RegExp(
|
||||
`(${cleanedFragmentsToLink.join('|')})`,
|
||||
'g',
|
||||
);
|
||||
|
||||
// After we split our text into an array representing the seqence of the raw text, with the
|
||||
// linkable items as their own entries, we transform the array to contain include the linkable
|
||||
// items actions, which we then use to determine if we want to render a `LinkWrapper` or plain-text.
|
||||
fragments = rawText.split(pattern).map((fragment): Fragment => {
|
||||
const action = linkedSubstrings[fragment];
|
||||
|
||||
if (action) {
|
||||
return { action, text: fragment };
|
||||
} else {
|
||||
const isTrailingPunctuation = /^[.,;:!?)\]}"”»']+$/.test(
|
||||
fragment.trim(),
|
||||
);
|
||||
|
||||
return {
|
||||
isTrailingPunctuation,
|
||||
text: fragment,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
{#each fragments as fragment}
|
||||
{#if fragment.action}
|
||||
<LinkWrapper
|
||||
action={fragment.action}
|
||||
includeExternalLinkArrowIcon={false}
|
||||
>
|
||||
{fragment.text}
|
||||
</LinkWrapper>
|
||||
{:else if fragment.isTrailingPunctuation}
|
||||
<span class="trailing-punctuation">{fragment.text}</span>
|
||||
{:else}
|
||||
{@html sanitizeHtml(fragment.text)}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<style>
|
||||
span :global(a:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.trailing-punctuation {
|
||||
margin-inline-start: -0.45ch;
|
||||
}
|
||||
</style>
|
||||
118
src/components/jet/item/MediumImageLockupItem.svelte
Normal file
118
src/components/jet/item/MediumImageLockupItem.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import type { ImageLockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import GradientOverlay from '~/components/GradientOverlay.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
|
||||
export let item: ImageLockup;
|
||||
|
||||
const color: string = item.artwork.backgroundColor
|
||||
? colorAsString(item.artwork.backgroundColor)
|
||||
: '#000';
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={item.lockup.clickAction}>
|
||||
<div class="container">
|
||||
<HoverWrapper>
|
||||
<div class="artwork-container">
|
||||
<Artwork artwork={item.artwork} profile="brick" />
|
||||
</div>
|
||||
|
||||
{#if item.lockup}
|
||||
<div
|
||||
class="lockup-container"
|
||||
class:on-dark={item.isDark}
|
||||
class:on-light={!item.isDark}
|
||||
>
|
||||
{#if item.lockup.icon}
|
||||
<div class="app-icon-container">
|
||||
<AppIcon icon={item.lockup.icon} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if item.lockup.heading}
|
||||
<LineClamp clamp={1}>
|
||||
<p class="eyebrow">{item.lockup.heading}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.lockup.title}
|
||||
<LineClamp clamp={2}>
|
||||
<h3>{item.lockup.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.lockup.subtitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p class="subtitle">{item.lockup.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<GradientOverlay --color={color} --height="90%" />
|
||||
</HoverWrapper>
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
.artwork-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.container {
|
||||
container-type: inline-size;
|
||||
container-name: container;
|
||||
}
|
||||
|
||||
.lockup-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
.lockup-container.on-dark {
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
.lockup-container.on-light {
|
||||
color: var(--systemPrimary-onLight);
|
||||
}
|
||||
|
||||
@container container (max-width: 260px) {
|
||||
.lockup-container {
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
margin-inline-end: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-3-emphasized);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
font: var(--subhead-emphasized);
|
||||
text-transform: uppercase;
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font: var(--callout-emphasized);
|
||||
}
|
||||
</style>
|
||||
96
src/components/jet/item/MediumLockupItem.svelte
Normal file
96
src/components/jet/item/MediumLockupItem.svelte
Normal file
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type FlowAction,
|
||||
type Lockup,
|
||||
isFlowAction,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import type { Opt } from '@jet/environment';
|
||||
|
||||
export let item: Lockup;
|
||||
|
||||
const i18n = getI18n();
|
||||
|
||||
const { clickAction } = item;
|
||||
const destination: Opt<FlowAction> = isFlowAction(clickAction)
|
||||
? clickAction
|
||||
: undefined;
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={destination}>
|
||||
<article>
|
||||
<div class="app-icon-container">
|
||||
<AppIcon
|
||||
icon={item.icon}
|
||||
profile="app-icon-medium"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if item.heading}
|
||||
<span class="heading">{item.heading}</span>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<LineClamp clamp={1}>
|
||||
<h3>{item.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.subtitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{item.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if destination}
|
||||
<div class="button-container">
|
||||
<span class="get-button gray">
|
||||
{$i18n.t('ASE.Web.AppStore.View')}
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</article>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
flex-shrink: 0;
|
||||
width: 85px;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-3);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--callout);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
.heading {
|
||||
font: var(--callout-emphasized);
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-inline-start: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
27
src/components/jet/item/MediumStoryCardItem.svelte
Normal file
27
src/components/jet/item/MediumStoryCardItem.svelte
Normal file
@@ -0,0 +1,27 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
EditorialStoryCard,
|
||||
TodayCard,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export type Item = EditorialStoryCard | TodayCard;
|
||||
|
||||
function isEditorialStoryCard(item: Item): item is EditorialStoryCard {
|
||||
return 'artwork' in item;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import EditorialStoryCardItem from '~/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte';
|
||||
import SmallStoryCardWithMediaItem, {
|
||||
isSmallStoryCardWithMediaItem,
|
||||
} from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte';
|
||||
|
||||
export let item: Item;
|
||||
</script>
|
||||
|
||||
{#if isEditorialStoryCard(item)}
|
||||
<EditorialStoryCardItem {item} />
|
||||
{:else if isSmallStoryCardWithMediaItem(item)}
|
||||
<SmallStoryCardWithMediaItem {item} />
|
||||
{/if}
|
||||
39
src/components/jet/item/MixedMediaLockupItem.svelte
Normal file
39
src/components/jet/item/MixedMediaLockupItem.svelte
Normal file
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import type { MixedMediaLockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte';
|
||||
import Video from '~/components/jet/Video.svelte';
|
||||
|
||||
export let item: MixedMediaLockup;
|
||||
|
||||
let video = item.trailers?.[0]?.videos[0];
|
||||
</script>
|
||||
|
||||
<div class="mixed-media-lockup-item">
|
||||
<div class="video-wrapper">
|
||||
{#if video}
|
||||
<Video {video} profile="brick" shouldSuperimposePosterImage />
|
||||
{/if}
|
||||
</div>
|
||||
<SmallLockupItem {item} />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.mixed-media-lockup-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
--mixed-media-lockup-video-aspect-ratio: 16/9;
|
||||
aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio);
|
||||
overflow: hidden;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
.video-wrapper :global(video) {
|
||||
aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio);
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
21
src/components/jet/item/ParagraphShelfItem.svelte
Normal file
21
src/components/jet/item/ParagraphShelfItem.svelte
Normal file
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import type { Paragraph } from '@jet-app/app-store/api/models';
|
||||
import he from 'he';
|
||||
|
||||
export let item: Paragraph;
|
||||
</script>
|
||||
|
||||
<p>
|
||||
{@html he.decode(item.text)}
|
||||
</p>
|
||||
|
||||
<style>
|
||||
p {
|
||||
font: var(--title-2-medium);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
p :global(b) {
|
||||
color: var(--systemPrimary);
|
||||
}
|
||||
</style>
|
||||
121
src/components/jet/item/PosterLockupItem.svelte
Normal file
121
src/components/jet/item/PosterLockupItem.svelte
Normal file
@@ -0,0 +1,121 @@
|
||||
<script lang="ts">
|
||||
import type { PosterLockup } from '@jet-app/app-store/api/models';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import Video from '~/components/jet/Video.svelte';
|
||||
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
|
||||
export let item: PosterLockup;
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<HoverWrapper>
|
||||
<article>
|
||||
<div class="background">
|
||||
{#if item.epicHeading}
|
||||
<div class="title-container">
|
||||
<Artwork
|
||||
hasTransparentBackground
|
||||
artwork={item.epicHeading}
|
||||
alt={item.heading}
|
||||
profile="poster-title"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.posterVideo}
|
||||
<div class="video-container">
|
||||
<Video
|
||||
autoplay
|
||||
loop
|
||||
video={item.posterVideo}
|
||||
useControls={false}
|
||||
profile="poster-lockup"
|
||||
/>
|
||||
</div>
|
||||
{:else if item.posterArtwork}
|
||||
<div class="artwork-container">
|
||||
<Artwork
|
||||
artwork={item.posterArtwork}
|
||||
profile="poster-lockup"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="logo-container">
|
||||
<AppleArcadeLogo aria-label={item.heading} />
|
||||
</div>
|
||||
|
||||
<span>
|
||||
{item.footerText}
|
||||
{#if item.tertiaryTitle}
|
||||
| {item.tertiaryTitle}
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</HoverWrapper>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
article {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16/9;
|
||||
overflow: hidden;
|
||||
color: var(--systemPrimary-onDark);
|
||||
border-radius: var(--global-border-radius-large);
|
||||
container-type: inline-size;
|
||||
container-name: poster-lockup-item;
|
||||
}
|
||||
|
||||
.title-container {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.background {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
padding: 12px 0;
|
||||
font: var(--body);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(0, 0, 0, 0.5) 0%,
|
||||
rgba(255, 255, 255, 0) 25%,
|
||||
rgba(255, 255, 255, 0) 50%,
|
||||
rgba(255, 255, 255, 0) 80%,
|
||||
rgba(0, 0, 0, 0.4) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.logo-container {
|
||||
width: 62px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
@container poster-lockup-item (min-width: 550px) {
|
||||
.logo-container {
|
||||
width: 78px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-container :global(path) {
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
</style>
|
||||
41
src/components/jet/item/PrivacyHeaderItem.svelte
Normal file
41
src/components/jet/item/PrivacyHeaderItem.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { PrivacyHeader } from '@jet-app/app-store/api/models';
|
||||
import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
|
||||
|
||||
export let item: PrivacyHeader;
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<p>
|
||||
<LinkableTextItem item={item.bodyText} />
|
||||
</p>
|
||||
|
||||
{#if item.supplementaryItems.length}
|
||||
<div class="supplementary-items-container">
|
||||
{#each item.supplementaryItems as supItem}
|
||||
<p>
|
||||
<LinkableTextItem item={supItem.bodyText} />
|
||||
</p>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
p {
|
||||
font: var(--body-tall);
|
||||
}
|
||||
|
||||
p :global(a) {
|
||||
color: var(--keyColor);
|
||||
}
|
||||
|
||||
.supplementary-items-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 20px 0 0;
|
||||
margin-top: 20px;
|
||||
border-top: 1px solid var(--systemGray4);
|
||||
}
|
||||
</style>
|
||||
193
src/components/jet/item/PrivacyTypeItem.svelte
Normal file
193
src/components/jet/item/PrivacyTypeItem.svelte
Normal file
@@ -0,0 +1,193 @@
|
||||
<script lang="ts">
|
||||
import type { PrivacyType } from '@jet-app/app-store/api/models';
|
||||
|
||||
import SystemImage, {
|
||||
isSystemImageArtwork,
|
||||
} from '~/components/SystemImage.svelte';
|
||||
|
||||
export let item: PrivacyType;
|
||||
export let isDetailView: boolean = false;
|
||||
</script>
|
||||
|
||||
<article class:is-detail-view={isDetailView}>
|
||||
{#if item.artwork && isSystemImageArtwork(item.artwork)}
|
||||
<span class="icon-container" aria-hidden="true">
|
||||
<SystemImage artwork={item.artwork} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.detail}</p>
|
||||
|
||||
<ul class:grid={item.categories.length > 1 && !isDetailView}>
|
||||
{#each item.categories as category}
|
||||
<li>
|
||||
{#if isSystemImageArtwork(category.artwork)}
|
||||
<span aria-hidden="true" class="category-icon-container">
|
||||
<SystemImage artwork={category.artwork} />
|
||||
</span>
|
||||
{/if}
|
||||
{category.title}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#each item.purposes as purpose}
|
||||
<section class="purpose-section">
|
||||
<h3>{purpose.title}</h3>
|
||||
|
||||
{#each purpose.categories as category}
|
||||
<li class="purpose-category">
|
||||
{#if isSystemImageArtwork(category.artwork)}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
class="category-icon-container"
|
||||
>
|
||||
<SystemImage artwork={category.artwork} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<span class="category-title">{category.title}</span>
|
||||
|
||||
<ul class="privacy-data-types">
|
||||
{#each category.dataTypes as type}
|
||||
<li>{type}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</li>
|
||||
{/each}
|
||||
</section>
|
||||
{/each}
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'amp/stylekit/core/border-radiuses' as *;
|
||||
@use '@amp/web-shared-styles/app/core/globalvars' as *;
|
||||
|
||||
article {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
padding: 30px;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
font: var(--body-tall);
|
||||
border-radius: $global-border-radius-rounded-large;
|
||||
background-color: var(--systemQuinary);
|
||||
|
||||
&.is-detail-view {
|
||||
padding: 20px 0 0;
|
||||
margin-top: 20px;
|
||||
text-align: left;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
border-top: 1px solid var(--defaultLine);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
width: 30px;
|
||||
margin: 0 auto;
|
||||
|
||||
.is-detail-view & {
|
||||
display: block;
|
||||
width: 32px;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container :global(svg) {
|
||||
width: 100%;
|
||||
fill: var(--keyColor);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font: var(--title-3-emphasized);
|
||||
|
||||
.is-detail-view & {
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
text-wrap: pretty;
|
||||
font: var(--body-tall);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: start;
|
||||
padding: 4px 0;
|
||||
gap: 8px;
|
||||
|
||||
.is-detail-view & {
|
||||
justify-content: start;
|
||||
}
|
||||
}
|
||||
|
||||
.category-title {
|
||||
font: var(--title-3);
|
||||
}
|
||||
|
||||
.grid li {
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
.category-icon-container {
|
||||
display: inline-flex;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
filter: invert(1);
|
||||
}
|
||||
|
||||
.is-detail-view & {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.category-icon-container :global(svg) {
|
||||
width: 20px;
|
||||
|
||||
.is-detail-view & {
|
||||
width: 20px;
|
||||
height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.purpose-section {
|
||||
border-top: 1px solid var(--defaultLine);
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.purpose-section + .purpose-section {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.purpose-section h3 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.purpose-category {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'icon title'
|
||||
'. types';
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.privacy-data-types {
|
||||
grid-area: types;
|
||||
color: var(--systemSecondary);
|
||||
font: var(--body);
|
||||
}
|
||||
</style>
|
||||
188
src/components/jet/item/ProductBadgeItem.svelte
Normal file
188
src/components/jet/item/ProductBadgeItem.svelte
Normal file
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import type { Badge } from '@jet-app/app-store/api/models';
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import StarRating from '~/components/StarRating.svelte';
|
||||
import GameController from '~/sf-symbols/gamecontroller.fill.svg';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import SystemImage, {
|
||||
isSystemImageArtwork,
|
||||
} from '~/components/SystemImage.svelte';
|
||||
import SFSymbol from '~/components/SFSymbol.svelte';
|
||||
import ContentRatingBadge, {
|
||||
isContentRatingBadge,
|
||||
} from '../badge/ContentRatingBadge.svelte';
|
||||
|
||||
export let item: Badge;
|
||||
|
||||
const { artwork, content, type } = item;
|
||||
|
||||
$: isParagraph = type === 'paragraph';
|
||||
$: isRating = type === 'rating';
|
||||
$: isEditorsChoice = type === 'editorsChoice';
|
||||
$: isController = type === 'controller';
|
||||
$: hasImageArtwork = artwork && !isSystemImageArtwork(artwork);
|
||||
</script>
|
||||
|
||||
<LinkWrapper withoutLabel action={item.clickAction}>
|
||||
<div class="badge-container">
|
||||
<div class="badge">
|
||||
<div class="badge-dt" role="term">
|
||||
<LineClamp clamp={1}>
|
||||
{item.heading}
|
||||
</LineClamp>
|
||||
</div>
|
||||
|
||||
<div class="badge-dd" role="definition">
|
||||
{#if isContentRatingBadge(item)}
|
||||
<ContentRatingBadge badge={item} />
|
||||
{:else if isParagraph}
|
||||
<span class="text-container">{content.paragraphText}</span>
|
||||
{:else if isRating && !content.rating}
|
||||
<span class="text-container">
|
||||
{content.ratingFormatted}
|
||||
</span>
|
||||
{:else if isEditorsChoice}
|
||||
<span class="editors-choice">
|
||||
<SFSymbol name="laurel.leading" ariaHidden={true} />
|
||||
|
||||
<span>
|
||||
<LineClamp clamp={2}>
|
||||
{item.accessibilityTitle}
|
||||
</LineClamp>
|
||||
</span>
|
||||
|
||||
<SFSymbol name="laurel.trailing" ariaHidden={true} />
|
||||
</span>
|
||||
{:else if artwork && hasImageArtwork}
|
||||
<div class="artwork-container" aria-hidden="true">
|
||||
<Artwork
|
||||
{artwork}
|
||||
profile="app-icon"
|
||||
hasTransparentBackground
|
||||
/>
|
||||
</div>
|
||||
{:else if artwork && isSystemImageArtwork(artwork)}
|
||||
<div class="icon-container color" aria-hidden="true">
|
||||
<SystemImage {artwork} />
|
||||
</div>
|
||||
{:else if isController}
|
||||
<div class="icon-container" aria-hidden="true">
|
||||
<GameController />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isRating && content.rating}
|
||||
<span class="text-container" aria-hidden="true">
|
||||
{content.ratingFormatted}
|
||||
</span>
|
||||
<StarRating rating={content.rating} />
|
||||
{:else}
|
||||
<LineClamp clamp={1}>{item.caption}</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
.badge-container {
|
||||
--color: var(--systemGray3-onDark);
|
||||
--accent-color: var(--systemSecondary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
transition: filter 210ms ease-in;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--color: var(--systemGray3-onLight);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
height: 25px;
|
||||
aspect-ratio: 1/1;
|
||||
margin: 4px 0 2px;
|
||||
opacity: 0.7;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container {
|
||||
display: flex;
|
||||
width: 35px;
|
||||
height: 25px;
|
||||
margin: 4px 0 2px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.icon-container.color {
|
||||
filter: brightness(1);
|
||||
}
|
||||
|
||||
.badge-dt {
|
||||
text-transform: uppercase;
|
||||
font: var(--subhead-emphasized);
|
||||
color: var(--accent-color);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
height: 25px;
|
||||
margin: 4px 0 2px;
|
||||
font: var(--title-1-emphasized);
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
.editors-choice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 30px;
|
||||
|
||||
:global(svg) {
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@include rtl {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (--range-medium-only) {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
:global(svg path:not([fill='none'])) {
|
||||
fill: var(--color);
|
||||
}
|
||||
}
|
||||
|
||||
.editors-choice span {
|
||||
width: 50%;
|
||||
font: var(--subhead-medium);
|
||||
|
||||
@media (--range-medium-only) {
|
||||
width: 55%;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-dd {
|
||||
--fill-color: var(--color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
font: var(--subhead-tall);
|
||||
color: var(--color);
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
84
src/components/jet/item/ProductCapabilityItem.svelte
Normal file
84
src/components/jet/item/ProductCapabilityItem.svelte
Normal file
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type ProductCapability,
|
||||
type ProductCapabilityType,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte';
|
||||
|
||||
type CapabilityIcons = Record<ProductCapabilityType, string | undefined>;
|
||||
|
||||
const capabilityIcons: CapabilityIcons = {
|
||||
gameCenter: '/assets/images/supports/supports-GameCenter@2x.png',
|
||||
siri: '/assets/images/supports/supports-Siri@2x.png',
|
||||
wallet: '/assets/images/supports/supports-Wallet@2x.png',
|
||||
controllers: '/assets/images/supports/supports-GameController@2x.png',
|
||||
familySharing: '/assets/images/supports/supports-FamilySharing@2x.png',
|
||||
sharePlay: '/assets/images/supports/supports-Shareplay@2x.png',
|
||||
spatialControllers:
|
||||
'/assets/images/supports/supports-SpatialController@2x.png',
|
||||
safariExtensions: '/assets/images/supports/supports-Safari@2x.png',
|
||||
};
|
||||
|
||||
export let item: ProductCapability;
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<div class="capability-icon-container">
|
||||
<img
|
||||
src={capabilityIcons[item.type]}
|
||||
class="capability-icon"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="metadata-container">
|
||||
<LineClamp clamp={1}>
|
||||
<h3>{item.title}</h3>
|
||||
</LineClamp>
|
||||
|
||||
<p>
|
||||
<LinkableTextItem item={item.caption} />
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.capability-icon-container {
|
||||
flex-shrink: 0;
|
||||
width: 48px;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.capability-icon {
|
||||
margin-top: 2px;
|
||||
min-width: 46px;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.metadata-container :global(a) {
|
||||
color: var(--keyColor);
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--systemPrimary);
|
||||
font-size: 1em;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--systemSecondary);
|
||||
font: var(--body-tall);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
68
src/components/jet/item/ProductPageLinkItem.svelte
Normal file
68
src/components/jet/item/ProductPageLinkItem.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type ProductPageLink,
|
||||
isFlowAction,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
import { isExternalUrlAction } from '~/jet/models/';
|
||||
import FlowAction from '~/components/jet/action/FlowAction.svelte';
|
||||
import ExternalURLAction from '~/components/jet/action/ExternalUrlAction.svelte';
|
||||
|
||||
export let item: ProductPageLink;
|
||||
|
||||
const clickAction = item.clickAction;
|
||||
|
||||
$: canRenderContainer =
|
||||
isFlowAction(clickAction) || isExternalUrlAction(clickAction);
|
||||
</script>
|
||||
|
||||
{#if canRenderContainer}
|
||||
<div class="product-link-container">
|
||||
{#if isFlowAction(clickAction)}
|
||||
<FlowAction destination={clickAction}>
|
||||
{item.text}
|
||||
</FlowAction>
|
||||
{:else if isExternalUrlAction(clickAction)}
|
||||
<ExternalURLAction destination={clickAction}>
|
||||
{item.text}
|
||||
</ExternalURLAction>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.product-link-container {
|
||||
@media (--range-xsmall-down) {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.product-link-container :global(a) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--keyColor);
|
||||
text-decoration: none;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (--range-xsmall-down) {
|
||||
font-size: 18px;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.product-link-container :global(a) :global(.external-link-arrow) {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
fill: var(--keyColor);
|
||||
margin-top: 3px;
|
||||
|
||||
@media (--range-xsmall-down) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
37
src/components/jet/item/ProductRatingsItem.svelte
Normal file
37
src/components/jet/item/ProductRatingsItem.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import type { Ratings } from '@jet-app/app-store/api/models';
|
||||
|
||||
import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte';
|
||||
import { getJet } from '~/jet/svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let item: Ratings;
|
||||
|
||||
const i18n = getI18n();
|
||||
const jet = getJet();
|
||||
const numberOfRatings = jet.localization.formattedCount(
|
||||
item.totalNumberOfRatings,
|
||||
);
|
||||
</script>
|
||||
|
||||
<article>
|
||||
{#if item.totalNumberOfRatings === 0}
|
||||
{item.status}
|
||||
{:else}
|
||||
<RatingComponent
|
||||
averageRating={jet.localization.decimal(item.ratingAverage, 1)}
|
||||
ratingCount={item.totalNumberOfRatings}
|
||||
ratingCountText={$i18n.t('ASE.Web.AppStore.Ratings.CountText', {
|
||||
numberOfRatings: numberOfRatings,
|
||||
})}
|
||||
totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')}
|
||||
ratingCountsList={item.ratingCounts}
|
||||
/>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article {
|
||||
--ratingBarColor: var(--systemPrimary);
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
25
src/components/jet/item/ProductReview/UserReviewItem.svelte
Normal file
25
src/components/jet/item/ProductReview/UserReviewItem.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts" context="module">
|
||||
import {
|
||||
type Review as ReviewModel,
|
||||
ProductReview,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
interface UserReview extends ProductReview {
|
||||
sourceType: 'user';
|
||||
review: ReviewModel;
|
||||
}
|
||||
|
||||
export function isUserReviewItem(
|
||||
productReview: ProductReview,
|
||||
): productReview is UserReview {
|
||||
return productReview.sourceType === 'user';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ReviewItem from '~/components/jet/item/ReviewItem.svelte';
|
||||
|
||||
export let item: UserReview;
|
||||
</script>
|
||||
|
||||
<ReviewItem item={item.review} />
|
||||
237
src/components/jet/item/ReviewItem.svelte
Normal file
237
src/components/jet/item/ReviewItem.svelte
Normal file
@@ -0,0 +1,237 @@
|
||||
<script lang="ts">
|
||||
import type { Review as ReviewModel } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
|
||||
import ContentModal from '~/components/jet/item/ContentModal.svelte';
|
||||
import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte';
|
||||
import StarRating from '~/components/StarRating.svelte';
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import { getJet } from '~/jet/svelte';
|
||||
import {
|
||||
escapeHtml,
|
||||
stripUnicodeWhitespace,
|
||||
} from '~/utils/string-formatting';
|
||||
import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics';
|
||||
|
||||
export let item: ReviewModel;
|
||||
export let isDetailView: boolean = false;
|
||||
|
||||
let modalComponent: Modal | undefined;
|
||||
let modalTriggerElement: HTMLElement | null = null;
|
||||
|
||||
const jet = getJet();
|
||||
const i18n = getI18n();
|
||||
const translateFn = (key: string) => $i18n.t(key);
|
||||
|
||||
const handleCloseModal = () => modalComponent?.close();
|
||||
const handleOpenModal = () => {
|
||||
modalComponent?.showModal();
|
||||
jet.recordCustomMetricsEvent({
|
||||
eventType: 'dialog',
|
||||
dialogId: 'more',
|
||||
targetId: CUSTOMER_REVIEW_MODAL_ID,
|
||||
dialogType: 'button',
|
||||
});
|
||||
};
|
||||
|
||||
$: ({ id, reviewerName, rating, contents, title, date, response } = item);
|
||||
$: dateForDisplay = jet.localization.timeAgo(new Date(date));
|
||||
$: dateForAttribute = new Date(date).toISOString();
|
||||
$: titleId = `review-${id}-title`;
|
||||
$: maximumLinesForReview = response ? 3 : 5;
|
||||
$: responseDateForDisplay =
|
||||
response && jet.localization.timeAgo(new Date(response.date));
|
||||
$: responseDateForAttribute =
|
||||
response && new Date(response.date).toISOString();
|
||||
$: reviewContents = stripUnicodeWhitespace(escapeHtml(contents));
|
||||
$: responseContents =
|
||||
response && stripUnicodeWhitespace(escapeHtml(response.contents));
|
||||
</script>
|
||||
|
||||
<article class:is-detail-view={isDetailView} aria-labelledby={titleId}>
|
||||
<div class="header">
|
||||
<div class="title-and-rating-container">
|
||||
{#if !isDetailView}
|
||||
<h3 id={titleId} class="title">
|
||||
<LineClamp clamp={1}>
|
||||
{title}
|
||||
</LineClamp>
|
||||
</h3>
|
||||
{/if}
|
||||
|
||||
<StarRating
|
||||
{rating}
|
||||
--fill-color="var(--systemOrange)"
|
||||
--star-size={isDetailView ? '24px' : '12px'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="review-header">
|
||||
<time class="date" datetime={dateForAttribute}>
|
||||
{dateForDisplay}
|
||||
</time>
|
||||
|
||||
<LineClamp clamp={1}>
|
||||
<p class="author">
|
||||
{reviewerName}
|
||||
</p>
|
||||
</LineClamp>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isDetailView}
|
||||
<p>
|
||||
{@html sanitizeHtml(reviewContents, {
|
||||
allowedTags: [''],
|
||||
keepChildrenWhenRemovingParent: true,
|
||||
})}
|
||||
|
||||
{#if response}
|
||||
<div class="developer-response-container">
|
||||
<div class="developer-response-header">
|
||||
<span class="developer-response-heading">
|
||||
{$i18n.t(
|
||||
'ASE.Web.AppStore.Review.DeveloperResponse',
|
||||
)}
|
||||
</span>
|
||||
|
||||
<time class="date" datetime={responseDateForAttribute}>
|
||||
{responseDateForDisplay}
|
||||
</time>
|
||||
</div>
|
||||
|
||||
{@html sanitizeHtml(responseContents, {
|
||||
allowedTags: [''],
|
||||
keepChildrenWhenRemovingParent: true,
|
||||
})}
|
||||
</div>
|
||||
{/if}
|
||||
</p>
|
||||
{:else}
|
||||
<div class="content">
|
||||
<Truncate
|
||||
on:openModal={handleOpenModal}
|
||||
{title}
|
||||
lines={maximumLinesForReview}
|
||||
{translateFn}
|
||||
text={reviewContents}
|
||||
isPortalModal={true}
|
||||
/>
|
||||
|
||||
{#if item.response}
|
||||
<div class="developer-response-container">
|
||||
<span class="developer-response-heading">
|
||||
{$i18n.t('ASE.Web.AppStore.Review.DeveloperResponse')}
|
||||
</span>
|
||||
<Truncate
|
||||
on:openModal={handleOpenModal}
|
||||
{title}
|
||||
{translateFn}
|
||||
lines={1}
|
||||
text={responseContents}
|
||||
isPortalModal={true}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</article>
|
||||
|
||||
{#if !isDetailView}
|
||||
<Modal {modalTriggerElement} bind:this={modalComponent}>
|
||||
<ContentModal
|
||||
on:close={handleCloseModal}
|
||||
{title}
|
||||
subtitle={null}
|
||||
targetId={CUSTOMER_REVIEW_MODAL_ID}
|
||||
>
|
||||
<svelte:fragment slot="content">
|
||||
<svelte:self {item} isDetailView={true} />
|
||||
</svelte:fragment>
|
||||
</ContentModal>
|
||||
</Modal>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
article:not(.is-detail-view) {
|
||||
height: 186px;
|
||||
padding: 20px 16px;
|
||||
background-color: var(--systemQuinary);
|
||||
border-radius: var(--global-border-radius-xlarge);
|
||||
|
||||
@media (--small) {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-bottom: 18px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.is-detail-view & {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.title-and-rating-container {
|
||||
.is-detail-view & {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
color: var(--systemPrimary);
|
||||
font: var(--body-emphasized);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.date,
|
||||
.author {
|
||||
color: var(--systemSecondary);
|
||||
font: var(--callout);
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
.content {
|
||||
position: relative;
|
||||
word-wrap: break-word; /* Break to fit the review block, even when people leave a review with long text without spaces */
|
||||
text-align: start;
|
||||
font: var(--body);
|
||||
}
|
||||
|
||||
.review-header {
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.developer-response-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 5px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.developer-response-heading {
|
||||
font: var(--body-emphasized);
|
||||
|
||||
.is-detail-view & {
|
||||
display: block;
|
||||
font: var(--title-3-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
.developer-response-container {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
article :global(.more) {
|
||||
--moreTextColorOverride: var(--keyColor);
|
||||
--moreFontOverride: var(--body);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
</style>
|
||||
47
src/components/jet/item/SearchLinkItem.svelte
Normal file
47
src/components/jet/item/SearchLinkItem.svelte
Normal file
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
isFlowAction,
|
||||
type SearchLink,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
import FlowAction from '~/components/jet/action/FlowAction.svelte';
|
||||
import MagnifyingGlass from '~/sf-symbols/magnifyingglass.svg';
|
||||
|
||||
export let item: SearchLink;
|
||||
</script>
|
||||
|
||||
{#if isFlowAction(item.clickAction)}
|
||||
<div class="link-container">
|
||||
<FlowAction destination={item.clickAction}>
|
||||
<MagnifyingGlass class="icon" />
|
||||
{item.title}
|
||||
</FlowAction>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.link-container {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.link-container :global(a) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 12px;
|
||||
font: var(--title-2);
|
||||
border-radius: var(--global-border-radius-large);
|
||||
background: var(--systemQuinary);
|
||||
}
|
||||
|
||||
.link-container :global(a:hover) {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.link-container :global(a) :global(.icon) {
|
||||
overflow: visible;
|
||||
width: 20px;
|
||||
fill: currentColor;
|
||||
}
|
||||
</style>
|
||||
392
src/components/jet/item/SearchResult/AppSearchResultItem.svelte
Normal file
392
src/components/jet/item/SearchResult/AppSearchResultItem.svelte
Normal file
@@ -0,0 +1,392 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
AppSearchResult,
|
||||
AppEventSearchResult,
|
||||
SearchResult,
|
||||
Trailers,
|
||||
Screenshots,
|
||||
FlowAction,
|
||||
Artwork as ArtworkType,
|
||||
Video as VideoType,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export function isAppSearchResult(
|
||||
result: SearchResult,
|
||||
): result is AppSearchResult {
|
||||
return result.resultType === 'content';
|
||||
}
|
||||
|
||||
export function isAppEventSearchResult(
|
||||
result: SearchResult,
|
||||
): result is AppEventSearchResult {
|
||||
return result.resultType === 'appEvent';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type {
|
||||
ImageSizes,
|
||||
Profile,
|
||||
} from '@amp/web-app-components/src/components/Artwork/types';
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
|
||||
|
||||
import type { NamedProfile } from '~/config/components/artwork';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import AppIcon, {
|
||||
doesAppIconNeedBorder,
|
||||
} from '~/components/AppIcon.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import StarRating from '~/components/StarRating.svelte';
|
||||
import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
|
||||
import Video from '~/components/jet/Video.svelte';
|
||||
import SFSymbol from '~/components/SFSymbol.svelte';
|
||||
import { isNamedColor } from '~/utils/color';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
import VideoPlayer from '~/components/VideoPlayer.svelte';
|
||||
|
||||
const i18n = getI18n();
|
||||
|
||||
export let item: AppSearchResult;
|
||||
|
||||
$: ({
|
||||
clickAction,
|
||||
heading,
|
||||
isEditorsChoice,
|
||||
rating,
|
||||
ratingCount,
|
||||
screenshots,
|
||||
subtitle,
|
||||
title,
|
||||
trailers,
|
||||
} = item.lockup);
|
||||
let video: VideoType | undefined;
|
||||
let media: (ArtworkType | VideoType)[];
|
||||
let mediaAspectRatio: number;
|
||||
let numberOfMediaToShow: number;
|
||||
let profile: NamedProfile | Profile;
|
||||
let mediaSizes: ImageSizes;
|
||||
let videoPlayerInstance: InstanceType<typeof VideoPlayer> | null = null;
|
||||
let shouldAutoplayVideo: boolean = false;
|
||||
|
||||
const currentPlatform =
|
||||
(item.lockup.clickAction as FlowAction).destination?.platform ?? '';
|
||||
|
||||
function isForCurrentPlatform(media: Trailers | Screenshots) {
|
||||
return media.mediaPlatform.appPlatform === currentPlatform;
|
||||
}
|
||||
|
||||
$: {
|
||||
const selectedTrailer =
|
||||
trailers?.find(isForCurrentPlatform) ?? trailers?.[0];
|
||||
video = selectedTrailer?.videos?.[0];
|
||||
|
||||
const selectedScreenshot =
|
||||
screenshots.find(isForCurrentPlatform) ?? screenshots[0];
|
||||
|
||||
const firstMedia = video
|
||||
? video.preview
|
||||
: selectedScreenshot.artwork[0];
|
||||
const hasPortraitMedia = firstMedia.width < firstMedia.height;
|
||||
const isMobile = $mediaQueries === 'xsmall' && $sidebarIsHidden;
|
||||
|
||||
mediaAspectRatio = firstMedia.width / firstMedia.height;
|
||||
|
||||
if (!hasPortraitMedia) {
|
||||
numberOfMediaToShow = 1;
|
||||
mediaSizes = isMobile ? [308] : [648, 417, 417, 656];
|
||||
} else if (currentPlatform !== 'iphone') {
|
||||
numberOfMediaToShow = 2;
|
||||
mediaSizes = isMobile ? [150] : [238, 203, 203, 320];
|
||||
} else {
|
||||
numberOfMediaToShow = 3;
|
||||
mediaSizes = isMobile ? [98] : [156, 133, 133, 210];
|
||||
}
|
||||
|
||||
profile = getNaturalProfile(firstMedia, mediaSizes);
|
||||
media = [video, ...selectedScreenshot.artwork]
|
||||
.filter(Boolean)
|
||||
.slice(0, numberOfMediaToShow) as (ArtworkType | VideoType)[];
|
||||
}
|
||||
|
||||
function handleMouseEnter() {
|
||||
videoPlayerInstance?.play();
|
||||
}
|
||||
|
||||
function handleMouseLeave() {
|
||||
videoPlayerInstance?.pause();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
shouldAutoplayVideo = navigator.maxTouchPoints > 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<LinkWrapper
|
||||
action={clickAction}
|
||||
label={`${$i18n.t('ASE.Web.AppStore.View')} ${clickAction.title}`}
|
||||
>
|
||||
<article on:mouseenter={handleMouseEnter} on:mouseleave={handleMouseLeave}>
|
||||
<div class="top-container">
|
||||
{#if item.lockup.icon}
|
||||
<div class="app-icon-container">
|
||||
<AppIcon
|
||||
icon={item.lockup.icon}
|
||||
profile="app-icon"
|
||||
withBorder={doesAppIconNeedBorder(item.lockup.icon)}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if heading}
|
||||
<LineClamp clamp={1}>
|
||||
<h4>{heading}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
<LineClamp clamp={1}>
|
||||
<h3>{title}</h3>
|
||||
</LineClamp>
|
||||
|
||||
<LineClamp clamp={1}>
|
||||
<p>{subtitle}</p>
|
||||
</LineClamp>
|
||||
|
||||
{#if isEditorsChoice}
|
||||
<div class="editors-choice-badge-container">
|
||||
<SFSymbol name="laurel.leading" ariaHidden={true} />
|
||||
|
||||
{$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
|
||||
|
||||
<SFSymbol name="laurel.trailing" ariaHidden={true} />
|
||||
</div>
|
||||
{:else if ratingCount}
|
||||
<span class="rating-container">
|
||||
<StarRating
|
||||
{rating}
|
||||
--fill-color="var(--systemGray2-onDark_IC)"
|
||||
/>
|
||||
{ratingCount}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<span class="get-button gray">
|
||||
{$i18n.t('ASE.Web.AppStore.View')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="artwork-container {currentPlatform}"
|
||||
style:--media-aspect-ratio={mediaAspectRatio}
|
||||
>
|
||||
{#each media as mediaItem}
|
||||
{#if 'videoUrl' in mediaItem}
|
||||
<div class="video-wrapper">
|
||||
<Video
|
||||
{profile}
|
||||
loop
|
||||
video={mediaItem}
|
||||
autoplay={shouldAutoplayVideo}
|
||||
useControls={false}
|
||||
autoplayVisibilityThreshold={0.75}
|
||||
bind:videoPlayerRef={videoPlayerInstance}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<Artwork
|
||||
{profile}
|
||||
artwork={mediaItem}
|
||||
disableAutoCenter={true}
|
||||
useCropCodeFromArtwork={false}
|
||||
/>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</article>
|
||||
</LinkWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
article {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
border-radius: 28px;
|
||||
box-shadow: var(--shadow-medium);
|
||||
background: #fff;
|
||||
transition: box-shadow 210ms ease-out;
|
||||
width: 100%;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: var(--systemQuaternary);
|
||||
}
|
||||
}
|
||||
|
||||
article:hover {
|
||||
box-shadow: 0 5px 28px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.top-container {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-bottom: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.top-container,
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.rating-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font: var(--subhead-emphasized);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
.rating-container :global(svg) {
|
||||
@media (prefers-contrast: more) and (prefers-color-scheme: dark) {
|
||||
--fill-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.editors-choice-badge-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font: var(--caption-1-emphasized);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
.editors-choice-badge-container :global(svg) {
|
||||
height: 14px;
|
||||
overflow: visible;
|
||||
|
||||
@include rtl {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.editors-choice-badge-container :global(svg path) {
|
||||
fill: var(--systemSecondary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--headline);
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: var(--systemSecondary);
|
||||
font: var(--footnote-emphasized);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--callout);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
--container-aspect-ratio: 1.333;
|
||||
--artwork-override-object-fit: contain;
|
||||
--artwork-override-height: auto;
|
||||
--artwork-override-width: 100%;
|
||||
--artwork-override-max-height: 100%;
|
||||
--artwork-override-max-width: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
height: calc(100% * var(--container-aspect-ratio));
|
||||
aspect-ratio: var(--container-aspect-ratio);
|
||||
border-radius: var(--global-border-radius-medium);
|
||||
|
||||
&.iphone {
|
||||
--container-aspect-ratio: 1.444;
|
||||
}
|
||||
|
||||
&.ipad {
|
||||
--container-aspect-ratio: 1.54;
|
||||
}
|
||||
|
||||
&.mac {
|
||||
--container-aspect-ratio: 1.6;
|
||||
}
|
||||
|
||||
&.watch {
|
||||
--container-aspect-ratio: 1.636;
|
||||
}
|
||||
|
||||
&.tv,
|
||||
&.vision {
|
||||
--container-aspect-ratio: 1.77;
|
||||
}
|
||||
}
|
||||
|
||||
// Centers a single item in the grid
|
||||
.artwork-container :global(> :only-child) {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
// Aligns the first of two items to the center edge
|
||||
.artwork-container :global(> :nth-child(1):nth-last-child(2)) {
|
||||
justify-self: flex-end;
|
||||
}
|
||||
|
||||
// Aligns the second of two items to the center edge
|
||||
.artwork-container :global(> :nth-child(2):nth-last-child(1)) {
|
||||
justify-self: flex-start;
|
||||
}
|
||||
|
||||
.video-wrapper {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
aspect-ratio: var(--media-aspect-ratio, 16/9);
|
||||
border: 1px solid var(--systemQuaternary);
|
||||
border-radius: 16px;
|
||||
}
|
||||
|
||||
.artwork-container :global(.artwork-component) {
|
||||
display: flex;
|
||||
aspect-ratio: var(--media-aspect-ratio);
|
||||
border-radius: 16px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.artwork-container :global(.artwork-component img) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.artwork-container :global(.video-container) {
|
||||
container-type: normal;
|
||||
}
|
||||
|
||||
.artwork-container :global(video) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
187
src/components/jet/item/SmallBreakoutItem.svelte
Normal file
187
src/components/jet/item/SmallBreakoutItem.svelte
Normal file
@@ -0,0 +1,187 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Artwork as JetArtworkType,
|
||||
type SmallBreakout,
|
||||
isFlowAction,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
import { isSome } from '@jet/environment/types/optional';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import SFSymbol from '~/components/SFSymbol.svelte';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
|
||||
export let item: SmallBreakout;
|
||||
|
||||
$: ({ backgroundColor, iconArtwork, clickAction: action = null } = item);
|
||||
|
||||
$: backgroundColorForCss = backgroundColor
|
||||
? colorAsString(backgroundColor)
|
||||
: '#000';
|
||||
</script>
|
||||
|
||||
<LinkWrapper {action}>
|
||||
<HoverWrapper>
|
||||
<div class="container" style:--background-color={backgroundColorForCss}>
|
||||
{#if iconArtwork}
|
||||
<div class="artwork-container">
|
||||
<AppIcon
|
||||
icon={iconArtwork}
|
||||
profile="app-icon-xlarge"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div
|
||||
class="text-container"
|
||||
class:with-dark-background={item.details.backgroundStyle ===
|
||||
'dark'}
|
||||
>
|
||||
{#if item.details?.badge}
|
||||
<LineClamp clamp={1}>
|
||||
<h4>{item.details.badge}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.details.title}
|
||||
<LineClamp clamp={2}>
|
||||
<h3>{item.details.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.details.description}
|
||||
<LineClamp clamp={3}>
|
||||
<p>{item.details.description}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if isSome(action) && isFlowAction(action)}
|
||||
<span class="link-container">
|
||||
{action.title}
|
||||
<span aria-hidden="true">
|
||||
<SFSymbol name="chevron.forward" />
|
||||
</span>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</HoverWrapper>
|
||||
</LinkWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/helpers' as *;
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-height: 460px;
|
||||
aspect-ratio: 16/9;
|
||||
background-color: var(--background-color);
|
||||
container-type: inline-size;
|
||||
container-name: container;
|
||||
|
||||
@media (--range-small-up) {
|
||||
aspect-ratio: 13/5;
|
||||
}
|
||||
}
|
||||
|
||||
.artwork-container {
|
||||
--rotation: -30deg;
|
||||
position: absolute;
|
||||
width: 33%;
|
||||
max-width: 430px;
|
||||
inset-inline-end: -10%;
|
||||
transform: translateY(-8%) rotate(var(--rotation));
|
||||
|
||||
@include rtl {
|
||||
--rotation: 30deg;
|
||||
}
|
||||
}
|
||||
|
||||
@container container (min-width: 1150px) {
|
||||
.artwork-container {
|
||||
transform: translateY(-11%) rotate(var(--rotation));
|
||||
}
|
||||
}
|
||||
|
||||
.artwork-container :global(.artwork-component) {
|
||||
--angle: -7px;
|
||||
box-shadow: var(--angle) 5px 12px 0 rgba(0, 0, 0, 0.15);
|
||||
|
||||
@include rtl {
|
||||
--angle: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
.text-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 66%;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
text-wrap: pretty;
|
||||
|
||||
@media (--range-small-up) {
|
||||
width: 33%;
|
||||
}
|
||||
|
||||
@media (--range-large-up) {
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
|
||||
.text-container.with-dark-background {
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
.link-container {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-top: 16px;
|
||||
font: var(--title-3-emphasized);
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
.link-container :global(svg) {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
fill: currentColor;
|
||||
|
||||
@include rtl {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
text-wrap: balance;
|
||||
font: var(--title-1-emphasized);
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--large-title-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font: var(--subhead-emphasized);
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--headline);
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 8px;
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--title-3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
110
src/components/jet/item/SmallLockupItem.svelte
Normal file
110
src/components/jet/item/SmallLockupItem.svelte
Normal file
@@ -0,0 +1,110 @@
|
||||
<script lang="ts">
|
||||
import type { Lockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let item: Lockup;
|
||||
|
||||
/**
|
||||
* Controls the `get-button` variant class that is applied to the "View" button
|
||||
*
|
||||
* @default "gray"
|
||||
*/
|
||||
export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'gray';
|
||||
export let shouldShowLaunchNativeButton: boolean = false;
|
||||
export let titleLineCount: number = 2;
|
||||
export let appIconProfile: AppIconProfile = 'app-icon-small';
|
||||
|
||||
const i18n = getI18n();
|
||||
</script>
|
||||
|
||||
<div class="small-lockup-item">
|
||||
<LinkWrapper
|
||||
action={item.clickAction}
|
||||
label={`${$i18n.t('ASE.Web.AppStore.View')} ${
|
||||
item.title ? item.title : null
|
||||
}`}
|
||||
>
|
||||
{#if item.icon}
|
||||
<div class="app-icon-container">
|
||||
<AppIcon icon={item.icon} profile={appIconProfile} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if item.heading}
|
||||
<LineClamp clamp={1}>
|
||||
<h4 dir="auto">{item.heading}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<LineClamp clamp={titleLineCount}>
|
||||
<h3 dir="auto">{item.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.subtitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p dir="auto">{item.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="button-container" aria-hidden="true">
|
||||
{#if shouldShowLaunchNativeButton && $$slots['launch-native-button']}
|
||||
<slot name="launch-native-button" />
|
||||
{:else}
|
||||
<span class="get-button {buttonVariant}">
|
||||
{$i18n.t('ASE.Web.AppStore.View')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.small-lockup-item,
|
||||
.small-lockup-item :global(a) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: var(--title-color);
|
||||
font: var(--title-3-emphasized);
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: var(--eyebrow-color, var(--systemSecondary));
|
||||
font: var(--subhead-emphasized);
|
||||
text-transform: uppercase;
|
||||
mix-blend-mode: var(--eyebrow-blend-mode);
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--callout);
|
||||
color: var(--subtitle-color, var(--systemSecondary));
|
||||
mix-blend-mode: var(--subtitle-blend-mode);
|
||||
}
|
||||
|
||||
.button-container {
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: var(--margin-inline-end, 0);
|
||||
mix-blend-mode: var(--button-blend-mode);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
176
src/components/jet/item/SmallLockupWithOrdinalItem.svelte
Normal file
176
src/components/jet/item/SmallLockupWithOrdinalItem.svelte
Normal file
@@ -0,0 +1,176 @@
|
||||
<script lang="ts" context="module">
|
||||
import type { Lockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
interface SmallLockupWithOrdinalItem extends Lockup {
|
||||
ordinal: string;
|
||||
}
|
||||
|
||||
export function isSmallLockupWithOrdinalItem(
|
||||
item: Lockup,
|
||||
): item is SmallLockupWithOrdinalItem {
|
||||
return !!item?.ordinal;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
|
||||
export let item: Lockup;
|
||||
|
||||
$: titleLineCount = item.heading || $mediaQueries === 'xsmall' ? 1 : 2;
|
||||
|
||||
const i18n = getI18n();
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<article>
|
||||
{#if item.ordinal}
|
||||
<div class="ordinal">
|
||||
{item.ordinal}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if item.icon}
|
||||
<div
|
||||
class="app-icon-container"
|
||||
style:--icon-aspect-ratio={item.icon.width / item.icon.height}
|
||||
>
|
||||
<AppIcon
|
||||
icon={item.icon}
|
||||
profile="app-icon-medium"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="metadata-container">
|
||||
{#if item.heading}
|
||||
<LineClamp clamp={1}>
|
||||
<h4>{item.heading}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<LineClamp clamp={titleLineCount}>
|
||||
<h3 title={item.title}>{item.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if item.subtitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{item.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<span class="get-button gray">
|
||||
{$i18n.t('ASE.Web.AppStore.View')}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
article {
|
||||
position: relative;
|
||||
aspect-ratio: 0.9;
|
||||
height: 100%;
|
||||
padding: 16px;
|
||||
gap: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
border-radius: var(--global-border-radius-xlarge);
|
||||
background: var(--systemPrimary-onDark);
|
||||
box-shadow: var(--shadow-small);
|
||||
container-type: inline-size;
|
||||
container-name: container;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
background: var(--systemQuaternary);
|
||||
}
|
||||
|
||||
@media (--sidebar-visible) and (--range-xsmall-only) {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
|
||||
@media (--range-medium-up) {
|
||||
aspect-ratio: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
flex-shrink: 0;
|
||||
margin-top: 4px;
|
||||
aspect-ratio: var(--icon-aspect-ratio);
|
||||
height: clamp(40px, 40cqi, 100px);
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
text-wrap: balance;
|
||||
font: var(--body-emphasized);
|
||||
line-height: 1.1;
|
||||
color: var(--title-color);
|
||||
}
|
||||
|
||||
h4 {
|
||||
text-transform: uppercase;
|
||||
font: var(--subhead-emphasized);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--subhead);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
.button-container {
|
||||
--get-button-font: var(--subhead-bold);
|
||||
align-content: end;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.ordinal {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
inset-inline-start: 12px;
|
||||
font: var(--title-1-semibold);
|
||||
color: var(--systemTertiary);
|
||||
}
|
||||
|
||||
@container container (width >= 180px) {
|
||||
h3 {
|
||||
font: var(--title-3-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
@container container (width >= 250px) {
|
||||
h3 {
|
||||
font: var(--title-2-emphasized);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--body);
|
||||
}
|
||||
}
|
||||
|
||||
@container container (width >= 200px) {
|
||||
.button-container {
|
||||
--get-button-font: unset;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
87
src/components/jet/item/SmallStoryCardWithArtworkItem.svelte
Normal file
87
src/components/jet/item/SmallStoryCardWithArtworkItem.svelte
Normal file
@@ -0,0 +1,87 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
Artwork as ArtworkModel,
|
||||
TodayCard,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export interface SmallStoryCardWithArtwork extends TodayCard {
|
||||
artwork: ArtworkModel;
|
||||
badge: any;
|
||||
}
|
||||
|
||||
export function isSmallStoryCardWithArtworkItem(
|
||||
item: TodayCard,
|
||||
): item is SmallStoryCardWithArtwork {
|
||||
return !('media' in item) && 'artwork' in item;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import GradientOverlay from '~/components/GradientOverlay.svelte';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
|
||||
export let item: SmallStoryCardWithArtwork;
|
||||
|
||||
$: artwork = item.heroMedia?.artworks?.[0] || item.artwork;
|
||||
|
||||
$: gradientColor = artwork.backgroundColor
|
||||
? colorAsString(artwork.backgroundColor)
|
||||
: 'rgb(0 0 0 / 62%)';
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<HoverWrapper element="div">
|
||||
<Artwork {artwork} profile="small-story-card-portrait" />
|
||||
|
||||
<GradientOverlay --color={gradientColor} />
|
||||
|
||||
<div class="text-container">
|
||||
{#if item.badge?.title}
|
||||
<h4>{item.badge.title}</h4>
|
||||
{/if}
|
||||
|
||||
{#if item.title}
|
||||
<h3>{@html sanitizeHtml(item.title)}</h3>
|
||||
{/if}
|
||||
</div>
|
||||
</HoverWrapper>
|
||||
</LinkWrapper>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
article {
|
||||
aspect-ratio: 3/4;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: end;
|
||||
height: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
color: var(--systemPrimary);
|
||||
}
|
||||
|
||||
h3 {
|
||||
z-index: 1;
|
||||
text-wrap: pretty;
|
||||
font: var(--body-bold);
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
h4 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-bottom: 2px;
|
||||
font: var(--caption-2-emphasized);
|
||||
color: var(--systemSecondary-onDark);
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
</style>
|
||||
156
src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte
Normal file
156
src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte
Normal file
@@ -0,0 +1,156 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
TodayCard,
|
||||
TodayCardMediaAppIcon,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export interface TodayCardWithMediAppIcon extends TodayCard {
|
||||
media: TodayCardMediaAppIcon;
|
||||
}
|
||||
|
||||
export function isSmallStoryCardWithMediaAppIcon(
|
||||
item: TodayCard,
|
||||
): item is TodayCardWithMediAppIcon {
|
||||
return !!item.media && item.media.kind === 'appIcon';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
|
||||
export let item: TodayCardWithMediAppIcon;
|
||||
|
||||
$: artwork = item.heroMedia?.artworks[0];
|
||||
$: appIcon = item.media.icon;
|
||||
$: backgroundImage = appIcon
|
||||
? buildSrc(
|
||||
appIcon.template,
|
||||
{
|
||||
crop: 'bb',
|
||||
width: 160,
|
||||
height: 160,
|
||||
fileType: 'webp',
|
||||
},
|
||||
{},
|
||||
)
|
||||
: undefined;
|
||||
$: backgroundColor = appIcon.backgroundColor
|
||||
? colorAsString(appIcon.backgroundColor)
|
||||
: '#000';
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<HoverWrapper>
|
||||
<div
|
||||
class="container"
|
||||
style:--background-color={backgroundColor}
|
||||
style:--background-image={`url(${backgroundImage})`}
|
||||
>
|
||||
<div class="protection" />
|
||||
|
||||
{#if artwork}
|
||||
<Artwork {artwork} profile="brick" />
|
||||
{:else}
|
||||
<div class="app-icon-container">
|
||||
<div class="app-icon-normal">
|
||||
<AppIcon
|
||||
icon={appIcon}
|
||||
profile="app-icon-medium"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="app-icon-glow">
|
||||
<AppIcon
|
||||
icon={appIcon}
|
||||
profile="app-icon-medium"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</HoverWrapper>
|
||||
|
||||
<div class="text-container">
|
||||
<h4>{item.heading}</h4>
|
||||
<h3>{item.title}</h3>
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
@use 'amp/stylekit/core/mixins/browser-targets' as *;
|
||||
|
||||
.container {
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 20%,
|
||||
rgba(0, 0, 0, 0.33) 100%
|
||||
),
|
||||
var(--background-image), var(--background-color, #000);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
|
||||
// Safari has issues rendering the overlaid `backdrop-filter` from `.proection` atop the
|
||||
// background image of `.container`, so in Safari only we are forgoing the use of
|
||||
// `var(--background-image)` and just using colors.
|
||||
@include target-safari {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 20%,
|
||||
rgba(0, 0, 0, 0.33) 100%
|
||||
),
|
||||
var(--background-color, #000);
|
||||
}
|
||||
}
|
||||
|
||||
.protection {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(80px) saturate(1.5);
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
}
|
||||
|
||||
.app-icon-normal {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
filter: drop-shadow(0 0 13px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.app-icon-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
transform: scale(1.4);
|
||||
filter: blur(25px);
|
||||
}
|
||||
|
||||
.text-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-3);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 2px;
|
||||
font: var(--callout-emphasized);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
</style>
|
||||
104
src/components/jet/item/SmallStoryCardWithMediaItem.svelte
Normal file
104
src/components/jet/item/SmallStoryCardWithMediaItem.svelte
Normal file
@@ -0,0 +1,104 @@
|
||||
<script lang="ts" context="module">
|
||||
import { isSome } from '@jet/environment/types/optional';
|
||||
import type {
|
||||
TodayCard,
|
||||
TodayCardMediaWithArtwork,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte';
|
||||
|
||||
export interface SmallStoryCardWithMedia extends TodayCard {
|
||||
media: TodayCardMediaWithArtwork;
|
||||
heroMedia: TodayCardMediaWithArtwork;
|
||||
}
|
||||
|
||||
export function isSmallStoryCardWithMediaItem(
|
||||
item: TodayCard,
|
||||
): item is SmallStoryCardWithMedia {
|
||||
return isSome(item.media);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
|
||||
export let item: SmallStoryCardWithMedia;
|
||||
|
||||
$: artwork = (() => {
|
||||
if (item.heroMedia) {
|
||||
return item.heroMedia?.artworks?.[0];
|
||||
}
|
||||
|
||||
if (isTodayCardMediaWithArtwork(item.media)) {
|
||||
return item.media.artworks?.[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<HoverWrapper element="div">
|
||||
{#if artwork}
|
||||
<div class="artwork-container">
|
||||
<Artwork
|
||||
{artwork}
|
||||
profile={item.heroMedia
|
||||
? 'small-story-card'
|
||||
: 'small-story-card-legacy'}
|
||||
useCropCodeFromArtwork={!item.heroMedia}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</HoverWrapper>
|
||||
|
||||
<div class="text-container">
|
||||
<h4>{item.heading}</h4>
|
||||
<LineClamp clamp={1}>
|
||||
<h3>{item.title}</h3>
|
||||
</LineClamp>
|
||||
|
||||
{#if item.inlineDescription}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{item.inlineDescription}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
</article>
|
||||
|
||||
<style>
|
||||
.artwork-container {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background-color: var(--color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
display: flex;
|
||||
margin-top: 8px;
|
||||
gap: 4px;
|
||||
color: var(--systemPrimary);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-3);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font: var(--callout-emphasized);
|
||||
color: var(--systemTertiary);
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--body-tall);
|
||||
color: var(--systemSecondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
</style>
|
||||
118
src/components/jet/item/SmallStoryCardWithMediaRiver.svelte
Normal file
118
src/components/jet/item/SmallStoryCardWithMediaRiver.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
TodayCard,
|
||||
TodayCardMediaRiver,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export interface TodayCardWithMediaRiver extends TodayCard {
|
||||
media: TodayCardMediaRiver;
|
||||
}
|
||||
|
||||
export function isSmallStoryCardWithMediaRiver(
|
||||
item: TodayCard,
|
||||
): item is TodayCardWithMediaRiver {
|
||||
return !!item.media && item.media.kind === 'river';
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { Opt } from '@jet/environment/types/optional';
|
||||
import HoverWrapper from '~/components/HoverWrapper.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import AppIconRiver from '~/components/AppIconRiver.svelte';
|
||||
import {
|
||||
getBackgroundGradientCSSVarsFromArtworks,
|
||||
getLuminanceForRGB,
|
||||
} from '~/utils/color';
|
||||
|
||||
export let item: TodayCardWithMediaRiver;
|
||||
|
||||
$: icons = item.media.lockups.map((lockup) => lockup.icon);
|
||||
$: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks(
|
||||
icons,
|
||||
{
|
||||
// sorts from darkest to lightest
|
||||
sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
|
||||
},
|
||||
);
|
||||
|
||||
let title: Opt<string>;
|
||||
let eyebrow: Opt<string>;
|
||||
$: {
|
||||
eyebrow = item.heading;
|
||||
title = item.title;
|
||||
|
||||
if (item.inlineDescription) {
|
||||
eyebrow = item.title;
|
||||
title = item.inlineDescription;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<HoverWrapper>
|
||||
<div class="river-container" style={backgroundGradientCssVars}>
|
||||
<AppIconRiver {icons} profile="app-icon" />
|
||||
</div>
|
||||
</HoverWrapper>
|
||||
|
||||
<div class="text-container">
|
||||
{#if eyebrow}
|
||||
<h4>{eyebrow}</h4>
|
||||
{/if}
|
||||
|
||||
{#if title}
|
||||
<h3>{title}</h3>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
|
||||
<style>
|
||||
.river-container {
|
||||
--app-icon-river-icon-width: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
background: radial-gradient(
|
||||
circle at 3% -50%,
|
||||
var(--top-left, #000) 20%,
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at -50% 120%,
|
||||
var(--bottom-left, #000) 40%,
|
||||
transparent 80%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 140% -50%,
|
||||
var(--top-right, #000) 60%,
|
||||
transparent 80%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 62% 100%,
|
||||
var(--bottom-right, #000) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.river-container :global(.app-icons:last-of-type) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-3);
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin-bottom: 2px;
|
||||
font: var(--callout-emphasized);
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
</style>
|
||||
175
src/components/jet/item/TitledParagraphItem.svelte
Normal file
175
src/components/jet/item/TitledParagraphItem.svelte
Normal file
@@ -0,0 +1,175 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
ShelfModel,
|
||||
TitledParagraph,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export function isTitledParagraphItem(
|
||||
item: ShelfModel | string,
|
||||
): item is TitledParagraph {
|
||||
return typeof item !== 'string' && 'text' in item;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import { getNumericDateFromDateString } from '@amp/web-app-components/src/utils/date';
|
||||
import { getJet } from '~/jet/svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let item: TitledParagraph;
|
||||
|
||||
const i18n = getI18n();
|
||||
const jet = getJet();
|
||||
const isDetailView = item.style === 'detail';
|
||||
const dateForDisplay = jet.localization.timeAgo(
|
||||
new Date(item.secondarySubtitle),
|
||||
);
|
||||
const dateForAttribute = getNumericDateFromDateString(
|
||||
item.secondarySubtitle,
|
||||
);
|
||||
|
||||
let isTruncated = true;
|
||||
</script>
|
||||
|
||||
<article class:detail={isDetailView} class:overview={!isDetailView}>
|
||||
<div class="container">
|
||||
<p>
|
||||
{#if item.text}
|
||||
{#if !isTruncated || isDetailView}
|
||||
{item.text}
|
||||
{:else}
|
||||
<LineClamp
|
||||
clamp={5}
|
||||
observe
|
||||
on:resize={({ detail }) =>
|
||||
(isTruncated = detail.truncated)}
|
||||
>
|
||||
{@html sanitizeHtml(item.text)}
|
||||
</LineClamp>
|
||||
|
||||
{#if isTruncated}
|
||||
<button on:click={() => (isTruncated = false)}>
|
||||
{$i18n.t('ASE.Web.AppStore.More')}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</p>
|
||||
|
||||
<div class="metadata">
|
||||
<h4>{item.primarySubtitle}</h4>
|
||||
<time datetime={dateForAttribute}>{dateForDisplay}</time>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
article {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
font: var(--body-tall);
|
||||
color: var(--systemPrimary);
|
||||
margin: 0 var(--bodyGutter);
|
||||
|
||||
@media (--range-small-up) {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
p {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
white-space: break-spaces;
|
||||
font: var(--body-tall);
|
||||
}
|
||||
|
||||
.metadata {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
margin: 0 0 8px 8px;
|
||||
text-align: end;
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
h4 {
|
||||
font: var(--body-tall);
|
||||
}
|
||||
|
||||
button {
|
||||
--gradient-direction: 270deg;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
color: var(--keyColor);
|
||||
inset-inline-end: 0;
|
||||
padding-inline-start: 20px;
|
||||
background: linear-gradient(
|
||||
var(--gradient-direction),
|
||||
var(--pageBg) 72%,
|
||||
transparent 100%
|
||||
);
|
||||
|
||||
@include rtl {
|
||||
--gradient-direction: 90deg;
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
color: var(--systemSecondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail {
|
||||
flex-direction: column-reverse;
|
||||
margin: 0;
|
||||
padding: 16px 0 0;
|
||||
border-top: 1px solid var(--systemGray4);
|
||||
}
|
||||
|
||||
.detail .metadata {
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.detail h4 {
|
||||
font: var(--body-emphasized-tall);
|
||||
color: var(--systemPrimary);
|
||||
}
|
||||
|
||||
.overview .container {
|
||||
@media (--range-medium-up) {
|
||||
width: 66%;
|
||||
}
|
||||
}
|
||||
|
||||
.overview .metadata {
|
||||
flex-grow: 1;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.overview p {
|
||||
@media (--range-small-up) {
|
||||
width: 66%;
|
||||
}
|
||||
|
||||
@media (--range-large-up) {
|
||||
width: 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.detail .container {
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
51
src/components/jet/item/TrailersLockupItem.svelte
Normal file
51
src/components/jet/item/TrailersLockupItem.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import type { TrailersLockup } from '@jet-app/app-store/api/models';
|
||||
import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte';
|
||||
import Video from '~/components/jet/Video.svelte';
|
||||
|
||||
export let item: TrailersLockup;
|
||||
|
||||
$: video = item.trailers.videos[0];
|
||||
</script>
|
||||
|
||||
<article>
|
||||
{#if video}
|
||||
<div class="video-container">
|
||||
<Video
|
||||
{video}
|
||||
shouldSuperimposePosterImage
|
||||
loop={true}
|
||||
useControls={true}
|
||||
profile="app-trailer-lockup-video"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SmallLockup {item} />
|
||||
</article>
|
||||
|
||||
<style>
|
||||
/*
|
||||
The video container is explicitly not 16/9 aspect ratio, because a lot trailers have
|
||||
pillarboxing (black bars on the sides), so expand the height of their container which
|
||||
causes those black bars to overflow outside the container, thus cropping them.
|
||||
This follows the iOS pattern.
|
||||
*/
|
||||
.video-container {
|
||||
--app-trailer-lockup-video-aspect-ratio: 16/10;
|
||||
aspect-ratio: var(--app-trailer-lockup--video-aspect-ratio);
|
||||
margin-bottom: 16px;
|
||||
overflow: hidden;
|
||||
border-radius: var(--global-border-radius-large);
|
||||
}
|
||||
|
||||
/*
|
||||
Not all trailers are in a landscape aspect ratio (many iPhone trailers are portrait),
|
||||
so for those cases we force them to fit inside a landscape container, centered vertically,
|
||||
by using `object-fit: cover;`.
|
||||
*/
|
||||
.video-container :global(video) {
|
||||
aspect-ratio: var(--app-trailer-lockup-video-aspect-ratio);
|
||||
object-fit: cover;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user