forked from off-topic/apps.apple.com
init commit
This commit is contained in:
161
src/App.svelte
Normal file
161
src/App.svelte
Normal file
@@ -0,0 +1,161 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
import { BUILD } from '~/config/build';
|
||||
import { getJet } from '~/jet';
|
||||
import { makeErrorPageIntent } from '~/jet/intents/error-page-intent-controller';
|
||||
import { getLocale } from '~/utils/locale';
|
||||
|
||||
// Types
|
||||
import type { Page } from './jet/models/page';
|
||||
|
||||
// Components
|
||||
import Fonts from '~/components/structure/Fonts.svelte';
|
||||
import Footer from '~/components/structure/Footer.svelte';
|
||||
import Navigation from '~/components/navigation/Navigation.svelte';
|
||||
import NavigationSkeleton from '~/components/navigation/Skeleton.svelte';
|
||||
import PageResolver from '~/components/PageResolver.svelte';
|
||||
|
||||
const locale = getLocale();
|
||||
const jet = getJet();
|
||||
|
||||
$: language = locale.language;
|
||||
|
||||
export let page: Promise<Page> | Page = new Promise(() => {});
|
||||
export let isFirstPage: boolean = true;
|
||||
|
||||
$: pageWithRejectionErrorPage = transformRejectionIntoErrorPage(page);
|
||||
|
||||
// Critically, this function is not async. We want to preserve the behavior
|
||||
// where if page is not a promise than neither is
|
||||
// pageWithRejectionErrorPage.
|
||||
function transformRejectionIntoErrorPage(
|
||||
page: Promise<Page> | Page,
|
||||
): Promise<Page> | Page {
|
||||
if (!(page instanceof Promise)) {
|
||||
return page;
|
||||
}
|
||||
|
||||
// The async IIFE allows this function to return synchronously.
|
||||
return (async (): Promise<Page> => {
|
||||
try {
|
||||
return await page;
|
||||
} catch (error) {
|
||||
return jet.dispatch(
|
||||
makeErrorPageIntent({
|
||||
// This allows the error page to pick the right platform
|
||||
// and display the correct mesage (ex. "Page not found" for
|
||||
// a 404)
|
||||
error: error instanceof Error ? error : null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// NOTE: The use of page instead of pageWithRejectionErrorPage here is very
|
||||
// intentional. Since pageWithRejectionErrorPage is reactive, it will
|
||||
// be undefined in this initializer. This is intentionally not
|
||||
// not derived (eg. defined as $: webNavigation = ...), since we only
|
||||
// want to update it _after_ the page promise resolves (so the nav
|
||||
// doesn't disappear on navigation). But then for SSR, there are no
|
||||
// promises, so we need a sync value here so the nav renders, which
|
||||
// is why we have the initializer.
|
||||
let webNavigation = page instanceof Promise ? null : page.webNavigation;
|
||||
$: {
|
||||
if (pageWithRejectionErrorPage instanceof Promise) {
|
||||
// Clientside once the new page resolves, update the navigation
|
||||
// (in case it changed)
|
||||
pageWithRejectionErrorPage.then((page: Page) => {
|
||||
webNavigation = page.webNavigation;
|
||||
});
|
||||
} else {
|
||||
// Sometimes clientside a promise is not passed to updateApp, so
|
||||
// we need to handle a WebRenderablePage (possible with a
|
||||
// different webNavigation).
|
||||
webNavigation = pageWithRejectionErrorPage.webNavigation;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
//@ts-ignore
|
||||
window.__ASOTW = {
|
||||
version: BUILD,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<meta name="version" content={BUILD} />
|
||||
</svelte:head>
|
||||
|
||||
<Fonts {language} />
|
||||
|
||||
{#if import.meta.env.DEV}
|
||||
{#await import('~/components/ArtworkBreakpointLogger.svelte') then { default: ArtworkBreakpointLogger }}
|
||||
<ArtworkBreakpointLogger />
|
||||
{/await}
|
||||
{/if}
|
||||
|
||||
<div class="app-container" data-testid="app-container">
|
||||
<div class="navigation-container">
|
||||
{#if webNavigation}
|
||||
<Navigation {webNavigation} />
|
||||
{:else}
|
||||
<NavigationSkeleton />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style="display: flex;
|
||||
position: relative;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
"
|
||||
>
|
||||
<main class="page-container">
|
||||
<PageResolver page={pageWithRejectionErrorPage} {isFirstPage} />
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use '@amp/web-shared-styles/app/core/viewports' as *;
|
||||
@use '@amp/web-shared-styles/app/core/globalvars' as *;
|
||||
|
||||
.app-container {
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
'structure-header'
|
||||
'structure-main-section';
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-gap: 0;
|
||||
grid-template-rows: 44px auto;
|
||||
|
||||
@media (--sidebar-visible) {
|
||||
grid-template-rows: auto;
|
||||
grid-template-columns: 260px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
@media (--sidebar-large-visible) {
|
||||
grid-template-columns: $global-sidebar-width-large minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.navigation-container {
|
||||
@media (--range-small-up) {
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.page-container {
|
||||
flex-grow: 1;
|
||||
}
|
||||
</style>
|
||||
97
src/bootstrap.ts
Normal file
97
src/bootstrap.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Sets up app specific configurations
|
||||
import type { Opt } from '@jet/environment';
|
||||
import type { Intent } from '@jet/environment/dispatching';
|
||||
import type { ActionModel } from '@jet/environment/types/models';
|
||||
import { initializeUniqueIdContext } from '@amp/web-app-components/src/utils/uniqueId';
|
||||
import { setLocale as setSharedLocale } from '@amp/web-app-components/src/utils/locale';
|
||||
|
||||
import type {
|
||||
NormalizedStorefront,
|
||||
NormalizedLanguage,
|
||||
} from '@jet-app/app-store/api/locale';
|
||||
|
||||
import {
|
||||
DEFAULT_STOREFRONT_CODE,
|
||||
DEFAULT_LANGUAGE_BCP47,
|
||||
} from '~/constants/storefront';
|
||||
import { Jet } from '~/jet';
|
||||
import { setup as setupI18n } from '~/stores/i18n';
|
||||
import type { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
|
||||
import type { LoggerFactory } from '@amp/web-apps-logger';
|
||||
import type { Locale as Language } from '@amp/web-apps-localization';
|
||||
import type I18N from '@amp/web-apps-localization';
|
||||
import '~/config/components/artwork';
|
||||
import '~/config/components/shelf';
|
||||
import type { FeaturesCallbacks } from './jet/dependencies/net';
|
||||
|
||||
export type Context = Map<string, unknown>;
|
||||
|
||||
export async function bootstrap({
|
||||
loggerFactory,
|
||||
initialUrl,
|
||||
fetch,
|
||||
prefetchedIntents,
|
||||
featuresCallbacks,
|
||||
}: {
|
||||
loggerFactory: LoggerFactory;
|
||||
initialUrl: string;
|
||||
fetch: typeof window.fetch;
|
||||
prefetchedIntents: PrefetchedIntents;
|
||||
featuresCallbacks?: FeaturesCallbacks;
|
||||
}): Promise<{
|
||||
context: Context;
|
||||
jet: Jet;
|
||||
initialAction: Opt<ActionModel>;
|
||||
intent: Opt<Intent<unknown>>;
|
||||
storefront: NormalizedStorefront;
|
||||
language: NormalizedLanguage;
|
||||
i18n: I18N;
|
||||
}> {
|
||||
const log = loggerFactory.loggerFor('bootstrap');
|
||||
|
||||
const context = new Map();
|
||||
|
||||
const jet = Jet.load({
|
||||
loggerFactory,
|
||||
context,
|
||||
fetch,
|
||||
prefetchedIntents,
|
||||
featuresCallbacks,
|
||||
});
|
||||
|
||||
initializeUniqueIdContext(context, loggerFactory);
|
||||
|
||||
const routing = await jet.routeUrl(initialUrl);
|
||||
|
||||
if (routing) {
|
||||
log.info('initial URL routed to:', routing);
|
||||
} else {
|
||||
log.warn('initial URL was unroutable:', initialUrl);
|
||||
}
|
||||
|
||||
const {
|
||||
intent = null,
|
||||
action: initialAction = null,
|
||||
storefront = DEFAULT_STOREFRONT_CODE,
|
||||
language = DEFAULT_LANGUAGE_BCP47,
|
||||
} = routing || {};
|
||||
|
||||
// TODO: rdar://78109398 (i18n Improvements)
|
||||
const i18nStore = await setupI18n(
|
||||
context,
|
||||
loggerFactory,
|
||||
language.toLowerCase() as Language,
|
||||
);
|
||||
jet.setLocale(i18nStore, storefront, language);
|
||||
setSharedLocale(context, { storefront, language });
|
||||
|
||||
return {
|
||||
context,
|
||||
jet,
|
||||
initialAction,
|
||||
intent,
|
||||
storefront,
|
||||
language,
|
||||
i18n: i18nStore,
|
||||
};
|
||||
}
|
||||
100
src/browser.ts
Normal file
100
src/browser.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
// This must be imported first to ensure base styles are imported first
|
||||
import '~/styles/app-store.scss';
|
||||
|
||||
import App from '~/App.svelte';
|
||||
import { bootstrap } from '~/bootstrap';
|
||||
import { registerActionHandlers } from '~/jet/action-handlers';
|
||||
import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents';
|
||||
import {
|
||||
CompositeLoggerFactory,
|
||||
ConsoleLoggerFactory,
|
||||
DeferredLoggerFactory,
|
||||
setContext,
|
||||
} from '@amp/web-apps-logger';
|
||||
|
||||
import { setHTMLAttributes } from '@amp/web-apps-localization';
|
||||
import { ERROR_KIT_CONFIG } from '~/config/errorkit';
|
||||
import {
|
||||
ErrorKitLoggerFactory,
|
||||
setupErrorKit,
|
||||
} from '@amp/web-apps-logger/src/errorkit';
|
||||
import { setupRuntimeFeatures } from '~/utils/features/runtime';
|
||||
|
||||
export async function startApplication() {
|
||||
const onyxFeatures = await setupRuntimeFeatures(
|
||||
new DeferredLoggerFactory(() => logger),
|
||||
);
|
||||
const consoleLogger = new ConsoleLoggerFactory();
|
||||
const errorKit = setupErrorKit(ERROR_KIT_CONFIG, consoleLogger);
|
||||
const logger = new CompositeLoggerFactory([
|
||||
consoleLogger,
|
||||
new ErrorKitLoggerFactory(errorKit),
|
||||
...(onyxFeatures ? [onyxFeatures.recordingLogger] : []),
|
||||
]);
|
||||
|
||||
let url = window.location.href;
|
||||
|
||||
// TODO: this is busted for some reason? rdar://111465791 ([Onyx] Foundation - PerfKit)
|
||||
// const perfkit = setupBrowserPerfkit(PERF_KIT_CONFIG, logger);
|
||||
|
||||
// Initialize Jet, and get starting state.
|
||||
const { context, jet, initialAction, storefront, language, i18n } =
|
||||
await bootstrap({
|
||||
loggerFactory: logger,
|
||||
initialUrl: url,
|
||||
fetch: window.fetch.bind(window),
|
||||
prefetchedIntents: PrefetchedIntents.fromDom(logger, {
|
||||
evenIfSignedIn: true,
|
||||
featureKitItfe: onyxFeatures?.featureKit?.itfe,
|
||||
}),
|
||||
featuresCallbacks: {
|
||||
getITFEValues(): string | undefined {
|
||||
return onyxFeatures?.featureKit?.itfe;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit)
|
||||
// setPageSpeedContext(context, perfkit, logger);
|
||||
setContext(context, logger);
|
||||
|
||||
// Add lang + dir tag to HTML node
|
||||
setHTMLAttributes(language);
|
||||
|
||||
// Using a container element to avoid svelte hydration
|
||||
// "clean up" from removing tags that have
|
||||
// been add to the <body> tag in our HTML file.
|
||||
const container = document.querySelector('.body-container');
|
||||
|
||||
const app = new App({
|
||||
target: container,
|
||||
context,
|
||||
hydrate: true,
|
||||
});
|
||||
|
||||
// Initialize action-handlers.
|
||||
registerActionHandlers({
|
||||
jet,
|
||||
logger,
|
||||
updateApp: (props) => app.$set(props),
|
||||
});
|
||||
|
||||
if (initialAction) {
|
||||
// TODO: rdar://73165545 (Error Handling Across App): handle throw
|
||||
await jet.perform(initialAction);
|
||||
} else {
|
||||
app.$set({
|
||||
page: Promise.reject(new Error('404')),
|
||||
isFirstPage: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If we export default here, this will run during tests when we do
|
||||
// `import { startApplication } from '~/browser';`. To avoid this, we guard using the
|
||||
// presence of an ENV var only set by Vitest.
|
||||
|
||||
// This is covered by acceptance tests
|
||||
if (!import.meta.env?.VITEST) {
|
||||
startApplication();
|
||||
}
|
||||
202
src/components/AmbientBackgroundArtwork.svelte
Normal file
202
src/components/AmbientBackgroundArtwork.svelte
Normal file
@@ -0,0 +1,202 @@
|
||||
<script lang="ts">
|
||||
import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
|
||||
import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
|
||||
import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
|
||||
import ResizeDetector from '@amp/web-app-components/src/components/helpers/ResizeDetector.svelte';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
|
||||
export let artwork: JetArtworkType;
|
||||
export let active: boolean = false;
|
||||
|
||||
$: isBackgroundImageLoaded = false;
|
||||
$: backgroundImage = artwork
|
||||
? buildSrc(
|
||||
artwork.template,
|
||||
{
|
||||
crop: 'sr',
|
||||
width: 400,
|
||||
height: Math.floor(400 / 1.6667),
|
||||
fileType: 'webp',
|
||||
},
|
||||
{},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
$: if (backgroundImage) {
|
||||
const img = new Image();
|
||||
img.onload = () => (isBackgroundImageLoaded = true);
|
||||
img.src = backgroundImage;
|
||||
}
|
||||
|
||||
let resizing = false;
|
||||
const handleResizeUpdate = (e: CustomEvent<{ isResizing: boolean }>) =>
|
||||
(resizing = e.detail.isResizing);
|
||||
|
||||
let isOutOfView = true;
|
||||
const handleIntersectionOberserverUpdate = (
|
||||
isIntersectingViewport: boolean,
|
||||
) => (isOutOfView = !isIntersectingViewport);
|
||||
</script>
|
||||
|
||||
{#if backgroundImage}
|
||||
<ResizeDetector on:resizeUpdate={handleResizeUpdate} />
|
||||
|
||||
<div
|
||||
class="container"
|
||||
class:active
|
||||
class:resizing
|
||||
class:loaded={isBackgroundImageLoaded}
|
||||
class:out-of-view={isOutOfView}
|
||||
style:--background-image={`url(${backgroundImage})`}
|
||||
style:--background-color={artwork.backgroundColor &&
|
||||
colorAsString(artwork.backgroundColor)}
|
||||
use:intersectionObserver={{
|
||||
callback: handleIntersectionOberserverUpdate,
|
||||
threshold: 0,
|
||||
}}
|
||||
>
|
||||
<div class="overlay" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.container {
|
||||
--veil: rgb(240, 240, 240, 0.65);
|
||||
--speed: 0.66s;
|
||||
--aspect-ratio: 16/9;
|
||||
--scale: 1.2;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
max-height: 900px;
|
||||
opacity: 0;
|
||||
|
||||
/*
|
||||
This stack of background images represents the following three layers, listed front-to-back:
|
||||
|
||||
1) A gradient from transparent to white that acts as a mask for the entire container.
|
||||
`mask-image` caused too much thrashing and CPU usage when animating and resizing,
|
||||
so we are mimicking its functionality with this top-layer background image.
|
||||
2) A semi-transparent veil to evenly fade out the bg. Note that this is not technically
|
||||
a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
|
||||
used in `background-image`.
|
||||
3) The joe color of the background image that will eventualy be loaded.
|
||||
*/
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 50%,
|
||||
var(--pageBg) 80%
|
||||
),
|
||||
linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
|
||||
linear-gradient(
|
||||
0deg,
|
||||
var(--background-color) 0%,
|
||||
var(--background-color) 80%
|
||||
);
|
||||
background-position: center;
|
||||
background-size: 120%;
|
||||
|
||||
/*
|
||||
Blurring via the CSS filter does not extend edge-to-edge of the contents width, but we
|
||||
can mitigate that by ever-so-slightly bumping up the `scale` of content so it bleeds off
|
||||
the page cleanly.
|
||||
*/
|
||||
filter: blur(20px) saturate(1.3);
|
||||
transform: scale(var(--scale));
|
||||
transition: opacity calc(var(--speed) * 2) ease-out,
|
||||
background-size var(--speed) ease-in;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
--veil: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
.container.loaded {
|
||||
/*
|
||||
This stack of background images represents the following three layers, listed front-to-back:
|
||||
|
||||
1) A gradient from transparent to white that acts as a mask for the entire container.
|
||||
`mask-image` caused too much thrashing and CPU usage when animating and resizing,
|
||||
so we are mimicking its functionality with this top-layer background image.
|
||||
2) A semi-transparent veil to evenly fade out the image. Note that this is not technically
|
||||
a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be
|
||||
used in `background-image`.
|
||||
3) The actual background image.
|
||||
*/
|
||||
background-image: linear-gradient(
|
||||
180deg,
|
||||
rgba(255, 255, 255, 0) 50%,
|
||||
var(--pageBg) 80%
|
||||
),
|
||||
linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%),
|
||||
var(--background-image);
|
||||
}
|
||||
|
||||
.container.active {
|
||||
opacity: 1;
|
||||
transition: opacity calc(var(--speed) / 2) ease-in;
|
||||
background-size: 100%;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
max-height: 900px;
|
||||
opacity: 0;
|
||||
background-image: var(--background-image);
|
||||
background-position: 100% 100%;
|
||||
background-size: 250%;
|
||||
filter: brightness(1.3) saturate(0);
|
||||
mix-blend-mode: overlay;
|
||||
will-change: opacity, background-position;
|
||||
animation: shift-background 60s infinite linear alternate;
|
||||
animation-play-state: paused;
|
||||
transition: opacity var(--speed) ease-in;
|
||||
}
|
||||
|
||||
.active .overlay {
|
||||
opacity: 0.3;
|
||||
animation-play-state: running;
|
||||
transition: opacity calc(var(--speed) * 2) ease-in
|
||||
calc(var(--speed) * 2);
|
||||
}
|
||||
|
||||
.active.out-of-view .overlay,
|
||||
.active.resizing .overlay {
|
||||
animation-play-state: paused;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes shift-background {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
background-size: 250%;
|
||||
}
|
||||
|
||||
25% {
|
||||
background-position: 60% 20%;
|
||||
background-size: 300%;
|
||||
}
|
||||
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
background-size: 320%;
|
||||
}
|
||||
|
||||
75% {
|
||||
background-position: 40% 100%;
|
||||
background-size: 220%;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 20% 50%;
|
||||
background-size: 300%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
src/components/AppEventDate.svelte
Normal file
72
src/components/AppEventDate.svelte
Normal file
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { fade } from 'svelte/transition';
|
||||
import type { Optional } from '@jet/environment/types/optional';
|
||||
import type { AppEvent } from '@jet-app/app-store/api/models';
|
||||
import { getJet } from '~/jet';
|
||||
import {
|
||||
chooseAppEventDate,
|
||||
renderDate,
|
||||
computeAppEventFormattedDates,
|
||||
type RequiredAppEventFormattedDate,
|
||||
} from '~/jet/utils/app-event-formatted-date';
|
||||
|
||||
const jet = getJet();
|
||||
|
||||
/**
|
||||
* New pattern (*prefered*): accept appEvent object and compute formattedDates on client-side.
|
||||
* This avoids timezone differences in SSR server (UTC) which cause incorrect event date and time.
|
||||
* By computing dates in the browser, we ensure the user sees dates in their local timezone.
|
||||
*/
|
||||
export let appEvent:
|
||||
| Pick<AppEvent, 'appEventBadgeKind' | 'startDate' | 'endDate'>
|
||||
| undefined = undefined;
|
||||
|
||||
// Legacy pattern: accept pre-computed formattedDates from Jet
|
||||
export let formattedDates: RequiredAppEventFormattedDate[] | undefined =
|
||||
undefined;
|
||||
|
||||
let appEventDate: Optional<RequiredAppEventFormattedDate>;
|
||||
|
||||
onMount(() => {
|
||||
const dates = appEvent
|
||||
? computeAppEventFormattedDates(
|
||||
jet.objectGraph,
|
||||
appEvent.appEventBadgeKind,
|
||||
appEvent.startDate,
|
||||
appEvent.endDate,
|
||||
)
|
||||
: formattedDates;
|
||||
|
||||
if (dates) {
|
||||
appEventDate = chooseAppEventDate(dates);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* `Date` instances in the view-model will have been serialized to `string`
|
||||
* instances by ServerKit when delivered to the client; we need to normalize
|
||||
* this so that we have a `string` both client- and server-side.
|
||||
*/
|
||||
function normalizeDate(date: Date | string): string {
|
||||
return typeof date === 'string' ? date : date.toISOString();
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if appEventDate}
|
||||
<time
|
||||
transition:fade={{ duration: 210 }}
|
||||
datetime={appEventDate.displayFromDate &&
|
||||
normalizeDate(appEventDate.displayFromDate)}
|
||||
>
|
||||
{renderDate(jet.objectGraph.loc, appEventDate)}
|
||||
</time>
|
||||
{:else}
|
||||
<span aria-hidden="true">…</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
span {
|
||||
color: transparent;
|
||||
}
|
||||
</style>
|
||||
131
src/components/AppIcon.svelte
Normal file
131
src/components/AppIcon.svelte
Normal file
@@ -0,0 +1,131 @@
|
||||
<script lang="ts" context="module">
|
||||
import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
|
||||
import type { NamedProfile } from '~/config/components/artwork';
|
||||
|
||||
export type AppIconProfile = Extract<
|
||||
NamedProfile,
|
||||
| 'app-icon'
|
||||
| 'app-icon-large'
|
||||
| 'app-icon-medium'
|
||||
| 'app-icon-small'
|
||||
| 'app-icon-xlarge'
|
||||
| 'app-icon-river'
|
||||
| 'brick-app-icon'
|
||||
>;
|
||||
|
||||
export function doesAppIconNeedBorder(icon: JetArtworkType): boolean {
|
||||
const doesIconHaveTransparentBackground =
|
||||
icon.backgroundColor &&
|
||||
isNamedColor(icon.backgroundColor) &&
|
||||
icon.backgroundColor.name === 'clear';
|
||||
const isIconPrerendered =
|
||||
icon.style === 'roundedRectPrerendered' ||
|
||||
icon.style === 'roundPrerendered';
|
||||
const isIconUnadorned = icon.style === 'unadorned';
|
||||
|
||||
return (
|
||||
!doesIconHaveTransparentBackground &&
|
||||
!isIconPrerendered &&
|
||||
!isIconUnadorned
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork';
|
||||
import { isNamedColor } from '~/utils/color';
|
||||
|
||||
export let icon: JetArtworkType;
|
||||
export let profile: AppIconProfile = 'app-icon';
|
||||
export let fixedWidth: boolean = true;
|
||||
export let disableAutoCenter: boolean = false;
|
||||
export let withBorder: boolean = false;
|
||||
|
||||
const profiles = ArtworkConfig.get().PROFILES;
|
||||
|
||||
$: computedProfile = (
|
||||
icon.style === 'pill'
|
||||
? `${profile}-pill`
|
||||
: icon.style === 'tvRect'
|
||||
? `${profile}-tv-rect`
|
||||
: profile
|
||||
) as NamedProfile;
|
||||
$: widthFromProfile = profiles?.get(computedProfile)?.[0] ?? 0;
|
||||
$: hasTransparentBackground =
|
||||
!!icon.backgroundColor &&
|
||||
isNamedColor(icon.backgroundColor) &&
|
||||
icon.backgroundColor.name === 'clear';
|
||||
$: needsBorder = withBorder || doesAppIconNeedBorder(icon);
|
||||
|
||||
// These prerendered "Solarium" icons need to use higher than normal quality due to how their
|
||||
// rendering pipeline downscales/transforms sources.
|
||||
$: quality =
|
||||
icon.style &&
|
||||
['roundedRectPrerendered', 'roundPrerendered'].includes(icon.style)
|
||||
? 75
|
||||
: undefined;
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="app-icon"
|
||||
class:pill={icon.style === 'pill'}
|
||||
class:round={icon.style === 'round'}
|
||||
class:rounded-rect={icon.style === 'roundedRect'}
|
||||
class:tv-rect={icon.style === 'tvRect'}
|
||||
class:rounded-rect-prerendered={icon.style === 'roundedRectPrerendered'}
|
||||
class:round-prerendered={icon.style === 'roundPrerendered'}
|
||||
class:with-border={needsBorder}
|
||||
style={fixedWidth ? `--profileWidth: ${widthFromProfile}px` : ''}
|
||||
>
|
||||
<Artwork
|
||||
{disableAutoCenter}
|
||||
{hasTransparentBackground}
|
||||
{quality}
|
||||
artwork={icon}
|
||||
profile={computedProfile}
|
||||
noShelfChevronAnchor={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-icon {
|
||||
aspect-ratio: 1 / 1;
|
||||
min-width: var(--profileWidth, auto);
|
||||
}
|
||||
|
||||
.app-icon.pill {
|
||||
aspect-ratio: 4 / 3;
|
||||
|
||||
/*
|
||||
Creates elliptical corners with horizontal radii at 50% of the width and vertical radii
|
||||
at 65% of the height, for a rounded, squished, pill-like effect
|
||||
*/
|
||||
border-radius: 50% 50% 50% 50% / 65% 65% 65% 65%;
|
||||
}
|
||||
|
||||
.app-icon.round {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.app-icon.rounded-rect {
|
||||
border-radius: 23%;
|
||||
}
|
||||
|
||||
.app-icon.tv-rect {
|
||||
aspect-ratio: 16/9;
|
||||
border-radius: 9% / 16%;
|
||||
}
|
||||
|
||||
.app-icon.rounded-rect-prerendered {
|
||||
border-radius: 25%;
|
||||
}
|
||||
|
||||
.app-icon.round-prerendered {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.app-icon.with-border {
|
||||
box-shadow: 0 0 0 1px var(--systemQuaternary);
|
||||
}
|
||||
</style>
|
||||
92
src/components/AppIconRiver.svelte
Normal file
92
src/components/AppIconRiver.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { Artwork } from '@jet-app/app-store/api/models';
|
||||
import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte';
|
||||
|
||||
export let icons: Artwork[];
|
||||
export let profile: AppIconProfile = 'app-icon-river';
|
||||
|
||||
$: aspectRatio = icons[0].width / icons[0].height;
|
||||
|
||||
let mounted = false;
|
||||
const numberOfIcons = icons.length;
|
||||
|
||||
// We shift the order of the bottom row of icons to ensure that the same icons aren't shown
|
||||
// next to each other. Note that this is different from purely shuffling the icons, as that
|
||||
// could still lead to the same icons being next to one another, due to how small the set is.
|
||||
// The input and output here is as such:
|
||||
// in = [1, 2, 3, 4, 5, 6, 7]
|
||||
// out = [4, 5, 6, 7, 1, 2, 3]
|
||||
const iconsInShiftedOrder = [
|
||||
...icons.slice(numberOfIcons / 2),
|
||||
...icons.slice(0, numberOfIcons / 2),
|
||||
];
|
||||
|
||||
// We are quadrupling the icons we render so the flow is seamless and stretches across the
|
||||
// full width of the container.
|
||||
const topRow = Array(4).fill(icons).flat();
|
||||
const bottomRow = Array(4).fill(iconsInShiftedOrder).flat();
|
||||
|
||||
// We use this `mounted` flag to defer the rendering of the `AppIconRiver`, since it's markup heavy
|
||||
// and has no semantic meaning for SEO. This deferring saves about 190kb of initial HTML per instance.
|
||||
onMount(() => (mounted = true));
|
||||
</script>
|
||||
|
||||
{#if mounted}
|
||||
{#each [topRow, bottomRow] as iconRow}
|
||||
<ul class="app-icons">
|
||||
{#each iconRow as icon}
|
||||
<li
|
||||
class="app-icon-container"
|
||||
style:--aspect-ratio={aspectRatio}
|
||||
>
|
||||
<AppIcon {icon} {profile} fixedWidth={false} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
.app-icons {
|
||||
--icon-width: var(--app-icon-river-icon-width, 128px);
|
||||
--speed: var(--app-icon-river-speed, 240s);
|
||||
--direction: -50%;
|
||||
|
||||
@include rtl {
|
||||
--direction: 50%;
|
||||
}
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
z-index: 2;
|
||||
animation: scroll var(--speed) linear infinite;
|
||||
}
|
||||
|
||||
.app-icons:last-of-type {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
width: var(--icon-width);
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
margin: 8px;
|
||||
}
|
||||
|
||||
.app-icons:last-of-type .app-icon-container {
|
||||
position: relative;
|
||||
right: calc((var(--icon-width) / 2) + 8px);
|
||||
}
|
||||
|
||||
@keyframes scroll {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateX(var(--direction));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
src/components/Artwork.svelte
Normal file
118
src/components/Artwork.svelte
Normal file
@@ -0,0 +1,118 @@
|
||||
<script lang="ts" context="module">
|
||||
import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models';
|
||||
import type {
|
||||
Artwork as ComponentArtworkType,
|
||||
Profile as ArtworkProfile,
|
||||
CropCode,
|
||||
ImageSizes,
|
||||
} from '@amp/web-app-components/src/components/Artwork/types';
|
||||
|
||||
import type { NamedProfile } from '~/config/components/artwork';
|
||||
|
||||
/**
|
||||
* Creates a {@linkcode Profile} on-the-fly based on the properties of
|
||||
* the {@linkcode artwork}
|
||||
*/
|
||||
export function getNaturalProfile(
|
||||
artwork: JetArtworkType,
|
||||
imageSizes: ImageSizes = [artwork.width],
|
||||
): ArtworkProfile {
|
||||
const aspectRatio = artwork.width / artwork.height;
|
||||
|
||||
return [imageSizes, aspectRatio, artwork.crop as CropCode];
|
||||
}
|
||||
|
||||
export type Profile = NamedProfile | ArtworkProfile;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { ImageSettings } from '@amp/web-app-components/src/components/Artwork/types';
|
||||
import Artwork from '@amp/web-app-components/src/components/Artwork/Artwork.svelte';
|
||||
import { colorAsString, isNamedColor } from '~/utils/color';
|
||||
|
||||
import {
|
||||
ArtworkConfig,
|
||||
type ArtworkProfileMap,
|
||||
} from '@amp/web-app-components/config/components/artwork';
|
||||
|
||||
export let artwork: JetArtworkType;
|
||||
export let profile: Profile;
|
||||
export let alt: string = '';
|
||||
export let topRoundedSecondary: boolean = false;
|
||||
export let useContainerStyle: boolean = false;
|
||||
export let forceFullWidth: boolean = true;
|
||||
export let isDecorative: boolean = true;
|
||||
export let lazyLoad: boolean = true;
|
||||
export let disableAutoCenter: boolean = false;
|
||||
export let noShelfChevronAnchor: boolean = false;
|
||||
export let forceCropCode: boolean = false;
|
||||
export let quality: number | undefined = undefined;
|
||||
export let hasTransparentBackground: boolean =
|
||||
!!artwork.backgroundColor &&
|
||||
isNamedColor(artwork.backgroundColor) &&
|
||||
artwork.backgroundColor.name === 'clear';
|
||||
export let useCropCodeFromArtwork: boolean = true;
|
||||
export let withoutBorder: boolean = false;
|
||||
|
||||
let imageSettings: ImageSettings;
|
||||
$: imageSettings = {
|
||||
forceCropCode,
|
||||
hasTransparentBackground,
|
||||
quality,
|
||||
};
|
||||
|
||||
let PROFILES: ArtworkProfileMap<string> | undefined;
|
||||
let computedProfileAttributes: Profile | undefined;
|
||||
|
||||
$: {
|
||||
const config = ArtworkConfig?.get();
|
||||
PROFILES = config?.PROFILES;
|
||||
|
||||
const defaultProfileAttributes: Profile | undefined =
|
||||
typeof profile === 'string' ? PROFILES?.get(profile) : profile;
|
||||
|
||||
const cropCodeIndex = 2;
|
||||
|
||||
if (
|
||||
useCropCodeFromArtwork &&
|
||||
artwork?.crop &&
|
||||
defaultProfileAttributes
|
||||
) {
|
||||
computedProfileAttributes = [...defaultProfileAttributes];
|
||||
computedProfileAttributes[cropCodeIndex] =
|
||||
artwork?.crop as CropCode;
|
||||
}
|
||||
}
|
||||
|
||||
$: artworkForComponent = {
|
||||
...artwork,
|
||||
backgroundColor: artwork.backgroundColor
|
||||
? colorAsString(artwork.backgroundColor)
|
||||
: undefined,
|
||||
} satisfies ComponentArtworkType;
|
||||
</script>
|
||||
|
||||
<Artwork
|
||||
artwork={artworkForComponent}
|
||||
profile={computedProfileAttributes || profile}
|
||||
{topRoundedSecondary}
|
||||
{useContainerStyle}
|
||||
{forceFullWidth}
|
||||
{imageSettings}
|
||||
{alt}
|
||||
{isDecorative}
|
||||
{lazyLoad}
|
||||
{disableAutoCenter}
|
||||
{noShelfChevronAnchor}
|
||||
{withoutBorder}
|
||||
/>
|
||||
|
||||
<style>
|
||||
/* When a user enables the "Smart Invert" accessibility setting, images should not be inverted,
|
||||
so we are re-inverting back to their normal state in this media query, which only currently works for Safari. */
|
||||
@media (inverted-colors: inverted) {
|
||||
:global(.artwork-component img) {
|
||||
filter: invert(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
36
src/components/CollapsableContent.svelte
Normal file
36
src/components/CollapsableContent.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import ChevronDown from '~/sf-symbols/chevron.down.svg';
|
||||
</script>
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
<slot name="summary" />
|
||||
<ChevronDown />
|
||||
</summary>
|
||||
|
||||
<slot />
|
||||
</details>
|
||||
|
||||
<style>
|
||||
details[open] summary {
|
||||
display: none;
|
||||
}
|
||||
|
||||
summary {
|
||||
list-style: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
summary :global(svg) {
|
||||
overflow: visible;
|
||||
width: 14px;
|
||||
fill: var(--systemTertiary);
|
||||
position: relative;
|
||||
top: 3px;
|
||||
left: 2px;
|
||||
}
|
||||
</style>
|
||||
56
src/components/EditorsChoiceBadge.svelte
Normal file
56
src/components/EditorsChoiceBadge.svelte
Normal file
@@ -0,0 +1,56 @@
|
||||
<script lang="ts">
|
||||
import LaurelIcon from '~/sf-symbols/laurel.left.svg';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
const i18n = getI18n();
|
||||
</script>
|
||||
|
||||
<h4>
|
||||
<span class="icon-container left" aria-hidden="true">
|
||||
<LaurelIcon />
|
||||
</span>
|
||||
{$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')}
|
||||
<span class="icon-container right" aria-hidden="true">
|
||||
<LaurelIcon />
|
||||
</span>
|
||||
</h4>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
gap: 10px;
|
||||
font: var(--font, var(--title-1-emphasized));
|
||||
color: var(--systemSecondary);
|
||||
}
|
||||
|
||||
.icon-container.right {
|
||||
transform: rotateY(180deg);
|
||||
|
||||
@include rtl {
|
||||
transform: rotateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container.left {
|
||||
@include rtl {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.icon-container :global(svg) {
|
||||
overflow: visible;
|
||||
height: 42px;
|
||||
transform: translateY(3px);
|
||||
}
|
||||
|
||||
.icon-container :global(svg path) {
|
||||
fill: var(--systemSecondary);
|
||||
}
|
||||
</style>
|
||||
10
src/components/Error.svelte
Normal file
10
src/components/Error.svelte
Normal file
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
import ErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let error: Error;
|
||||
|
||||
const i18n = getI18n();
|
||||
</script>
|
||||
|
||||
<ErrorPage translateFn={$i18n.t} {error} />
|
||||
23
src/components/GradientOverlay.svelte
Normal file
23
src/components/GradientOverlay.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
export let shouldDarken: boolean = true;
|
||||
</script>
|
||||
|
||||
<div class="gradient-overlay" style:--brightness={shouldDarken ? 0.85 : 1} />
|
||||
|
||||
<style>
|
||||
.gradient-overlay {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: var(--height, 60%);
|
||||
border-radius: var(--border-radius, var(--global-border-radius-large));
|
||||
background: linear-gradient(
|
||||
transparent,
|
||||
var(--color, var(--systemSecondary-onLight)) var(--height, 100%)
|
||||
);
|
||||
backdrop-filter: blur(10px);
|
||||
filter: saturate(1.5) brightness(var(--brightness));
|
||||
mask-image: linear-gradient(180deg, transparent 6%, rgb(0, 0, 0.5) 85%);
|
||||
}
|
||||
</style>
|
||||
37
src/components/Grid.svelte
Normal file
37
src/components/Grid.svelte
Normal file
@@ -0,0 +1,37 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { getGridVars } from '@amp/web-app-components/src/components/Shelf/utils/getGridVars';
|
||||
import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
|
||||
|
||||
export let items: T[] = [];
|
||||
export let gridType: GridType;
|
||||
|
||||
$: style = getGridVars(gridType);
|
||||
</script>
|
||||
|
||||
<ul {style} class="grid" data-test-id="grid">
|
||||
{#each items as item}
|
||||
<li>
|
||||
<slot {item} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style lang="scss">
|
||||
@mixin grid-styles-for-viewport($viewport: null) {
|
||||
grid-template-columns: repeat(var(--grid-#{$viewport}), 1fr);
|
||||
column-gap: var(--grid-column-gap-#{$viewport});
|
||||
row-gap: var(--grid-row-gap-#{$viewport});
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
padding: 0 var(--bodyGutter);
|
||||
|
||||
@each $viewport in ('xsmall', 'small', 'medium', 'large', 'xlarge') {
|
||||
@media (--range-#{$viewport}-only) {
|
||||
@include grid-styles-for-viewport($viewport);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
54
src/components/HoverWrapper.svelte
Normal file
54
src/components/HoverWrapper.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
export let element: keyof HTMLElementTagNameMap = 'article';
|
||||
export let hasChin: boolean = false;
|
||||
</script>
|
||||
|
||||
<svelte:element this={element} class="hover-wrapper" class:has-chin={hasChin}>
|
||||
<slot />
|
||||
</svelte:element>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/app/core/mixins/scrim-opacity-controller' as *;
|
||||
@use 'amp/stylekit/core/mixins/hover-style' as *;
|
||||
|
||||
.hover-wrapper {
|
||||
position: relative;
|
||||
display: var(--display, flex);
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
border-radius: var(--global-border-radius-large);
|
||||
box-shadow: var(--shadow-small);
|
||||
|
||||
@include scrim-opacity-controller;
|
||||
}
|
||||
|
||||
.hover-wrapper.has-chin,
|
||||
.hover-wrapper.has-chin::after {
|
||||
// For chins, we cannot use `border-raidus` due a Chrome bug with unequal radii
|
||||
// (e.g. there is no rounding at the bottom) and mask-image. To get around that,
|
||||
// we use clip-path to the same effect.
|
||||
// https://issues.chromium.org/issues/40778541.
|
||||
border-radius: unset;
|
||||
clip-path: inset(
|
||||
0 0 0 0 round var(--global-border-radius-large)
|
||||
var(--global-border-radius-large) 0 0
|
||||
);
|
||||
}
|
||||
|
||||
/* stylelint-disable order/order */
|
||||
.hover-wrapper::after {
|
||||
mix-blend-mode: soft-light;
|
||||
|
||||
@include content-container-hover-style;
|
||||
|
||||
// These properties are overriding those provided by `content-container-hover-style`
|
||||
border-radius: var(--global-border-radius-large);
|
||||
transition: opacity 210ms ease-out;
|
||||
}
|
||||
/* stylelint-enable order/order */
|
||||
|
||||
.hover-wrapper:hover::after {
|
||||
@include scrim-opacity;
|
||||
}
|
||||
</style>
|
||||
69
src/components/LaunchNativeButton.svelte
Normal file
69
src/components/LaunchNativeButton.svelte
Normal file
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
import { getJet } from '~/jet';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import { launchAppOnMac } from '~/utils/launch-client';
|
||||
|
||||
export let url: string;
|
||||
|
||||
const i18n = getI18n();
|
||||
const jet = getJet();
|
||||
|
||||
function handleButtonClick(event: MouseEvent) {
|
||||
// Need to call both event.preventDefault() and event.stopPropagation()
|
||||
// to prevent navigation to the production page on web
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (url) {
|
||||
launchAppOnMac(url);
|
||||
jet.recordCustomMetricsEvent({
|
||||
eventType: 'click',
|
||||
targetId: 'OpenInMacAppStore',
|
||||
targetType: 'button',
|
||||
actionType: 'open',
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
class="get-button blue"
|
||||
aria-label={$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.AX')}
|
||||
on:click={handleButtonClick}
|
||||
>
|
||||
<LineClamp clamp={1}>
|
||||
{$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.Action')}
|
||||
<span>
|
||||
{$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.App')}
|
||||
</span>
|
||||
</LineClamp>
|
||||
<ArrowIcon class="external-link-arrow" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
button span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
button :global(.external-link-arrow) {
|
||||
align-self: center;
|
||||
width: var(--launch-native-button-arrow-size, 9px);
|
||||
height: var(--launch-native-button-arrow-size, 9px);
|
||||
padding-top: 1px;
|
||||
margin-inline-start: 4px;
|
||||
fill: var(--systemPrimary-onDark);
|
||||
|
||||
@include rtl {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
60
src/components/LinkWrapper.svelte
Normal file
60
src/components/LinkWrapper.svelte
Normal file
@@ -0,0 +1,60 @@
|
||||
<!--
|
||||
@component
|
||||
Wraps a link around the provided slot contents if a valid `FlowAction` or `ExternalUrlAction` is given.
|
||||
If no valid action is provided, the contents are rendered as-is with no decoration.
|
||||
|
||||
💡 For accessibility, this component should ideally wrap the entire visual block (e.g., `div`, `article`) so that
|
||||
screen readers and keyboard users interpret the entire element as a single link.
|
||||
|
||||
@example
|
||||
```
|
||||
<LinkWrapper action={item.clickAction}>
|
||||
<article>
|
||||
<Artwork artwork={item.artwork} />
|
||||
{item.title}
|
||||
</article>
|
||||
</LinkWrapper>
|
||||
```
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
|
||||
import { type Opt, isSome } from '@jet/environment/types/optional';
|
||||
|
||||
import FlowActionComponent from '~/components/jet/action/FlowAction.svelte';
|
||||
import { isExternalUrlAction } from '~/jet/models';
|
||||
import ExternalUrlAction from './jet/action/ExternalUrlAction.svelte';
|
||||
import ShelfBasedPageScrollAction, {
|
||||
isShelfBasedPageScrollAction,
|
||||
} from './jet/action/ShelfBasedPageScrollAction.svelte';
|
||||
|
||||
export let action: Opt<Action> = null;
|
||||
export let label: Opt<string> = null;
|
||||
export let withoutLabel: Opt<boolean> = false;
|
||||
export let includeExternalLinkArrowIcon: boolean = true;
|
||||
</script>
|
||||
|
||||
{#if isSome(action) && isFlowAction(action) && isSome(action.pageUrl)}
|
||||
<FlowActionComponent
|
||||
destination={action}
|
||||
aria-label={withoutLabel ? null : label || action.title}
|
||||
>
|
||||
<slot />
|
||||
</FlowActionComponent>
|
||||
{:else if isSome(action) && isExternalUrlAction(action)}
|
||||
<ExternalUrlAction
|
||||
destination={action}
|
||||
aria-label={withoutLabel ? null : label || action.title}
|
||||
includeArrowIcon={includeExternalLinkArrowIcon}
|
||||
>
|
||||
<slot />
|
||||
</ExternalUrlAction>
|
||||
{:else if isSome(action) && isShelfBasedPageScrollAction(action)}
|
||||
<ShelfBasedPageScrollAction
|
||||
destination={action}
|
||||
aria-label={withoutLabel ? null : label || action.title}
|
||||
>
|
||||
<slot />
|
||||
</ShelfBasedPageScrollAction>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
218
src/components/Menu.svelte
Normal file
218
src/components/Menu.svelte
Normal file
@@ -0,0 +1,218 @@
|
||||
<script lang="ts" generics="T">
|
||||
import { tick } from 'svelte';
|
||||
import type { Opt } from '@jet/environment/types/optional';
|
||||
import type { MouseEventHandler } from 'svelte/elements';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { generateUuid } from '@amp/web-apps-utils/src';
|
||||
import {
|
||||
computePosition,
|
||||
autoUpdate,
|
||||
offset,
|
||||
flip,
|
||||
shift,
|
||||
} from '@floating-ui/dom';
|
||||
|
||||
export let options: T[];
|
||||
// Allows the developer the override the floating-ui calculated offset to a fixed number
|
||||
export let forcedXPosition: number | null = null;
|
||||
|
||||
export let handleShowMenu: () => void = () => {};
|
||||
|
||||
let isMenuOpen = false;
|
||||
|
||||
/**
|
||||
* Display the menu
|
||||
*
|
||||
* @example
|
||||
* <script>
|
||||
* let menu;
|
||||
*
|
||||
* function showMenu() {
|
||||
* menu.show();
|
||||
* }
|
||||
* <\/script>
|
||||
*
|
||||
* <Menu bind:this={menu} />
|
||||
*/
|
||||
export async function show() {
|
||||
if (!menuEl) return;
|
||||
|
||||
isMenuOpen = true;
|
||||
|
||||
// Menu position should be updated *only* after the dialog has been shown
|
||||
updateMenuPosition();
|
||||
|
||||
// Focuses the first link in the dropdown after the DOM updates
|
||||
await tick();
|
||||
menuEl.querySelector('a')?.focus();
|
||||
|
||||
// When the modal is open, track viewport changes and update the menu position
|
||||
floatingUIAutoUpdatePositionCleanupCallback = autoUpdate(
|
||||
trigger!,
|
||||
menuEl!,
|
||||
updateMenuPosition,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the menu
|
||||
*
|
||||
* @example
|
||||
* <script>
|
||||
* let menu;
|
||||
*
|
||||
* function closeMenu() {
|
||||
* menu.close();
|
||||
* }
|
||||
* <\/script>
|
||||
*
|
||||
* <Menu bind:this={menu} />
|
||||
*/
|
||||
export function close() {
|
||||
if (!menuEl) return;
|
||||
|
||||
isMenuOpen = false;
|
||||
cleanUpFloatingUIAutoPosition();
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (isMenuOpen) {
|
||||
close();
|
||||
} else {
|
||||
show();
|
||||
handleShowMenu?.();
|
||||
}
|
||||
}
|
||||
|
||||
const menuId = generateUuid();
|
||||
|
||||
let menuEl: HTMLUListElement | undefined;
|
||||
let trigger: HTMLButtonElement | undefined;
|
||||
|
||||
function handleKeyUp(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss the dialog when clicking anywhere with the dialog open
|
||||
*/
|
||||
const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => {
|
||||
const clickedElement = event.target as HTMLElement;
|
||||
|
||||
// Only close the dialog if the click is "outside" of the trigger
|
||||
// Otherwise, it will be closed immediately
|
||||
if (!trigger?.contains(clickedElement)) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
/// MARK: Menu Positioning through `FloatingUI`
|
||||
|
||||
/**
|
||||
* Update the position of the menu to align it with the trigger
|
||||
*/
|
||||
async function updateMenuPosition() {
|
||||
const { x, y } = await computePosition(trigger!, menuEl!, {
|
||||
middleware: [
|
||||
offset({
|
||||
mainAxis: 10,
|
||||
}),
|
||||
|
||||
flip(),
|
||||
shift(),
|
||||
],
|
||||
placement: 'bottom-end',
|
||||
});
|
||||
|
||||
Object.assign(menuEl!.style, {
|
||||
left: `${forcedXPosition || x}px`,
|
||||
top: `${y}px`,
|
||||
});
|
||||
}
|
||||
|
||||
let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>;
|
||||
|
||||
/**
|
||||
* Cleans up the `FloatingUI` auto-update listener, which should only be "active"
|
||||
* while the menu is open
|
||||
*/
|
||||
function cleanUpFloatingUIAutoPosition() {
|
||||
floatingUIAutoUpdatePositionCleanupCallback?.();
|
||||
floatingUIAutoUpdatePositionCleanupCallback = undefined;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Ensures menu is hidden initially
|
||||
if (menuEl) isMenuOpen = false;
|
||||
});
|
||||
|
||||
onDestroy(function () {
|
||||
cleanUpFloatingUIAutoPosition();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} />
|
||||
|
||||
<button
|
||||
class="menu-trigger"
|
||||
aria-controls={menuId}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isMenuOpen}
|
||||
bind:this={trigger}
|
||||
on:click={toggle}
|
||||
>
|
||||
<slot name="trigger" />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
id={menuId}
|
||||
hidden={!isMenuOpen}
|
||||
tabindex="-1"
|
||||
class="menu-popover focus-visible"
|
||||
bind:this={menuEl}
|
||||
>
|
||||
{#each options as option}
|
||||
<li class="menu-item" role="presentation">
|
||||
<slot name="option" {option} />
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--menu-common-padding: 4px 8px;
|
||||
}
|
||||
|
||||
.menu-trigger {
|
||||
background-color: var(--menu-trigger-background-color);
|
||||
border-radius: var(--menu-trigger-border-radius);
|
||||
font: var(--menu-trigger-font);
|
||||
padding: var(--menu-trigger-padding, var(--menu-common-padding));
|
||||
}
|
||||
|
||||
.menu-popover {
|
||||
background-color: var(--menu-popover-background-color, var(--pageBg));
|
||||
padding: var(--menu-popover-padding, 0);
|
||||
border: var(--menu-popover-border, none);
|
||||
border-radius: var(
|
||||
--menu-popover-border-radius,
|
||||
var(--global-border-radius-large)
|
||||
);
|
||||
box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium));
|
||||
position: absolute;
|
||||
inset: auto;
|
||||
z-index: var(--menu-popover-z-index, 2);
|
||||
}
|
||||
|
||||
.menu-popover::backdrop {
|
||||
background: var(--menu-popover-backdrop-background, none);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
padding: var(--menu-item-padding, var(--menu-common-padding));
|
||||
margin: var(--menu-item-margin, 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
152
src/components/MotionArtwork.svelte
Normal file
152
src/components/MotionArtwork.svelte
Normal file
@@ -0,0 +1,152 @@
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount, onDestroy } from 'svelte';
|
||||
import { loggerFor } from '@amp/web-apps-logger';
|
||||
|
||||
const logger = loggerFor('components/MotionArtwork');
|
||||
|
||||
type HLSError = {
|
||||
type: string;
|
||||
message: string;
|
||||
details: string;
|
||||
fatal: boolean;
|
||||
handled: boolean;
|
||||
};
|
||||
|
||||
type MotionArtworkError = {
|
||||
type: string;
|
||||
reason: string;
|
||||
fatal: boolean;
|
||||
error?: Error;
|
||||
};
|
||||
|
||||
/** HTML `id` attribute for the <video /> element */
|
||||
export let id: string;
|
||||
|
||||
/** Source URL for the video, an HLS playlist ending in .m3u8 */
|
||||
export let src: string;
|
||||
|
||||
/** Poster image to show while the video is loading */
|
||||
export let poster: string | undefined;
|
||||
|
||||
/** If the video should loop from end to start. */
|
||||
export let loop: boolean = true;
|
||||
|
||||
/** If the audio should be muted on the video. */
|
||||
export let muted: boolean = true;
|
||||
|
||||
/** If the video should be paused when initially loaded. */
|
||||
export let paused: boolean = true;
|
||||
|
||||
/** The constructor to use for creating an Hls playback session. */
|
||||
export let HLS: Window['Hls'] = window.Hls;
|
||||
|
||||
/** RTCReportingAgent instance for RTC reporting on video playback. */
|
||||
export let reportingAgent: any = undefined;
|
||||
|
||||
/** HTMLVideoElement used by HLS.js to render the video */
|
||||
export let videoElement: HTMLVideoElement | null = null;
|
||||
|
||||
/** Internal error state for the component */
|
||||
let errorState: MotionArtworkError | undefined;
|
||||
|
||||
let hlsSession: Window['Hls'] | undefined;
|
||||
|
||||
/** Dispatcher for errors. */
|
||||
const dispatch = createEventDispatcher<{ error: MotionArtworkError }>();
|
||||
|
||||
function handleError(details: MotionArtworkError) {
|
||||
logger.error(
|
||||
`Error playing MotionArtwork with HLS: ${details?.reason}`,
|
||||
details?.error,
|
||||
);
|
||||
|
||||
errorState = {
|
||||
type: details.type,
|
||||
reason: details.reason,
|
||||
fatal: details.fatal,
|
||||
error: details?.error,
|
||||
};
|
||||
|
||||
dispatch('error', errorState);
|
||||
}
|
||||
|
||||
const hlsSupported = HLS?.isSupported() ?? false;
|
||||
|
||||
onMount(function () {
|
||||
if (!hlsSupported) {
|
||||
handleError({
|
||||
type: 'runtime',
|
||||
reason: 'unsupported',
|
||||
fatal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new HLS.js playback session
|
||||
hlsSession = new HLS({
|
||||
debug: false,
|
||||
debugLevel: 'error',
|
||||
enablePerformanceLogging: false,
|
||||
nativeControlsEnabled: false,
|
||||
|
||||
appData: {
|
||||
reportingAgent: reportingAgent,
|
||||
serviceName: reportingAgent?.ServiceName,
|
||||
},
|
||||
});
|
||||
|
||||
hlsSession.on(
|
||||
HLS.Events.ERROR,
|
||||
function (_event: string, error: HLSError) {
|
||||
handleError({
|
||||
type: 'hls',
|
||||
reason: error.message,
|
||||
fatal: error.fatal,
|
||||
error: error as unknown as Error,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Direct HLS.js to the VideoElement to use and start loading the video source
|
||||
hlsSession.attachMedia(videoElement);
|
||||
hlsSession.loadSource(src, {
|
||||
/* HLS.js loading options go here */
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
// Stop the video, release resources, and destroy the HLS context
|
||||
hlsSession?.destroy();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if errorState !== undefined}
|
||||
<slot name="error" error={errorState} {poster} />
|
||||
{:else}
|
||||
<!-- svelte-ignore a11y-media-has-caption -->
|
||||
<video
|
||||
{id}
|
||||
{loop}
|
||||
{poster}
|
||||
preload="none"
|
||||
data-loop={true}
|
||||
playsinline={true}
|
||||
controls={false}
|
||||
bind:this={videoElement}
|
||||
bind:muted
|
||||
bind:paused
|
||||
on:play
|
||||
on:ended
|
||||
on:loadedmetadata
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: center center;
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
}
|
||||
</style>
|
||||
68
src/components/Page.svelte
Normal file
68
src/components/Page.svelte
Normal file
@@ -0,0 +1,68 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
type Page,
|
||||
hasVisionProUrl,
|
||||
isAppEventDetailPage,
|
||||
isArticlePage,
|
||||
isChartsHubPage,
|
||||
isGenericPage,
|
||||
isSearchLandingPage,
|
||||
isShelfBasedProductPage,
|
||||
isTopChartsPage,
|
||||
isTodayPage,
|
||||
isSearchResultsPage,
|
||||
isStaticMessagePage,
|
||||
isSeeAllPage,
|
||||
isErrorPage,
|
||||
} from '~/jet/models';
|
||||
|
||||
import AppEventDetailPage from './pages/AppEventDetailPage.svelte';
|
||||
import ArticlePage from './pages/ArticlePage.svelte';
|
||||
import ChartsHubPage from './pages/ChartsHubPage.svelte';
|
||||
import DefaultPage from './pages/DefaultPage.svelte';
|
||||
import ErrorPage from './pages/ErrorPage.svelte';
|
||||
import ProductPage from './pages/ProductPage.svelte';
|
||||
import VisionProPage from './pages/VisionProPage.svelte';
|
||||
import StaticMessagePageComponent from './pages/StaticMessagePage.svelte';
|
||||
import SearchLandingPage from './pages/SearchLandingPage.svelte';
|
||||
import SearchResultsPage from './pages/SearchResultsPage.svelte';
|
||||
import TopChartsPage from './pages/TopChartsPage.svelte';
|
||||
import TodayPage from './pages/TodayPage.svelte';
|
||||
import SeeAllPage from './pages/SeeAllPage.svelte';
|
||||
import MetaTags from '~/components/structure/MetaTags.svelte';
|
||||
import PageModal from '~/components/PageModal.svelte';
|
||||
|
||||
export let page: Page;
|
||||
</script>
|
||||
|
||||
<MetaTags {page} />
|
||||
|
||||
<PageModal />
|
||||
|
||||
{#if isAppEventDetailPage(page)}
|
||||
<AppEventDetailPage {page} />
|
||||
{:else if isArticlePage(page)}
|
||||
<ArticlePage {page} />
|
||||
{:else if isChartsHubPage(page)}
|
||||
<ChartsHubPage {page} />
|
||||
{:else if isSearchLandingPage(page)}
|
||||
<SearchLandingPage {page} />
|
||||
{:else if isSearchResultsPage(page)}
|
||||
<SearchResultsPage {page} />
|
||||
{:else if isShelfBasedProductPage(page)}
|
||||
<ProductPage {page} />
|
||||
{:else if isTopChartsPage(page)}
|
||||
<TopChartsPage {page} />
|
||||
{:else if isGenericPage(page) && hasVisionProUrl(page)}
|
||||
<VisionProPage {page} />
|
||||
{:else if isTodayPage(page)}
|
||||
<TodayPage {page} />
|
||||
{:else if isStaticMessagePage(page)}
|
||||
<StaticMessagePageComponent {page} />
|
||||
{:else if isSeeAllPage(page)}
|
||||
<SeeAllPage {page} />
|
||||
{:else if isErrorPage(page)}
|
||||
<ErrorPage {page} />
|
||||
{:else}
|
||||
<DefaultPage {page} />
|
||||
{/if}
|
||||
82
src/components/PageModal.svelte
Normal file
82
src/components/PageModal.svelte
Normal file
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import { onMount, type SvelteComponent } from 'svelte';
|
||||
import type { GenericPage } from '@jet-app/app-store/api/models';
|
||||
|
||||
import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte';
|
||||
import { getModalPageStore } from '~/stores/modalPage';
|
||||
import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
|
||||
import ContentModal from '~/components/jet/item/ContentModal.svelte';
|
||||
import { LICENSE_AGREEMENT_MODAL_ID } from '~/utils/metrics';
|
||||
|
||||
let modalElement: SvelteComponent;
|
||||
let modalPage = getModalPageStore();
|
||||
let page: GenericPage | undefined;
|
||||
|
||||
$: page = $modalPage?.page;
|
||||
$: shelves = page?.shelves ?? [];
|
||||
$: title = page?.title ?? null;
|
||||
$: targetId =
|
||||
$modalPage?.pageDetail === 'licenseAgreement'
|
||||
? LICENSE_AGREEMENT_MODAL_ID
|
||||
: undefined;
|
||||
|
||||
onMount(() => {
|
||||
return modalPage.clearPage;
|
||||
});
|
||||
|
||||
$: {
|
||||
if ($modalPage) {
|
||||
modalElement?.showModal();
|
||||
} else {
|
||||
handleModalClose();
|
||||
}
|
||||
}
|
||||
|
||||
function handleModalClose() {
|
||||
modalElement?.close();
|
||||
modalPage.clearPage();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
modalTriggerElement={null}
|
||||
bind:this={modalElement}
|
||||
on:close={handleModalClose}
|
||||
>
|
||||
<div class="modal-content">
|
||||
{#if page}
|
||||
<ContentModal
|
||||
{title}
|
||||
subtitle={null}
|
||||
on:close={handleModalClose}
|
||||
{targetId}
|
||||
>
|
||||
<svelte:fragment slot="content">
|
||||
{#each shelves as shelf}
|
||||
<ShelfComponent {shelf}>
|
||||
<slot
|
||||
name="marker-shelf"
|
||||
slot="marker-shelf"
|
||||
let:shelf
|
||||
{shelf}
|
||||
/>
|
||||
</ShelfComponent>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
</ContentModal>
|
||||
{/if}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<style lang="scss">
|
||||
.modal-content :global(p) {
|
||||
user-select: text;
|
||||
margin-bottom: 15px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
:global(.noscroll) {
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
}
|
||||
</style>
|
||||
25
src/components/PageResolver.svelte
Normal file
25
src/components/PageResolver.svelte
Normal file
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
import type { Page } from '~/jet/models';
|
||||
|
||||
import PageComponent from '~/components/Page.svelte';
|
||||
import ErrorComponent from '~/components/Error.svelte';
|
||||
import LoadingSpinner from '@amp/web-app-components/src/components/LoadingSpinner/LoadingSpinner.svelte';
|
||||
|
||||
export let page: Promise<Page> | Page;
|
||||
export let isFirstPage: boolean;
|
||||
</script>
|
||||
|
||||
{#await page}
|
||||
<div data-testid="page-loading">
|
||||
<!--
|
||||
Delay showing the spinner on initial page load after app boot.
|
||||
After that, the FlowAction handler already waits 500ms before
|
||||
it changes DOM, so we only need to wait 1000ms.
|
||||
-->
|
||||
<LoadingSpinner delay={isFirstPage ? 1500 : 1000} />
|
||||
</div>
|
||||
{:then page}
|
||||
<PageComponent {page} />
|
||||
{:catch error}
|
||||
<ErrorComponent {error} />
|
||||
{/await}
|
||||
188
src/components/ProductPageArcadeBanner.svelte
Normal file
188
src/components/ProductPageArcadeBanner.svelte
Normal file
@@ -0,0 +1,188 @@
|
||||
<script lang="ts">
|
||||
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
const i18n = getI18n();
|
||||
</script>
|
||||
|
||||
<aside>
|
||||
<div class="arcade-banner">
|
||||
<div class="metadata-container">
|
||||
<div class="logo-container">
|
||||
<AppleArcadeLogo />
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
|
||||
<br />
|
||||
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
|
||||
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
|
||||
</h2>
|
||||
|
||||
<a href="https://www.apple.com/apple-arcade/" target="_blank">
|
||||
<span>
|
||||
{$i18n.t(
|
||||
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
|
||||
)}
|
||||
</span>
|
||||
{$i18n.t(
|
||||
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/app/core/globalvars' as *;
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
@use 'ac-sasskit/modules/viewportcontent/core' as *;
|
||||
@use 'amp/stylekit/core/viewports' as *;
|
||||
|
||||
.logo-container {
|
||||
width: 62px;
|
||||
margin-bottom: 10px;
|
||||
line-height: 0;
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
width: 48px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-container :global(path) {
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
padding: 0 20px;
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 10px;
|
||||
font: var(--title-1-emphasized);
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
margin-bottom: 8px;
|
||||
font: var(--title-3-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
font: var(--title-3-emphasized);
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
font: var(--body-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
a::after {
|
||||
content: '↗';
|
||||
font-weight: normal;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
aside {
|
||||
width: 100%;
|
||||
max-width: calc(viewport-content-for(xlarge));
|
||||
height: 152px;
|
||||
margin: 0 auto 32px;
|
||||
padding: 0 var(--bodyGutter);
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.arcade-banner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--systemPrimary-onDark);
|
||||
border-radius: var(--global-border-radius-medium);
|
||||
background: #000;
|
||||
background-repeat: no-repeat;
|
||||
background-position: right;
|
||||
background-size: contain;
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
border: 1px solid var(--systemQuaternary-onDark);
|
||||
}
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
border-radius: 0;
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
|
||||
background-size: cover;
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
|
||||
background-position: left;
|
||||
}
|
||||
|
||||
@media (resolution >= 192dpi) {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
|
||||
background-position: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (--range-small-only) {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png');
|
||||
background-position: left;
|
||||
}
|
||||
|
||||
@media (resolution >= 192dpi) {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png');
|
||||
background-position: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (--range-medium-up) {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-980@1x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-980@1x_RTL.png');
|
||||
background-position: left;
|
||||
}
|
||||
|
||||
@media (resolution >= 192dpi) {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-980@2x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/banner-980@2x_RTL.png');
|
||||
background-position: left;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
159
src/components/ProductPageArcadeFooter.svelte
Normal file
159
src/components/ProductPageArcadeFooter.svelte
Normal file
@@ -0,0 +1,159 @@
|
||||
<script lang="ts">
|
||||
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
const i18n = getI18n();
|
||||
</script>
|
||||
|
||||
<article>
|
||||
<div class="metadata-container">
|
||||
<div class="logo-container">
|
||||
<AppleArcadeLogo />
|
||||
</div>
|
||||
|
||||
<h2>
|
||||
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')}
|
||||
<br />
|
||||
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')}
|
||||
<br />
|
||||
{$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')}
|
||||
</h2>
|
||||
|
||||
<a href="https://www.apple.com/apple-arcade/" target="_blank">
|
||||
<span>
|
||||
{$i18n.t(
|
||||
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText',
|
||||
)}
|
||||
</span>
|
||||
{$i18n.t(
|
||||
'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark',
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
@use 'ac-sasskit/modules/viewportcontent/core' as *;
|
||||
@use 'amp/stylekit/core/viewports' as *;
|
||||
|
||||
.logo-container {
|
||||
width: 72px;
|
||||
margin-bottom: 20px;
|
||||
line-height: 0;
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
width: 62px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.logo-container :global(path) {
|
||||
color: var(--systemPrimary-onDark);
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
padding: 40px;
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
align-items: center;
|
||||
justify-content: end;
|
||||
width: unset;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 20px;
|
||||
font: var(--header-emphasized);
|
||||
line-height: 54px;
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
font: var(--title-1-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
a {
|
||||
display: flex;
|
||||
font: var(--title-3-emphasized);
|
||||
}
|
||||
|
||||
a::after {
|
||||
content: '↗';
|
||||
font-weight: normal;
|
||||
margin-inline-start: 4px;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover span {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
article {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
max-width: calc(viewport-content-for(xlarge) - var(--bodyGutter) * 2);
|
||||
aspect-ratio: 2.55;
|
||||
margin: 0 auto;
|
||||
color: var(--systemPrimary-onDark);
|
||||
background: #000;
|
||||
background-size: cover;
|
||||
|
||||
@media (--range-xsmall-only) {
|
||||
max-width: 338px;
|
||||
aspect-ratio: 35/48;
|
||||
border-radius: var(--global-border-radius-medium);
|
||||
background-image: url('/assets/images/arcade/upsell/footer-280@1x.png');
|
||||
|
||||
@media (resolution >= 192dpi) {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-280@2x.png');
|
||||
}
|
||||
}
|
||||
|
||||
@media (--range-small-only) {
|
||||
aspect-ratio: 173/96;
|
||||
background-image: url('/assets/images/arcade/upsell/footer-692@1x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-692@1x_RTL.png');
|
||||
}
|
||||
|
||||
@media (resolution >= 192dpi) {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-692@2x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-692@2x_RTL.png');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (--range-medium-up) {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-980@1x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-980@1x_RTL.png');
|
||||
}
|
||||
|
||||
@media (resolution >= 192dpi) {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-980@2x_LTR.png');
|
||||
|
||||
@include rtl {
|
||||
background-image: url('/assets/images/arcade/upsell/footer-980@2x_RTL.png');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (--range-xlarge-up) {
|
||||
border-radius: var(--global-border-radius-medium);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
51
src/components/SFSymbol.svelte
Normal file
51
src/components/SFSymbol.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<!--
|
||||
@component
|
||||
Renders a supported "SF Symbol" from the icons available in `~/sf-symbols`
|
||||
-->
|
||||
<script lang="ts" context="module">
|
||||
import type { ComponentType } from 'svelte';
|
||||
|
||||
const iconComponents = import.meta.glob('~/sf-symbols/*.svg', {
|
||||
eager: true,
|
||||
import: 'default',
|
||||
});
|
||||
|
||||
const iconNameToComponent: Record<string, ComponentType | undefined> =
|
||||
Object.fromEntries(
|
||||
Object.entries(iconComponents).map(
|
||||
([fullPathToIcon, iconComponent]) => {
|
||||
const iconName = fullPathToIcon
|
||||
.replace('/src/sf-symbols/', '')
|
||||
.replace('.svg', '');
|
||||
|
||||
return [iconName, iconComponent as ComponentType];
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
/**
|
||||
* The list of all supported icons
|
||||
*
|
||||
* This is exposed only for testing/Storybook purposes
|
||||
*/
|
||||
export const __iconNames = Object.keys(iconNameToComponent);
|
||||
|
||||
export function getIconComponentByName(iconName: string) {
|
||||
return iconNameToComponent[iconName];
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The name of the SF Symbol to render
|
||||
*
|
||||
* Must match the name of an `.svg` file in `~/sf-symbols`. If a file with a matching
|
||||
* name does not exist, nothing will be rendered
|
||||
*/
|
||||
export let name: string;
|
||||
export let ariaHidden: boolean = false;
|
||||
|
||||
$: icon = getIconComponentByName(name);
|
||||
</script>
|
||||
|
||||
<svelte:component this={icon} aria-hidden={ariaHidden ? 'true' : 'false'} />
|
||||
90
src/components/ShareArrowButton.svelte
Normal file
90
src/components/ShareArrowButton.svelte
Normal file
@@ -0,0 +1,90 @@
|
||||
<script lang="ts" context="module">
|
||||
export function isShareSupported() {
|
||||
return (
|
||||
typeof navigator !== 'undefined' &&
|
||||
typeof navigator.share === 'function'
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SFSymbol from '~/components/SFSymbol.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let url: string;
|
||||
export let withLabel: boolean = false;
|
||||
|
||||
const i18n = getI18n();
|
||||
|
||||
$: isShareSheetOpen = false;
|
||||
|
||||
async function handleShareClick() {
|
||||
isShareSheetOpen = !isShareSheetOpen;
|
||||
|
||||
try {
|
||||
await navigator.share({ url });
|
||||
isShareSheetOpen = false;
|
||||
} catch {
|
||||
isShareSheetOpen = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
on:click={handleShareClick}
|
||||
aria-label={$i18n.t('ASE.Web.AppStore.Share.Button.AccessibilityValue')}
|
||||
class:active={isShareSheetOpen}
|
||||
class:with-label={withLabel}
|
||||
>
|
||||
<SFSymbol name="square.and.arrow.up" ariaHidden={true} />
|
||||
|
||||
{#if withLabel}
|
||||
{$i18n.t('ASE.Web.AppStore.Share.Button.Value')}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style lang="scss">
|
||||
button {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: var(--share-arrow-size, 32px);
|
||||
height: var(--share-arrow-size, 32px);
|
||||
border-radius: var(--share-arrow-size, 32px);
|
||||
background: var(--systemQuaternary-onDark);
|
||||
transition: background-color 210ms ease-out;
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
button.with-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: auto;
|
||||
padding: 0 16px;
|
||||
gap: 8px;
|
||||
font: var(--body-emphasized);
|
||||
|
||||
:global(svg) {
|
||||
height: 16px;
|
||||
width: auto;
|
||||
top: -2px;
|
||||
position: relative;
|
||||
}
|
||||
}
|
||||
|
||||
button.active,
|
||||
button:hover {
|
||||
// stylelint-disable color-function-notation
|
||||
background-color: rgb(from var(--systemTertiary-onDark) r g b/.13);
|
||||
// stylelint-enable color-function-notation
|
||||
}
|
||||
|
||||
button :global(svg) {
|
||||
width: 37%;
|
||||
fill: var(--systemPrimary-onDark);
|
||||
overflow: visible;
|
||||
}
|
||||
</style>
|
||||
112
src/components/Shelf/Title.svelte
Normal file
112
src/components/Shelf/Title.svelte
Normal file
@@ -0,0 +1,112 @@
|
||||
<!--
|
||||
@component
|
||||
|
||||
Renders the "Title" and "See All action" for a `Shelf`
|
||||
|
||||
### Supported CSS Variables
|
||||
|
||||
- `--shelf-title-font`: overrides the font used for the "title" element
|
||||
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { type Opt, isSome } from '@jet/environment/types/optional';
|
||||
import { type Action, isFlowAction } from '@jet-app/app-store/api/models';
|
||||
|
||||
import SFSymbol from '~/components/SFSymbol.svelte';
|
||||
import LinkWrapper from '../LinkWrapper.svelte';
|
||||
|
||||
export let title: string;
|
||||
export let subtitle: Opt<string> = undefined;
|
||||
export let seeAllAction: Opt<Action> = undefined;
|
||||
</script>
|
||||
|
||||
<div class="title-action-wrapper" class:with-subtitle={!!subtitle}>
|
||||
<LinkWrapper action={seeAllAction} label={title}>
|
||||
<div class="link-contents">
|
||||
<h2 class="shelf-title" data-test-id="shelf-title">{title}</h2>
|
||||
|
||||
{#if isSome(seeAllAction) && isFlowAction(seeAllAction)}
|
||||
<span
|
||||
class="chevron-container"
|
||||
data-test-id="shelf-see-all-chevron"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<SFSymbol name="chevron.forward" />
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</LinkWrapper>
|
||||
</div>
|
||||
|
||||
{#if subtitle}
|
||||
<p>{subtitle}</p>
|
||||
{/if}
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/helpers' as *;
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
.title-action-wrapper {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
justify-content: space-between;
|
||||
margin: 0 var(--bodyGutter) 13px;
|
||||
}
|
||||
|
||||
.title-action-wrapper.with-subtitle {
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.title-action-wrapper :global(a:hover) {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p {
|
||||
font: var(--title-3-tall);
|
||||
color: var(--systemSecondary);
|
||||
margin: 0 var(--bodyGutter) 13px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: var(--systemPrimary, #000);
|
||||
font: var(--shelf-title-font, var(--title-2-emphasized));
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.link-contents {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chevron-container {
|
||||
line-height: 0;
|
||||
padding: 6px 0 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.chevron-container :global(svg) {
|
||||
height: 12px;
|
||||
display: block;
|
||||
translate: 0 0;
|
||||
transition: translate 210ms ease-out;
|
||||
|
||||
@include rtl {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.chevron-container :global(svg path:not([fill='none'])) {
|
||||
fill: var(--systemGray2);
|
||||
}
|
||||
|
||||
.link-contents:hover .chevron-container :global(svg) {
|
||||
translate: 1px 0;
|
||||
|
||||
@include rtl {
|
||||
transform: rotate(180deg);
|
||||
translate: -1px 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
81
src/components/Shelf/Wrapper.svelte
Normal file
81
src/components/Shelf/Wrapper.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { Shelf } from '@jet-app/app-store/api/models';
|
||||
import ShelfTitle from '~/components/Shelf/Title.svelte';
|
||||
|
||||
export let shelf: Shelf | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Whether or not to automatically display the shelf "centered" at the normal
|
||||
* page width for the App Store
|
||||
*
|
||||
* When `false`, the shelf is not constrained horizontally in any way
|
||||
*
|
||||
* The value of this property may be ignored when the shelf's `.presentationHints`
|
||||
* indicate that it is being rendered in a context where "centering" would not be
|
||||
* appropriate
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
export let centered: boolean = false;
|
||||
|
||||
export let withTopBorder: boolean = false;
|
||||
export let withTopMargin: boolean = false;
|
||||
export let withPaddingTop: boolean = true;
|
||||
export let withBottomPadding: boolean = true;
|
||||
|
||||
$: seeAllAction =
|
||||
shelf?.header?.titleAction ??
|
||||
shelf?.header?.accessoryAction ??
|
||||
shelf?.seeAllAction;
|
||||
</script>
|
||||
|
||||
<section
|
||||
id={shelf?.id}
|
||||
data-test-id="shelf-wrapper"
|
||||
class="shelf"
|
||||
class:centered
|
||||
class:border-top={withTopBorder}
|
||||
class:margin-top={withTopMargin}
|
||||
class:padding-top={withPaddingTop}
|
||||
class:padding-bottom={withBottomPadding}
|
||||
>
|
||||
{#if $$slots['title']}
|
||||
<slot name="title" />
|
||||
{:else if shelf?.header?.title}
|
||||
<ShelfTitle
|
||||
title={shelf.header.title}
|
||||
subtitle={shelf.header.subtitle}
|
||||
{seeAllAction}
|
||||
/>
|
||||
{:else if shelf?.title}
|
||||
<ShelfTitle
|
||||
title={shelf.title}
|
||||
subtitle={shelf.subtitle}
|
||||
{seeAllAction}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.padding-top {
|
||||
padding-top: 13px;
|
||||
}
|
||||
|
||||
.centered {
|
||||
margin: 0 var(--bodyGutter);
|
||||
}
|
||||
|
||||
.margin-top {
|
||||
margin-top: 13px;
|
||||
}
|
||||
|
||||
.border-top {
|
||||
border-top: 1px solid var(--systemGray4);
|
||||
}
|
||||
|
||||
.shelf.padding-bottom {
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
</style>
|
||||
103
src/components/ShelfItemLayout.svelte
Normal file
103
src/components/ShelfItemLayout.svelte
Normal file
@@ -0,0 +1,103 @@
|
||||
<!--
|
||||
@component
|
||||
Renders a set of `Shelf` items in either a horizontal shelf
|
||||
or a grid, depending on the `shelf` configuration
|
||||
|
||||
Note: when configuring the `gridType` property, a single value will be used
|
||||
for both the shelf-based or grid-based item layouts. If two different grid types
|
||||
are needed instead, `gridTypeForShelf` and `gridTypeForGrid` are needed instead;
|
||||
these properties cannot be used alongside the general-purpose `gridType`.
|
||||
-->
|
||||
<script lang="ts" generics="Item">
|
||||
import type { Shelf } from '@jet-app/app-store/api/models';
|
||||
|
||||
import type { GridType } from '@amp/web-app-components/src/components/Shelf/types';
|
||||
|
||||
import type { XOR } from '~/utils/types';
|
||||
import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
|
||||
import Grid from '~/components/Grid.svelte';
|
||||
|
||||
/**
|
||||
* The sub-set of {@linkcode Shelf} that is necesary to render this component
|
||||
*/
|
||||
interface RequiredShelf
|
||||
extends Pick<Shelf, 'rowsPerColumn' | 'isHorizontal'> {
|
||||
items: Item[];
|
||||
}
|
||||
|
||||
interface $$Slots {
|
||||
default: {
|
||||
item: Item;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the `gridType` properties of this component
|
||||
*
|
||||
* Either a `gridType` that will be used for both the shelf or grid
|
||||
* layouts can be provided, OR specific properties for the grid type
|
||||
* for the shelf and grid respectively; this `XOR` here prevents
|
||||
* these approachs from being mixed-and-matched.
|
||||
*/
|
||||
type GeneralOrIndividualGridType = XOR<
|
||||
{
|
||||
gridType: GridType;
|
||||
},
|
||||
{
|
||||
gridTypeForGrid: GridType;
|
||||
gridTypeForShelf: GridType;
|
||||
}
|
||||
>;
|
||||
|
||||
type $$Props = GeneralOrIndividualGridType & {
|
||||
shelf: RequiredShelf;
|
||||
rowsPerColumnOverride?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The shelf to render items for
|
||||
*/
|
||||
export let shelf: RequiredShelf;
|
||||
|
||||
/**
|
||||
* An optional override of the shelfs `rowsPerColumn` property
|
||||
*/
|
||||
export let rowsPerColumnOverride: number | null = null;
|
||||
|
||||
/**
|
||||
* Determine the grid type configuration for the shelf or grid layouts
|
||||
* based on the mutually-exclusive properties of {@linkcode GeneralOrIndividualGridType}
|
||||
*/
|
||||
function extractGridTypes(props: $$Props) {
|
||||
if (typeof props.gridType === 'string') {
|
||||
return {
|
||||
gridTypeForShelf: props.gridType,
|
||||
gridTypeForGrid: props.gridType,
|
||||
};
|
||||
} else {
|
||||
return props;
|
||||
}
|
||||
}
|
||||
|
||||
$: ({ gridTypeForShelf, gridTypeForGrid } = extractGridTypes(
|
||||
$$props as $$Props,
|
||||
));
|
||||
|
||||
$: isHorizontal = shelf.isHorizontal;
|
||||
$: gridRows = rowsPerColumnOverride ?? shelf.rowsPerColumn ?? undefined;
|
||||
</script>
|
||||
|
||||
{#if isHorizontal}
|
||||
<HorizontalShelf
|
||||
items={shelf.items}
|
||||
{gridRows}
|
||||
gridType={gridTypeForShelf}
|
||||
let:item
|
||||
>
|
||||
<slot {item} />
|
||||
</HorizontalShelf>
|
||||
{:else}
|
||||
<Grid items={shelf.items} gridType={gridTypeForGrid} let:item>
|
||||
<slot {item} />
|
||||
</Grid>
|
||||
{/if}
|
||||
80
src/components/StarRating.svelte
Normal file
80
src/components/StarRating.svelte
Normal file
@@ -0,0 +1,80 @@
|
||||
<script lang="ts" context="module">
|
||||
export function calculateStarFillPercentages(rating: number) {
|
||||
return [1, 2, 3, 4, 5].map((position) => {
|
||||
if (position <= Math.floor(rating)) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
if (position > Math.ceil(rating)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.round((rating % 1) * 100);
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import StarFilledIcon from '@amp/web-app-components/assets/icons/star-filled.svg';
|
||||
import StarHollowIcon from '@amp/web-app-components/assets/icons/star-hollow.svg';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
|
||||
export let rating: number;
|
||||
|
||||
const i18n = getI18n();
|
||||
|
||||
$: starFillPercentages = calculateStarFillPercentages(rating);
|
||||
$: label = $i18n.t('ASE.Web.AppStore.Review.StarsAria', {
|
||||
count: rating,
|
||||
});
|
||||
</script>
|
||||
|
||||
<ol class="stars" aria-label={label}>
|
||||
{#each starFillPercentages as fillPercent}
|
||||
<li class="star">
|
||||
{#if fillPercent === 100}
|
||||
<StarFilledIcon />
|
||||
{:else if fillPercent === 0}
|
||||
<StarHollowIcon />
|
||||
{:else}
|
||||
<div
|
||||
class="partial-star"
|
||||
style:--partial-star-width={`${fillPercent}%`}
|
||||
>
|
||||
<StarFilledIcon />
|
||||
</div>
|
||||
|
||||
<StarHollowIcon />
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ol>
|
||||
|
||||
<style>
|
||||
.stars {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.star {
|
||||
position: relative;
|
||||
margin-inline-end: 2px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.star :global(svg) {
|
||||
height: var(--star-size, 10px);
|
||||
width: var(--star-size, 10px);
|
||||
fill: var(--fill-color, rgb(127, 127, 127));
|
||||
}
|
||||
|
||||
.partial-star {
|
||||
position: absolute;
|
||||
overflow: hidden;
|
||||
width: var(--partial-star-width);
|
||||
fill: var(--fill-color, rgb(127, 127, 127));
|
||||
}
|
||||
|
||||
.partial-star :global(path) {
|
||||
stroke: transparent;
|
||||
}
|
||||
</style>
|
||||
52
src/components/SystemImage.svelte
Normal file
52
src/components/SystemImage.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<!--
|
||||
@component
|
||||
Renders an `Artwork` view model that references an SF Symbol through a `systemimage://` or `resource://` template URL
|
||||
-->
|
||||
<script lang="ts" context="module">
|
||||
import type { Artwork } from '@jet-app/app-store/api/models';
|
||||
|
||||
const systemImagePrefix = 'systemimage://';
|
||||
const resourcePrefix = 'resource://';
|
||||
|
||||
type SystemImageTemplate = `${typeof systemImagePrefix}${string}`;
|
||||
type ResourceTemplate = `${typeof resourcePrefix}${string}`;
|
||||
|
||||
/**
|
||||
* An {@linkcode Artwork} that references a system image
|
||||
*/
|
||||
interface FullSystemImageArtwork extends Artwork {
|
||||
template: SystemImageTemplate | ResourceTemplate;
|
||||
}
|
||||
|
||||
/**
|
||||
* The sub-set of {@linkcode FullSystemImageArtwork} required to render
|
||||
* the icon
|
||||
*/
|
||||
type SystemImageArtwork = Pick<FullSystemImageArtwork, 'template'>;
|
||||
|
||||
/**
|
||||
* Determine if some {@linkcode Artwork} represents a "system image"
|
||||
*/
|
||||
export function isSystemImageArtwork(
|
||||
artwork: Artwork,
|
||||
): artwork is FullSystemImageArtwork {
|
||||
return (
|
||||
artwork.template.startsWith(systemImagePrefix) ||
|
||||
artwork.template.startsWith(resourcePrefix)
|
||||
);
|
||||
}
|
||||
|
||||
export function getIconNameFromTemplate(template: string) {
|
||||
return new URL(template).host;
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SFSymbol from '~/components/SFSymbol.svelte';
|
||||
|
||||
export let artwork: SystemImageArtwork;
|
||||
|
||||
$: name = getIconNameFromTemplate(artwork.template);
|
||||
</script>
|
||||
|
||||
<SFSymbol {name} />
|
||||
412
src/components/VideoPlayer.svelte
Normal file
412
src/components/VideoPlayer.svelte
Normal file
@@ -0,0 +1,412 @@
|
||||
<script lang="ts">
|
||||
import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
|
||||
import MotionArtwork from '~/components/MotionArtwork.svelte';
|
||||
import { getJet } from '~/jet';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import type { Video } from '@jet-app/app-store/api/models';
|
||||
import {
|
||||
MetricsActionDetails,
|
||||
MetricsActionType,
|
||||
type MetricsActionDetailItem,
|
||||
type MetricsActionTypeItem,
|
||||
} from '~/constants/media-metrics';
|
||||
|
||||
/** HTML `id` attribute for the <video /> element */
|
||||
export let id: string;
|
||||
|
||||
/** Source URL for the video, an HLS playlist ending in .m3u8 */
|
||||
export let src: string;
|
||||
|
||||
/** Poster image to show while the video is loading */
|
||||
export let poster: string | undefined;
|
||||
|
||||
/** If the video should play automatically when in view */
|
||||
export let autoplay: boolean = false;
|
||||
|
||||
/* The whole-number percentage amount of the video needs to be in view before autoplay kicks in */
|
||||
export let autoplayVisibilityThreshold: number = 0;
|
||||
|
||||
/** If the video should loop from end to start. */
|
||||
export let loop: boolean = false;
|
||||
|
||||
/** If the audio should be muted on the video. */
|
||||
export let muted: boolean = true;
|
||||
|
||||
/** If our controls should be shown in the video player. */
|
||||
export let useControls: boolean = true;
|
||||
|
||||
/** The constructor to use for creating an Hls playback session. */
|
||||
export let HLS: Window['Hls'] = window.Hls;
|
||||
|
||||
/**
|
||||
* If we should bypass the `poster` attribute on the `video` tag, in favor of having the poster
|
||||
* image overlaid as it's own DOM element, which covers an HLS playback bug in Safari, wherein
|
||||
* the video is seeked to the first frame once the metadata is loaded, thus removing the poster.
|
||||
*/
|
||||
export let shouldSuperimposePosterImage: boolean = false;
|
||||
|
||||
/** an optional metric template provided by jet */
|
||||
export let metricsTemplate:
|
||||
| Record<string, unknown>
|
||||
| Video['templateMediaEvent'] = {};
|
||||
|
||||
export function play(isAutoPlay = true) {
|
||||
videoRef?.play();
|
||||
recordMediaEvent(
|
||||
MetricsActionType.PLAY,
|
||||
isAutoPlay
|
||||
? MetricsActionDetails.AUTOPLAY
|
||||
: MetricsActionDetails.PLAY,
|
||||
);
|
||||
}
|
||||
|
||||
export function pause(isAutoPause = true) {
|
||||
recordMediaEvent(
|
||||
MetricsActionType.STOP,
|
||||
isAutoPause
|
||||
? MetricsActionDetails.AUTOPAUSE
|
||||
: MetricsActionDetails.PAUSE,
|
||||
);
|
||||
|
||||
videoRef?.pause();
|
||||
}
|
||||
|
||||
let isPaused: boolean = !autoplay;
|
||||
let isMuted: boolean = muted;
|
||||
let shouldShowReplayControl: boolean = false;
|
||||
let shouldShowPlaybackControls: boolean = true;
|
||||
let hasPlaybackBeenInitiated: boolean = false;
|
||||
let videoRef: HTMLVideoElement | null = null;
|
||||
|
||||
const i18n = getI18n();
|
||||
const jet = getJet();
|
||||
|
||||
const handleFullScreenButtonClick = () => {
|
||||
videoRef?.requestFullscreen();
|
||||
};
|
||||
|
||||
const handleReplayButtonClick = () => {
|
||||
if (videoRef) {
|
||||
videoRef.currentTime = 0;
|
||||
videoRef.play();
|
||||
shouldShowPlaybackControls = true;
|
||||
}
|
||||
};
|
||||
|
||||
const handlePlayButtonClick = () => {
|
||||
if (isPaused) {
|
||||
play(false);
|
||||
} else {
|
||||
pause(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMuteButtonClick = () => {
|
||||
isMuted = !isMuted;
|
||||
};
|
||||
|
||||
const handleVideoEnded = () => {
|
||||
if (!loop) {
|
||||
shouldShowPlaybackControls = true;
|
||||
|
||||
if (videoRef) {
|
||||
videoRef.currentTime = 1;
|
||||
videoRef.pause();
|
||||
}
|
||||
|
||||
recordMediaEvent(
|
||||
MetricsActionType.STOP,
|
||||
MetricsActionDetails.COMPLETE,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoPlay = () => {
|
||||
// Display the replay button after the first play
|
||||
shouldShowReplayControl = true;
|
||||
hasPlaybackBeenInitiated = true;
|
||||
};
|
||||
|
||||
// metric events that are waiting for loadMetadata from video element
|
||||
let queuedMetricEvents: Array<() => void> = [];
|
||||
|
||||
// flush any metric events once load metadata has been called
|
||||
const flushMetricEvents = () => {
|
||||
queuedMetricEvents.forEach((recordFn) => recordFn());
|
||||
|
||||
queuedMetricEvents = [];
|
||||
};
|
||||
|
||||
const recordMediaEvent = (
|
||||
actionType: MetricsActionTypeItem,
|
||||
actionDetail: MetricsActionDetailItem,
|
||||
) => {
|
||||
if (!metricsTemplate?.fields) {
|
||||
return;
|
||||
}
|
||||
|
||||
const recordEvent = () => {
|
||||
const duration = Math.floor(videoRef?.duration ?? 0) * 1000;
|
||||
const position = Math.min(
|
||||
Math.floor((videoRef?.currentTime ?? 0) * 1000),
|
||||
duration,
|
||||
);
|
||||
jet.recordCustomMetricsEvent({
|
||||
...(metricsTemplate?.fields ?? {}),
|
||||
actionType: actionType,
|
||||
actionDetails: actionDetail,
|
||||
url: src,
|
||||
duration,
|
||||
position,
|
||||
topic: metricsTemplate?.topic ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
if (Number.isNaN(videoRef?.duration)) {
|
||||
queuedMetricEvents.push(() => recordEvent());
|
||||
} else {
|
||||
recordEvent();
|
||||
}
|
||||
};
|
||||
|
||||
const isVideoPlaying = (video: HTMLVideoElement | null) => {
|
||||
if (!video) {
|
||||
return false;
|
||||
}
|
||||
return !!(
|
||||
video.currentTime > 0 &&
|
||||
!video.paused &&
|
||||
!video.ended &&
|
||||
video.readyState > 2
|
||||
);
|
||||
};
|
||||
|
||||
const intersectionObserverConfig = {
|
||||
threshold: autoplayVisibilityThreshold,
|
||||
callback: (isIntersectingViewport: boolean) => {
|
||||
if (isIntersectingViewport) {
|
||||
play();
|
||||
} else if (isVideoPlaying(videoRef)) {
|
||||
pause();
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="video-container"
|
||||
use:intersectionObserver={autoplay ? intersectionObserverConfig : undefined}
|
||||
>
|
||||
<div class="video">
|
||||
<MotionArtwork
|
||||
{id}
|
||||
{HLS}
|
||||
{src}
|
||||
{loop}
|
||||
poster={!shouldSuperimposePosterImage ? poster : undefined}
|
||||
bind:muted={isMuted}
|
||||
bind:paused={isPaused}
|
||||
bind:videoElement={videoRef}
|
||||
on:play={handleVideoPlay}
|
||||
on:ended={handleVideoEnded}
|
||||
on:loadedmetadata={flushMetricEvents}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if shouldSuperimposePosterImage && !hasPlaybackBeenInitiated}
|
||||
<img
|
||||
src={poster}
|
||||
class="fake-poster"
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
alt=""
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if useControls}
|
||||
<div class="video-control">
|
||||
{#if shouldShowReplayControl}
|
||||
<button
|
||||
class="video-control-replay"
|
||||
aria-label={$i18n.t(
|
||||
'ASE.Web.AppStore.VideoPlayer.AX.Replay',
|
||||
)}
|
||||
on:click={handleReplayButtonClick}
|
||||
>
|
||||
<img
|
||||
class="btn-img"
|
||||
src="/assets/images/video-control/video-control-replay.png"
|
||||
alt={$i18n.t('ASE.Web.AppStore.VideoPlayer.AX.Replay')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowPlaybackControls}
|
||||
<div class="video-control-playback">
|
||||
<button
|
||||
class="video-control-play"
|
||||
aria-label={$i18n.t(
|
||||
isPaused
|
||||
? 'ASE.Web.AppStore.VideoPlayer.AX.Play'
|
||||
: 'ASE.Web.AppStore.VideoPlayer.AX.Pause',
|
||||
)}
|
||||
on:click={handlePlayButtonClick}
|
||||
>
|
||||
{#if isPaused}
|
||||
<img
|
||||
class="btn-img"
|
||||
src="/assets/images/video-control/video-control-play.png"
|
||||
alt={$i18n.t(
|
||||
'ASE.Web.AppStore.VideoPlayer.AX.Play',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
class="btn-img"
|
||||
src="/assets/images/video-control/video-control-pause.png"
|
||||
alt={$i18n.t(
|
||||
'ASE.Web.AppStore.VideoPlayer.AX.Pause',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="video-control-unmute"
|
||||
aria-label={$i18n.t(
|
||||
isMuted
|
||||
? 'ASE.Web.AppStore.VideoPlayer.AX.Unmute'
|
||||
: 'ASE.Web.AppStore.VideoPlayer.AX.Mute',
|
||||
)}
|
||||
on:click={handleMuteButtonClick}
|
||||
>
|
||||
{#if isMuted}
|
||||
<img
|
||||
class="btn-img"
|
||||
src="/assets/images/video-control/video-control-volume-muted.png"
|
||||
alt={$i18n.t(
|
||||
'ASE.Web.AppStore.VideoPlayer.AX.Mute',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{:else}
|
||||
<img
|
||||
class="btn-img"
|
||||
src="/assets/images/video-control/video-control-volume.png"
|
||||
alt={$i18n.t(
|
||||
'ASE.Web.AppStore.VideoPlayer.AX.Unmute',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="video-control-fullscreen"
|
||||
aria-label={$i18n.t(
|
||||
'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
|
||||
)}
|
||||
on:click={handleFullScreenButtonClick}
|
||||
>
|
||||
<img
|
||||
class="btn-img"
|
||||
src="/assets/images/video-control/video-control-fullscreen.png"
|
||||
alt={$i18n.t(
|
||||
'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.video-container {
|
||||
--button-size: 32px;
|
||||
display: grid;
|
||||
position: relative;
|
||||
container-type: inline-size;
|
||||
container-name: video-container;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--systemQuaternary);
|
||||
}
|
||||
|
||||
.video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.video-control {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: inline-flex;
|
||||
justify-content: space-between;
|
||||
z-index: 1;
|
||||
align-self: end;
|
||||
color: white;
|
||||
margin: 0 12px 12px;
|
||||
}
|
||||
|
||||
.video-control::after {
|
||||
position: absolute;
|
||||
content: '';
|
||||
z-index: -1;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
height: calc(var(--button-size) * 2);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgb(0, 0, 0, 0.68),
|
||||
rgb(0, 0, 0, 0.2),
|
||||
transparent
|
||||
);
|
||||
mask-image: linear-gradient(360deg, #000 47%, transparent);
|
||||
}
|
||||
|
||||
.video-control-playback {
|
||||
display: inline-flex;
|
||||
margin-inline-start: auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.btn-img {
|
||||
height: var(--button-size);
|
||||
width: var(--button-size);
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--systemQuaternary-onDark);
|
||||
background: rgba(0, 0, 0, 0.11);
|
||||
backdrop-filter: blur(20px);
|
||||
object-fit: cover;
|
||||
transition: background 105ms ease-out;
|
||||
}
|
||||
|
||||
.btn-img:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
@container video-container (max-width: 500px) {
|
||||
.btn-img {
|
||||
--button-size: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.fake-poster {
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
67
src/components/decorators/HlsJSDecorator.svelte
Normal file
67
src/components/decorators/HlsJSDecorator.svelte
Normal file
@@ -0,0 +1,67 @@
|
||||
<script lang="ts" context="module">
|
||||
// This store is used to keep track of in-flight requests, ensuring that we don't attempt
|
||||
// to load the same src (which is stored in the Map key) multiple times.
|
||||
const inFlightRequests = new Map<string, Promise<void>>();
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { generateHLSJSURL } from '~/config/hlsjs';
|
||||
import { generateRTCJSURL } from '~/config/rtcjs';
|
||||
|
||||
export let version: string | undefined = undefined;
|
||||
|
||||
let hlsjsSourceURL = generateHLSJSURL(version).toString();
|
||||
let rtcjsSourceURL = generateRTCJSURL(version).toString();
|
||||
|
||||
function loadScript(src: string): Promise<void> {
|
||||
// If we have an in-flight request for this `src`, return it.
|
||||
const inFlightRequest = inFlightRequests.get(src);
|
||||
if (inFlightRequest) {
|
||||
return inFlightRequest;
|
||||
}
|
||||
|
||||
const promise = new Promise<void>(function (resolve, reject) {
|
||||
const scriptElement = document.createElement('script');
|
||||
scriptElement.src = src;
|
||||
scriptElement.onload = () => resolve();
|
||||
scriptElement.onerror = () => {
|
||||
// If a script fails to load due to a network blip, we remove it from the store,
|
||||
// so that the next attempt in an `onMount` will try to load the `src` again.
|
||||
inFlightRequests.delete(src);
|
||||
reject();
|
||||
};
|
||||
|
||||
document.head.appendChild(scriptElement);
|
||||
});
|
||||
|
||||
// Add the given `src` to the store so we can keep track of in-flight requests.
|
||||
inFlightRequests.set(src, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
let loading: Promise<[void, void]> | undefined;
|
||||
|
||||
onMount(() => {
|
||||
loading = Promise.all([
|
||||
window.Hls ?? loadScript(hlsjsSourceURL),
|
||||
window.rtc ?? loadScript(rtcjsSourceURL),
|
||||
]);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
{#await loading}
|
||||
<slot name="loading-component" />
|
||||
{:then}
|
||||
<slot HLS={window.Hls} RTC={window.rtc} />
|
||||
{:catch}
|
||||
<div>
|
||||
<p>
|
||||
Failed to load HLS.js {version} from
|
||||
<a href={hlsjsSourceURL}>{hlsjsSourceURL}</a>
|
||||
</p>
|
||||
</div>
|
||||
{/await}
|
||||
{/if}
|
||||
109
src/components/hero/AppLockupDetail.svelte
Normal file
109
src/components/hero/AppLockupDetail.svelte
Normal file
@@ -0,0 +1,109 @@
|
||||
<!--
|
||||
@component
|
||||
Component for rendering App information into the `details` slot
|
||||
of the `Hero.svelte` component
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Lockup } from '@jet-app/app-store/api/models';
|
||||
|
||||
import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte';
|
||||
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
|
||||
const i18n = getI18n();
|
||||
|
||||
export let lockup: Lockup;
|
||||
export let isOnDarkBackground: boolean = true;
|
||||
</script>
|
||||
|
||||
<div class="lockup-container">
|
||||
{#if lockup.icon}
|
||||
<div class="app-icon-container">
|
||||
<AppIcon icon={lockup.icon} profile="app-icon-small" />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="text-container">
|
||||
{#if lockup.heading}
|
||||
<LineClamp clamp={1}>
|
||||
<h4>{lockup.heading}</h4>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if lockup.title}
|
||||
<LineClamp clamp={2}>
|
||||
<h3>{lockup.title}</h3>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
|
||||
{#if lockup.subtitle}
|
||||
<LineClamp clamp={1}>
|
||||
<p>{lockup.subtitle}</p>
|
||||
</LineClamp>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="button-container">
|
||||
<span
|
||||
class="get-button"
|
||||
class:transparent={isOnDarkBackground}
|
||||
class:dark-gray={!isOnDarkBackground}
|
||||
>
|
||||
{$i18n.t('ASE.Web.AppStore.View')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style lang="scss">
|
||||
.lockup-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
max-width: 350px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
color: var(--hero-primary-color, var(--systemPrimary-onDark));
|
||||
border-top: 1px solid
|
||||
var(--hero-divider-color, var(--systemQuaternary-onDark));
|
||||
|
||||
@media (--range-xsmall-down) {
|
||||
text-align: left;
|
||||
padding: 20px 0 10px;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
flex-shrink: 0;
|
||||
width: 64px;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
.text-container {
|
||||
width: 100%;
|
||||
margin-inline-end: 16px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font: var(--title-3-emphasized);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
h4 {
|
||||
color: var(--hero-secondary-color, var(--systemSecondary-onDark));
|
||||
font: var(--subhead-emphasized);
|
||||
text-transform: uppercase;
|
||||
mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
|
||||
}
|
||||
|
||||
p {
|
||||
mix-blend-mode: var(--hero-text-blend-mode, plus-lighter);
|
||||
}
|
||||
|
||||
.button-container {
|
||||
--get-button-font: var(--title-3-bold);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
</style>
|
||||
132
src/components/hero/Carousel.svelte
Normal file
132
src/components/hero/Carousel.svelte
Normal file
@@ -0,0 +1,132 @@
|
||||
<!--
|
||||
@component
|
||||
Component for rendering a carousel of `Hero.svelte` components in a way taht's decoupled from
|
||||
any particular data model
|
||||
-->
|
||||
<script lang="ts" generics="Item">
|
||||
import type { Opt } from '@jet/environment/types/optional';
|
||||
import type { Artwork, Shelf } from '@jet-app/app-store/api/models';
|
||||
|
||||
import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte';
|
||||
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
|
||||
import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden';
|
||||
import HeroCarouselBackgroundPortal, {
|
||||
id as portalId,
|
||||
} from '~/components/hero/CarouselBackgroundPortal.svelte';
|
||||
import AmbientBackgroundArtwork from '~/components/AmbientBackgroundArtwork.svelte';
|
||||
import portal from '~/utils/portal';
|
||||
import { carouselMediaStyle } from '~/stores/carousel-media-style';
|
||||
|
||||
interface $$Slots {
|
||||
default: {
|
||||
/**
|
||||
* The `Item` to render as a `Hero` in the carousel
|
||||
*/
|
||||
item: Item;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The shelf being rendered
|
||||
*
|
||||
* Used to derrive any shelf-specific presentation
|
||||
*/
|
||||
export let shelf: Shelf;
|
||||
|
||||
/**
|
||||
* The items to render in the hero carousel
|
||||
*
|
||||
* This is decoupled from `shelf` to avoid assuming that `shelf.items` is, itself,
|
||||
* the set of items that we need to present; some shelves model their items as chilren
|
||||
* of the first shelf item.
|
||||
*/
|
||||
export let items: Item[];
|
||||
|
||||
/**
|
||||
* Callback that determines the "background artwork" to use behind the
|
||||
* active `Hero` for the given `Item`
|
||||
*/
|
||||
export let deriveBackgroundArtworkFromItem: (item: Item) => Opt<Artwork>;
|
||||
|
||||
$: gridRows = shelf.rowsPerColumn ?? undefined;
|
||||
$: isXSmallViewport = $mediaQueries === 'xsmall';
|
||||
|
||||
let activeIndex: number | undefined = 0;
|
||||
|
||||
function createIntersectionObserverCallback(index: number) {
|
||||
return (isIntersectingViewport: boolean) => {
|
||||
if (isIntersectingViewport) {
|
||||
// Many different types of `item`s can be rendered in this Carousel, and all those
|
||||
// different items have different ways of determining whether or not the background
|
||||
// is dark or light, so we are running through all the options here.
|
||||
const { style, mediaOverlayStyle, isMediaDark } = items[
|
||||
index
|
||||
] as any;
|
||||
const fallbackStyle = 'dark';
|
||||
let derivedStyle;
|
||||
|
||||
if (typeof isMediaDark !== 'undefined') {
|
||||
derivedStyle = isMediaDark ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
carouselMediaStyle.set(
|
||||
style || mediaOverlayStyle || derivedStyle || fallbackStyle,
|
||||
);
|
||||
|
||||
activeIndex = index;
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<HeroCarouselBackgroundPortal />
|
||||
|
||||
<ShelfWrapper {shelf} --shelfGridGutterWidth="0">
|
||||
<HorizontalShelf
|
||||
{gridRows}
|
||||
{items}
|
||||
--shelfScrollPaddingInline="0"
|
||||
--grid-max-content-xsmall={!$sidebarIsHidden
|
||||
? 'calc(100% + 50px)'
|
||||
: '100vw'}
|
||||
gridType="Spotlight"
|
||||
let:item
|
||||
let:index
|
||||
>
|
||||
{#if isXSmallViewport}
|
||||
<div
|
||||
use:intersectionObserver={{
|
||||
callback: createIntersectionObserverCallback(index),
|
||||
threshold: 0.5,
|
||||
}}
|
||||
>
|
||||
<slot {item} />
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
use:intersectionObserver={{
|
||||
callback: createIntersectionObserverCallback(index),
|
||||
threshold: 0,
|
||||
}}
|
||||
>
|
||||
{#if !import.meta.env.SSR}
|
||||
{@const backgroundArtwork =
|
||||
deriveBackgroundArtworkFromItem(item)}
|
||||
|
||||
{#if backgroundArtwork}
|
||||
<div use:portal={portalId}>
|
||||
<AmbientBackgroundArtwork
|
||||
artwork={backgroundArtwork}
|
||||
active={activeIndex === index}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<slot {item} />
|
||||
</div>
|
||||
{/if}
|
||||
</HorizontalShelf>
|
||||
</ShelfWrapper>
|
||||
17
src/components/hero/CarouselBackgroundPortal.svelte
Normal file
17
src/components/hero/CarouselBackgroundPortal.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts" context="module">
|
||||
export const id = 'hero-carousel-shelf-background-portal';
|
||||
</script>
|
||||
|
||||
<div {id} />
|
||||
|
||||
<style>
|
||||
#hero-carousel-shelf-background-portal {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-x: hidden;
|
||||
z-index: -1;
|
||||
}
|
||||
</style>
|
||||
536
src/components/hero/Hero.svelte
Normal file
536
src/components/hero/Hero.svelte
Normal file
@@ -0,0 +1,536 @@
|
||||
<!--
|
||||
@component
|
||||
Component for rendering an item in a "Hero Carousel" without coupling to any specific data model
|
||||
-->
|
||||
<script lang="ts">
|
||||
import type { Opt } from '@jet/environment/types/optional';
|
||||
import type {
|
||||
Action,
|
||||
Artwork as ArtworkModel,
|
||||
Color,
|
||||
Video as VideoModel,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion';
|
||||
import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html';
|
||||
|
||||
import AppIcon from '~/components/AppIcon.svelte';
|
||||
import Artwork from '~/components/Artwork.svelte';
|
||||
import LinkWrapper from '~/components/LinkWrapper.svelte';
|
||||
import Video from '~/components/jet/Video.svelte';
|
||||
import type { NamedProfile } from '~/config/components/artwork';
|
||||
import {
|
||||
colorAsString,
|
||||
getBackgroundGradientCSSVarsFromArtworks,
|
||||
getLuminanceForRGB,
|
||||
} from '~/utils/color';
|
||||
import { isRtl } from '~/utils/locale';
|
||||
|
||||
/**
|
||||
* The main text for the carousel item
|
||||
*/
|
||||
export let title: Opt<string> = undefined;
|
||||
|
||||
/**
|
||||
* Additional text above the title.
|
||||
* Note: If a slot is defined with the name `eyebrow`, the slot takes precedence.
|
||||
*/
|
||||
export let eyebrow: Opt<string> = undefined;
|
||||
|
||||
/**
|
||||
* Additional text below the title
|
||||
*/
|
||||
export let subtitle: Opt<string> = undefined;
|
||||
|
||||
/**
|
||||
* Primary accent color for the carousel item
|
||||
*/
|
||||
export let backgroundColor: Opt<Color> = undefined;
|
||||
|
||||
/**
|
||||
* Static artwork to display in the carousel item
|
||||
*/
|
||||
export let artwork: Opt<ArtworkModel> = undefined;
|
||||
|
||||
/**
|
||||
* Video to display in the carousel item
|
||||
*
|
||||
* Takes precedence over `artwork`
|
||||
*/
|
||||
export let video: Opt<VideoModel> = undefined;
|
||||
|
||||
/**
|
||||
* Action to perform when clicking on the carousel item
|
||||
*/
|
||||
export let action: Opt<Action> = undefined;
|
||||
|
||||
/**
|
||||
* Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container
|
||||
*/
|
||||
export let pinArtworkToHorizontalEnd: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default)
|
||||
*/
|
||||
export let pinArtworkToVerticalMiddle: boolean = false;
|
||||
|
||||
/**
|
||||
* Whether the text (e.g. title, description, etc) should be pinned to the top of the container
|
||||
*/
|
||||
export let pinTextToVerticalStart: boolean = false;
|
||||
|
||||
/**
|
||||
* Allows for the absolute overriding of the profile used for the Hero artwork
|
||||
*/
|
||||
export let profileOverride: Opt<NamedProfile> = null;
|
||||
|
||||
export let isMediaDark: boolean = true;
|
||||
|
||||
export let collectionIcons: ArtworkModel[] | undefined = undefined;
|
||||
|
||||
let isPortraitLayout: boolean;
|
||||
let profile: NamedProfile;
|
||||
let collectionIconsBackgroundGradientCssVars: string | undefined =
|
||||
undefined;
|
||||
|
||||
$: isPortraitLayout = $mediaQueries === 'xsmall';
|
||||
|
||||
$: {
|
||||
if (profileOverride) {
|
||||
profile = profileOverride;
|
||||
} else if (isPortraitLayout) {
|
||||
profile = 'large-hero-portrait';
|
||||
} else if (pinArtworkToHorizontalEnd && isRtl()) {
|
||||
profile = 'large-hero-east';
|
||||
} else if (pinArtworkToHorizontalEnd) {
|
||||
profile = 'large-hero-west';
|
||||
} else {
|
||||
profile = 'large-hero';
|
||||
}
|
||||
}
|
||||
|
||||
const color: string = backgroundColor
|
||||
? colorAsString(backgroundColor)
|
||||
: '#000';
|
||||
|
||||
if (collectionIcons && collectionIcons.length > 1) {
|
||||
// If there are multiple app icons, we build a string of CSS variables from the icons
|
||||
// background colors to fill as many of the lockups quadrants as possible.
|
||||
collectionIconsBackgroundGradientCssVars =
|
||||
getBackgroundGradientCSSVarsFromArtworks(collectionIcons, {
|
||||
// sorts from darkest to lightest
|
||||
sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b),
|
||||
shouldRemoveGreys: true,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<LinkWrapper {action} includeExternalLinkArrowIcon={false}>
|
||||
<article
|
||||
data-test-id="hero"
|
||||
class:with-dark-media={isMediaDark}
|
||||
class:with-collection-icons={!artwork && !video && collectionIcons}
|
||||
class:text-pinned-to-vertical-start={pinTextToVerticalStart}
|
||||
>
|
||||
{#if video || artwork}
|
||||
<div
|
||||
class={`image-container ${profile}`}
|
||||
class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd}
|
||||
class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle}
|
||||
style:--color={color}
|
||||
>
|
||||
{#if video && !$prefersReducedMotion}
|
||||
<Video
|
||||
loop
|
||||
autoplay
|
||||
useControls={false}
|
||||
{video}
|
||||
{profile}
|
||||
/>
|
||||
{:else if artwork}
|
||||
<Artwork
|
||||
{artwork}
|
||||
{profile}
|
||||
noShelfChevronAnchor={true}
|
||||
useCropCodeFromArtwork={false}
|
||||
withoutBorder={true}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if collectionIcons}
|
||||
<ul class="app-icons">
|
||||
{#each collectionIcons?.slice(0, 5) as collectionIcon}
|
||||
<li class="app-icon-container">
|
||||
<AppIcon
|
||||
icon={collectionIcon}
|
||||
profile="app-icon-large"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div
|
||||
class="collection-icons-background-gradient"
|
||||
style={collectionIconsBackgroundGradientCssVars}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<div class="gradient" style="--color: {color};" />
|
||||
|
||||
<slot name="badge" {isPortraitLayout} />
|
||||
|
||||
<div class="metadata-container">
|
||||
{#if $$slots.eyebrow}
|
||||
<h3><slot name="eyebrow" /></h3>
|
||||
{:else if eyebrow}
|
||||
<h3>{eyebrow}</h3>
|
||||
{/if}
|
||||
|
||||
{#if title}
|
||||
<h2>{@html sanitizeHtml(title)}</h2>
|
||||
{/if}
|
||||
|
||||
{#if subtitle}
|
||||
<p class="subtitle">{@html sanitizeHtml(subtitle)}</p>
|
||||
{/if}
|
||||
|
||||
<slot name="details" {isPortraitLayout} />
|
||||
</div>
|
||||
</article>
|
||||
</LinkWrapper>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/app/core/globalvars' as *;
|
||||
|
||||
article {
|
||||
--hero-primary-color: var(--systemPrimary-onLight);
|
||||
--hero-secondary-color: var(--systemSecondary-onLight);
|
||||
--hero-text-blend-mode: normal;
|
||||
--hero-divider-color: var(--systemQuaternary-onLight);
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: end;
|
||||
aspect-ratio: 3 / 4;
|
||||
container-name: hero-container;
|
||||
container-type: size;
|
||||
|
||||
@media (--range-small-up) {
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-height: 360px;
|
||||
max-height: min(60vh, 770px);
|
||||
border-radius: var(--global-border-radius-large);
|
||||
border: 1px solid var(--systemQuaternary);
|
||||
}
|
||||
}
|
||||
|
||||
article.with-dark-media,
|
||||
article.with-collection-icons {
|
||||
--hero-primary-color: var(--systemPrimary-onDark);
|
||||
--hero-secondary-color: var(--systemSecondary-onDark);
|
||||
--hero-divider-color: var(--systemQuaternary-onDark);
|
||||
--hero-text-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.image-container {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color);
|
||||
}
|
||||
|
||||
.image-container.pinned-to-vertical-middle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.image-container.pinned-to-vertical-middle :global(.video-container),
|
||||
.image-container.pinned-to-vertical-middle :global(.artwork-component) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.image-container.pinned-to-horizontal-end :global(.artwork-component) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.image-container.pinned-to-horizontal-end :global(.artwork-component img) {
|
||||
height: 100%;
|
||||
width: auto;
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
|
||||
@container hero-container (aspect-ratio >= 279/100) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.image-container.pinned-to-horizontal-end.large-hero-story-card-rtl
|
||||
:global(.artwork-component img) {
|
||||
inset-inline-start: 0;
|
||||
}
|
||||
|
||||
// This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of
|
||||
// 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the
|
||||
// artwork full-width rather than full-height. This should eventually be fixed when Editorial
|
||||
// can prescribe us only 16x9 (1.77:1) hero images.
|
||||
.image-container.pinned-to-horizontal-end.large-hero-story-card,
|
||||
.image-container.pinned-to-horizontal-end.large-hero-story-card-rtl {
|
||||
@container hero-container (aspect-ratio >= 225/100) {
|
||||
:global(.artwork-component img) {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metadata-container {
|
||||
position: absolute;
|
||||
width: 40%;
|
||||
padding-bottom: 40px;
|
||||
padding-inline-start: 40px;
|
||||
text-wrap: pretty;
|
||||
color: var(--hero-primary-color);
|
||||
|
||||
@media (--range-small-only) {
|
||||
width: 50%;
|
||||
padding: 0 20px 20px;
|
||||
}
|
||||
|
||||
@media (--range-xsmall-down) {
|
||||
width: 100%;
|
||||
padding: 0 20px 20px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.text-pinned-to-vertical-start .metadata-container {
|
||||
@media (--range-small-only) {
|
||||
top: 20px;
|
||||
}
|
||||
|
||||
@media (--range-medium-up) {
|
||||
top: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
text-wrap: balance;
|
||||
font: var(--header-emphasized);
|
||||
|
||||
@media (--range-xsmall-down) {
|
||||
font: var(--title-1-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
@container hero-container (height < 420px) {
|
||||
h2 {
|
||||
font: var(--large-title-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
color: var(--hero-secondary-color);
|
||||
font: var(--callout-emphasized-tall);
|
||||
mix-blend-mode: var(--hero-text-blend-mode);
|
||||
|
||||
@media (--range-xsmall-down) {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
p {
|
||||
mix-blend-mode: var(--hero-text-blend-mode);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin-top: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font: var(--body-tall);
|
||||
color: var(--hero-secondary-color);
|
||||
}
|
||||
|
||||
.gradient {
|
||||
--rotation: 55deg;
|
||||
|
||||
&:dir(rtl) {
|
||||
--rotation: -55deg;
|
||||
mask-image: radial-gradient(
|
||||
ellipse 127% 130% at 95% 100%,
|
||||
rgb(0, 0, 0) 18%,
|
||||
rgb(0, 0, 0.33) 24%,
|
||||
rgba(0, 0, 0, 0.66) 32%,
|
||||
transparent 40%
|
||||
),
|
||||
linear-gradient(
|
||||
-129deg,
|
||||
rgb(0, 0, 0) 0%,
|
||||
rgba(255, 255, 255, 0) 55%
|
||||
);
|
||||
}
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
// stylelint-disable color-function-notation
|
||||
background: linear-gradient(
|
||||
var(--rotation),
|
||||
rgb(from var(--color) r g b / 0.25) 0%,
|
||||
transparent 50%
|
||||
);
|
||||
// stylelint-enable color-function-notation
|
||||
filter: saturate(1.5) brightness(0.9);
|
||||
backdrop-filter: blur(40px);
|
||||
mask-image: radial-gradient(
|
||||
ellipse 127% 130% at 5% 100%,
|
||||
rgb(0, 0, 0) 18%,
|
||||
rgb(0, 0, 0.33) 24%,
|
||||
rgba(0, 0, 0, 0.66) 32%,
|
||||
transparent 40%
|
||||
),
|
||||
linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%);
|
||||
|
||||
@media (--range-xsmall-down) {
|
||||
--rotation: 0deg;
|
||||
mask-image: linear-gradient(
|
||||
var(--rotation),
|
||||
rgb(0, 0, 0) 28%,
|
||||
rgba(0, 0, 0, 0) 56%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// When the text is pinned to the top of the lockup, we use a different gradient for legibility
|
||||
article.text-pinned-to-vertical-start .gradient {
|
||||
--rotation: -170deg;
|
||||
mask-image: radial-gradient(
|
||||
ellipse 118% 121% at 100% 0%,
|
||||
rgb(0, 0, 0) 18%,
|
||||
rgb(0, 0, 0.33) 22%,
|
||||
rgba(0, 0, 0, 0.66) 33%,
|
||||
transparent 43%
|
||||
);
|
||||
}
|
||||
|
||||
.app-icons {
|
||||
display: grid;
|
||||
align-self: center;
|
||||
width: 90%;
|
||||
grid-template-rows: auto auto;
|
||||
grid-auto-flow: column;
|
||||
gap: 24px;
|
||||
margin-inline-start: -4%;
|
||||
position: absolute;
|
||||
inset-inline-end: 24px;
|
||||
|
||||
@media (--range-small-up) {
|
||||
width: 44%;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icons li:nth-child(even) {
|
||||
inset-inline-start: 44%;
|
||||
}
|
||||
|
||||
.app-icon-container {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
@property --top-left-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 20%;
|
||||
}
|
||||
|
||||
@property --bottom-left-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 40%;
|
||||
}
|
||||
|
||||
@property --top-right-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 55%;
|
||||
}
|
||||
|
||||
@property --bottom-right-stop {
|
||||
syntax: '<percentage>';
|
||||
inherits: false;
|
||||
initial-value: 50%;
|
||||
}
|
||||
|
||||
.collection-icons-background-gradient {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
background: radial-gradient(
|
||||
circle at 3% -50%,
|
||||
var(--top-left, #000) var(--top-left-stop),
|
||||
transparent 70%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at -50% 120%,
|
||||
var(--bottom-left, #000) var(--bottom-left-stop),
|
||||
transparent 80%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 66% -175%,
|
||||
var(--top-right, #000) var(--top-right-stop),
|
||||
transparent 80%
|
||||
),
|
||||
radial-gradient(
|
||||
circle at 62% 100%,
|
||||
var(--bottom-right, #000) var(--bottom-right-stop),
|
||||
transparent 100%
|
||||
);
|
||||
animation: collection-icons-background-gradient-shift 16s infinite
|
||||
alternate-reverse;
|
||||
animation-play-state: paused;
|
||||
|
||||
@media (--range-small-up) {
|
||||
animation-play-state: running;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes collection-icons-background-gradient-shift {
|
||||
0% {
|
||||
--top-left-stop: 20%;
|
||||
--bottom-left-stop: 40%;
|
||||
--top-right-stop: 55%;
|
||||
--bottom-right-stop: 50%;
|
||||
background-size: 100% 100%;
|
||||
}
|
||||
|
||||
50% {
|
||||
--top-left-stop: 25%;
|
||||
--bottom-left-stop: 15%;
|
||||
--top-right-stop: 70%;
|
||||
--bottom-right-stop: 30%;
|
||||
background-size: 130% 130%;
|
||||
}
|
||||
|
||||
100% {
|
||||
--top-left-stop: 15%;
|
||||
--bottom-left-stop: 20%;
|
||||
--top-right-stop: 55%;
|
||||
--bottom-right-stop: 20%;
|
||||
background-size: 110% 110%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
1
src/components/icons/AppStoreLogo.svg
Normal file
1
src/components/icons/AppStoreLogo.svg
Normal file
@@ -0,0 +1 @@
|
||||
export default "__VITE_ASSET__PaJpmjhr__"
|
||||
1
src/components/icons/AppleArcadeLogo.svg
Normal file
1
src/components/icons/AppleArcadeLogo.svg
Normal file
@@ -0,0 +1 @@
|
||||
export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20384%2080'%20preserveAspectRatio='xMinYMin%20meet'%20%3e%3cpath%20fill='currentColor'%20d='M43.873%2012.699C46.606%209.28%2048.461%204.69%2047.972%200c-4.001.199-8.883%202.64-11.71%206.06-2.538%202.93-4.784%207.712-4.198%2012.206%204.49.39%208.978-2.245%2011.81-5.567M47.92%2019.144c-6.521-.389-12.067%203.701-15.182%203.701-3.116%200-7.885-3.506-13.044-3.411-6.714.098-12.945%203.895-16.352%209.933-7.008%2012.079-1.85%2029.996%204.966%2039.833%203.31%204.867%207.298%2010.226%2012.553%2010.034%204.966-.195%206.912-3.216%2012.948-3.216%206.032%200%207.785%203.216%2013.041%203.118%205.451-.097%208.859-4.869%2012.168-9.74%203.797-5.549%205.351-10.906%205.449-11.2-.098-.097-10.511-4.092-10.608-16.07-.098-10.03%208.176-14.801%208.565-15.097-4.672-6.91-11.972-7.69-14.503-7.885'%20/%3e%3cpath%20fill='currentColor'%20d='M115.598%2058.881H87.752L81.07%2078.627H69.273L95.651%205.569h12.252l26.377%2073.058h-12l-6.682-19.746zm-24.96-9.113h22.074L101.827%2017.72h-.304L90.638%2049.768zM140.503%2025.365h10.43v9.062h.253c1.773-6.226%206.531-9.923%2012.81-9.923%201.569%200%202.936.253%203.746.406v10.175c-.86-.354-2.784-.607-4.911-.607-7.038%200-11.391%204.71-11.391%2012.252v31.897h-10.937V25.365zM207.744%2043.693c-1.114-5.671-5.367-10.177-12.505-10.177-8.455%200-14.025%207.037-14.025%2018.48%200%2011.695%205.62%2018.48%2014.126%2018.48%206.734%200%2011.138-3.696%2012.404-9.873h10.53c-1.164%2011.34-10.227%2019.036-23.035%2019.036-15.24%200-25.162-10.43-25.162-27.643%200-16.91%209.923-27.593%2025.06-27.593%2013.72%200%2022.074%208.81%2023.036%2019.29h-10.43zM223.9%2063.489c0-9.317%207.14-15.037%2019.797-15.746l14.58-.86v-4.101c0-5.924-4-9.468-10.682-9.468-6.329%200-10.278%203.037-11.24%207.797h-10.328c.607-9.62%208.81-16.708%2021.973-16.708%2012.91%200%2021.163%206.835%2021.163%2017.517v36.707h-10.48v-8.76h-.254c-3.088%205.925-9.821%209.67-16.808%209.67-10.43%200-17.72-6.48-17.72-16.048zm34.378-4.81v-4.202l-13.113.81c-6.532.456-10.227%203.341-10.227%207.898%200%204.657%203.848%207.695%209.72%207.695%207.645%200%2013.62-5.265%2013.62-12.2zM276.853%2051.996c0-16.809%208.91-27.492%2022.276-27.492%207.645%200%2013.721%203.848%2016.707%209.721h.204V5.57h10.986v73.058h-10.632v-9.063h-.203c-3.139%206.075-9.214%209.974-16.96%209.974-13.468%200-22.378-10.734-22.378-27.542zm11.189%200c0%2011.239%205.417%2018.277%2014.075%2018.277%208.404%200%2014.023-7.139%2014.023-18.277%200-11.037-5.619-18.277-14.023-18.277-8.658%200-14.075%207.088-14.075%2018.277zM382.956%2062.982c-1.519%209.72-10.734%2016.657-22.935%2016.657-15.644%200-25.111-10.58-25.111-27.39%200-16.707%209.619-27.846%2024.656-27.846%2014.783%200%2023.997%2010.43%2023.997%2026.58v3.747h-37.616v.658c0%209.265%205.568%2015.39%2014.327%2015.39%206.228%200%2010.835-3.138%2012.303-7.796h10.379zm-36.96-15.897h26.631c-.252-8.15-5.417-13.873-13.061-13.873-7.646%200-13.012%205.823-13.57%2013.873z'%20/%3e%3c/svg%3e"
|
||||
66
src/components/jet/Video.svelte
Normal file
66
src/components/jet/Video.svelte
Normal file
@@ -0,0 +1,66 @@
|
||||
<script lang="ts">
|
||||
import type { Video } from '@jet-app/app-store/api/models';
|
||||
import VideoPlayer from '../VideoPlayer.svelte';
|
||||
import HlsJsDecorator from '../decorators/HlsJSDecorator.svelte';
|
||||
import { buildPoster } from '~/utils/video-poster';
|
||||
import { generateUuid } from '@amp/web-apps-utils/src';
|
||||
import type { NamedProfile } from 'src/config/components/artwork';
|
||||
import type { Profile } from '@amp/web-app-components/src/components/Artwork/types';
|
||||
import mediaQueries from '~/utils/media-queries';
|
||||
import { colorAsString } from '~/utils/color';
|
||||
|
||||
export let video: Video;
|
||||
export let autoplay: boolean = false;
|
||||
export let loop: boolean = false;
|
||||
export let muted: boolean = true;
|
||||
export let useControls: boolean = true;
|
||||
export let profile: NamedProfile | Profile;
|
||||
export let autoplayVisibilityThreshold: number = 0;
|
||||
export let videoPlayerRef: InstanceType<typeof VideoPlayer> | null = null;
|
||||
export let shouldSuperimposePosterImage: boolean = false;
|
||||
|
||||
$: poster =
|
||||
video.preview && buildPoster(video.preview, profile, $mediaQueries);
|
||||
$: backgroundColor = video.preview.backgroundColor
|
||||
? colorAsString(video.preview.backgroundColor)
|
||||
: '#f1f1f1';
|
||||
|
||||
$: metricsTemplate = video?.templateMediaEvent ?? {};
|
||||
const uuid = generateUuid();
|
||||
</script>
|
||||
|
||||
<HlsJsDecorator let:HLS>
|
||||
<VideoPlayer
|
||||
{HLS}
|
||||
{loop}
|
||||
{muted}
|
||||
{autoplay}
|
||||
{useControls}
|
||||
{autoplayVisibilityThreshold}
|
||||
{metricsTemplate}
|
||||
{shouldSuperimposePosterImage}
|
||||
id={uuid}
|
||||
src={video.videoUrl}
|
||||
poster={poster ?? undefined}
|
||||
--aspect-ratio={video.preview.width / video.preview.height}
|
||||
bind:this={videoPlayerRef}
|
||||
/>
|
||||
|
||||
<div
|
||||
class="loader"
|
||||
slot="loading-component"
|
||||
style:--aspect-ratio={video.preview.width / video.preview.height}
|
||||
style:--background-image={`url(${poster})`}
|
||||
style:--background-color={backgroundColor}
|
||||
/>
|
||||
</HlsJsDecorator>
|
||||
|
||||
<style>
|
||||
.loader {
|
||||
aspect-ratio: var(--aspect-ratio);
|
||||
width: 100%;
|
||||
background-image: var(--background-image);
|
||||
background-color: var(--background-color);
|
||||
background-size: cover;
|
||||
}
|
||||
</style>
|
||||
52
src/components/jet/action/ExternalUrlAction.svelte
Normal file
52
src/components/jet/action/ExternalUrlAction.svelte
Normal file
@@ -0,0 +1,52 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
import type { ExternalUrlAction } from '@jet-app/app-store/api/models';
|
||||
import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg';
|
||||
import { getJetPerform } from '~/jet';
|
||||
|
||||
type AllowedAnchorAttributes = Omit<
|
||||
HTMLAnchorAttributes,
|
||||
// The `href` attribute is not allowed because it will be provided
|
||||
// by the `ExternalUrlAction`
|
||||
'href'
|
||||
>;
|
||||
|
||||
interface $$Props extends AllowedAnchorAttributes {
|
||||
destination: ExternalUrlAction;
|
||||
includeArrowIcon?: boolean;
|
||||
}
|
||||
|
||||
const perform = getJetPerform();
|
||||
|
||||
export let destination: ExternalUrlAction;
|
||||
export let includeArrowIcon: boolean = true;
|
||||
|
||||
function handleClickAction() {
|
||||
perform(destination);
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
{...$$restProps}
|
||||
data-test-id="external-link"
|
||||
href={destination.url}
|
||||
target="_blank"
|
||||
rel="nofollow noopener noreferrer"
|
||||
on:click={handleClickAction}
|
||||
>
|
||||
<slot />
|
||||
{#if includeArrowIcon}
|
||||
<ArrowIcon class="external-link-arrow" aria-hidden="true" />
|
||||
{/if}
|
||||
</a>
|
||||
|
||||
<style lang="scss">
|
||||
@use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config';
|
||||
@use 'ac-sasskit/core/locale' as *;
|
||||
|
||||
a :global(.external-link-arrow) {
|
||||
@include rtl {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
41
src/components/jet/action/FlowAction.svelte
Normal file
41
src/components/jet/action/FlowAction.svelte
Normal file
@@ -0,0 +1,41 @@
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
import type { FlowAction } from '@jet-app/app-store/api/models';
|
||||
import { getJetPerform } from '~/jet';
|
||||
|
||||
type AllowedAnchorAttributes = Omit<
|
||||
HTMLAnchorAttributes,
|
||||
// The `href` attribute is not allowed because it will be provided
|
||||
// by the `FlowAction`
|
||||
'href'
|
||||
>;
|
||||
|
||||
interface $$Props extends AllowedAnchorAttributes {
|
||||
destination: FlowAction;
|
||||
}
|
||||
|
||||
const perform = getJetPerform();
|
||||
|
||||
export let destination: FlowAction;
|
||||
|
||||
// Web cannot support internal protocols, so this guard prevents
|
||||
// them from showing up in anchor tags.
|
||||
$: pageUrl = destination.pageUrl?.includes('x-as3-internal:')
|
||||
? '#'
|
||||
: destination?.pageUrl;
|
||||
|
||||
function onClick(event: MouseEvent) {
|
||||
event.preventDefault();
|
||||
|
||||
perform(destination);
|
||||
}
|
||||
</script>
|
||||
|
||||
<a
|
||||
{...$$restProps}
|
||||
href={pageUrl}
|
||||
data-test-id="internal-link"
|
||||
on:click={onClick}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
51
src/components/jet/action/ShelfBasedPageScrollAction.svelte
Normal file
51
src/components/jet/action/ShelfBasedPageScrollAction.svelte
Normal file
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
Action,
|
||||
ShelfBasedPageScrollAction,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export function isShelfBasedPageScrollAction(
|
||||
action: Action,
|
||||
): action is ShelfBasedPageScrollAction {
|
||||
return (
|
||||
action.$kind === 'ShelfBasedPageScrollAction' && 'shelfId' in action
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import type { HTMLAnchorAttributes } from 'svelte/elements';
|
||||
|
||||
interface $$Props extends HTMLAnchorAttributes {
|
||||
destination: ShelfBasedPageScrollAction;
|
||||
}
|
||||
|
||||
export let destination: ShelfBasedPageScrollAction;
|
||||
|
||||
function handleLinkClick(e: Event) {
|
||||
const anchorElement = e.currentTarget as HTMLAnchorElement;
|
||||
const hash = anchorElement.hash;
|
||||
const elementToScrollTo = document.querySelector(hash);
|
||||
if (!elementToScrollTo) {
|
||||
return;
|
||||
}
|
||||
elementToScrollTo.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start',
|
||||
});
|
||||
history.replaceState(null, '', hash);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if destination.shelfId}
|
||||
<a
|
||||
{...$$restProps}
|
||||
data-test-id="scroll-link"
|
||||
href={`#${destination.shelfId}`}
|
||||
on:click|preventDefault|stopPropagation={handleLinkClick}
|
||||
>
|
||||
<slot />
|
||||
</a>
|
||||
{:else}
|
||||
<slot />
|
||||
{/if}
|
||||
61
src/components/jet/badge/ContentRatingBadge.svelte
Normal file
61
src/components/jet/badge/ContentRatingBadge.svelte
Normal file
@@ -0,0 +1,61 @@
|
||||
<script lang="ts" context="module">
|
||||
import type { Badge, BadgeType } from '@jet-app/app-store/api/models';
|
||||
|
||||
const ARTWORK_TYPE: BadgeType = 'artwork';
|
||||
const CONTENT_RATING_TYPE: BadgeType = 'contentRating';
|
||||
const CONTENT_RATING_KEY = 'contentRating';
|
||||
|
||||
interface ContentRatingBadge extends Badge {
|
||||
type: typeof CONTENT_RATING_TYPE;
|
||||
}
|
||||
|
||||
export function isContentRatingBadge(
|
||||
badge: Badge,
|
||||
): badge is ContentRatingBadge {
|
||||
return (
|
||||
badge.type === CONTENT_RATING_TYPE ||
|
||||
(badge.key === CONTENT_RATING_KEY && badge.type === ARTWORK_TYPE)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import SystemImage, {
|
||||
isSystemImageArtwork,
|
||||
} from '~/components/SystemImage.svelte';
|
||||
|
||||
export let badge: ContentRatingBadge;
|
||||
|
||||
$: ({ artwork, accessibilityTitle } = badge);
|
||||
</script>
|
||||
|
||||
{#if artwork && isSystemImageArtwork(artwork)}
|
||||
<div class="pictogram-container" aria-label={accessibilityTitle}>
|
||||
<SystemImage {artwork} />
|
||||
</div>
|
||||
{:else}
|
||||
<span>
|
||||
{badge.content.contentRating}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
span {
|
||||
height: 25px;
|
||||
margin: 4px 0 2px;
|
||||
font: var(--title-1-emphasized);
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
.pictogram-container {
|
||||
height: 25px;
|
||||
padding: 2px;
|
||||
aspect-ratio: 1/1;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
|
||||
.pictogram-container :global(svg) {
|
||||
width: 21px;
|
||||
height: 21px;
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
463
src/components/jet/marker-shelf/ProductTopLockup.svelte
Normal file
463
src/components/jet/marker-shelf/ProductTopLockup.svelte
Normal file
@@ -0,0 +1,463 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
AppPlatform,
|
||||
ShelfBasedProductPage,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
/**
|
||||
* The parts of {@linkcode ShelfBasedProductPage} that are required to render
|
||||
* the `MarkerShelf` component
|
||||
*/
|
||||
export type MarkerShelfPageRequirements = Pick<
|
||||
ShelfBasedProductPage,
|
||||
| 'badges'
|
||||
| 'banner'
|
||||
| 'developerAction'
|
||||
| 'lockup'
|
||||
| 'shelfMapping'
|
||||
| 'titleOfferDisplayProperties'
|
||||
| 'canonicalURL'
|
||||
| 'appPlatforms'
|
||||
>;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset';
|
||||
import { platform } from '@amp/web-apps-utils';
|
||||
import AppIcon, {
|
||||
doesAppIconNeedBorder,
|
||||
} from '~/components/AppIcon.svelte';
|
||||
import Banner from '~/components/jet/item/BannerItem.svelte';
|
||||
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
|
||||
import ProductPageArcadeBanner from '~/components/ProductPageArcadeBanner.svelte';
|
||||
import { getI18n } from '~/stores/i18n';
|
||||
import { colorAsString, isNamedColor, isRGBColor } from '~/utils/color';
|
||||
import { concatWithMiddot, isString } from '~/utils/string-formatting';
|
||||
import {
|
||||
isPlatformExclusivelySupported,
|
||||
isPlatformSupported,
|
||||
PlatformToExclusivityText,
|
||||
} from '~/utils/app-platforms';
|
||||
import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg';
|
||||
import ShareArrowButton, {
|
||||
isShareSupported,
|
||||
} from '~/components/ShareArrowButton.svelte';
|
||||
import LaunchNativeButton from '~/components/LaunchNativeButton.svelte';
|
||||
|
||||
export let page: MarkerShelfPageRequirements;
|
||||
|
||||
$: banner = page.banner;
|
||||
$: lockup = page.lockup;
|
||||
$: appPlatforms = page.appPlatforms;
|
||||
$: offerDisplayProperties = lockup.offerDisplayProperties || {};
|
||||
$: ({ expectedReleaseDate } = offerDisplayProperties?.subtitles || {});
|
||||
|
||||
const i18n = getI18n();
|
||||
|
||||
// TODO: replace with `supportsArcade` from Jet
|
||||
// rdar://143706610 (Support `supportsArcade` attribute)
|
||||
$: supportsArcade = offerDisplayProperties.offerType === 'arcadeApp';
|
||||
|
||||
$: backgroundColor = isRGBColor(lockup.icon?.backgroundColor)
|
||||
? colorAsString(lockup.icon.backgroundColor)
|
||||
: '#fff';
|
||||
|
||||
$: backgroundImage = lockup.icon
|
||||
? buildSrc(
|
||||
lockup.icon.template,
|
||||
{
|
||||
crop: 'bb',
|
||||
width: 400,
|
||||
height: 400,
|
||||
fileType: 'webp',
|
||||
},
|
||||
{},
|
||||
)
|
||||
: undefined;
|
||||
|
||||
$: attributes = concatWithMiddot(
|
||||
[
|
||||
expectedReleaseDate && $i18n.t('ASE.Web.AppStore.App.ComingSoon'),
|
||||
expectedReleaseDate && expectedReleaseDate,
|
||||
// Attributes that are not relevant for Arcade Apps:
|
||||
...(!supportsArcade
|
||||
? [
|
||||
page.titleOfferDisplayProperties?.isFree &&
|
||||
$i18n.t('ASE.Web.AppStore.Free'),
|
||||
offerDisplayProperties.priceFormatted,
|
||||
offerDisplayProperties.subtitles?.standard,
|
||||
lockup.tertiaryTitle,
|
||||
]
|
||||
: []),
|
||||
].filter(isString),
|
||||
$i18n,
|
||||
);
|
||||
|
||||
$: exclusivePlatform = (
|
||||
Object.keys(PlatformToExclusivityText) as AppPlatform[]
|
||||
).find((platform: AppPlatform) =>
|
||||
isPlatformExclusivelySupported(platform, appPlatforms),
|
||||
);
|
||||
$: exclusivityText = exclusivePlatform
|
||||
? PlatformToExclusivityText[exclusivePlatform]
|
||||
: null;
|
||||
|
||||
$: shouldShowLaunchNativeButton =
|
||||
platform.ismacOS() &&
|
||||
(lockup.isIOSBinaryMacOSCompatible ||
|
||||
isPlatformSupported('mac', appPlatforms));
|
||||
|
||||
let shouldShowShareButton: boolean = true;
|
||||
|
||||
onMount(() => {
|
||||
shouldShowShareButton = isShareSupported();
|
||||
});
|
||||
</script>
|
||||
|
||||
<ShelfWrapper withBottomPadding={false} withPaddingTop={false}>
|
||||
<div
|
||||
class="container"
|
||||
style:--background-color={backgroundColor}
|
||||
style:--background-image={`url(${backgroundImage})`}
|
||||
>
|
||||
<div class="rotate" />
|
||||
<div class="blur" />
|
||||
|
||||
<div class="content-container">
|
||||
{#if lockup.icon}
|
||||
<div
|
||||
class="app-icon-contianer"
|
||||
class:without-border={!doesAppIconNeedBorder(lockup.icon)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<AppIcon
|
||||
icon={lockup.icon}
|
||||
profile="app-icon-large"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
|
||||
<div class="glow">
|
||||
<AppIcon
|
||||
icon={lockup.icon}
|
||||
profile="app-icon-large"
|
||||
fixedWidth={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section>
|
||||
{#if supportsArcade}
|
||||
<span
|
||||
class="arcade-logo"
|
||||
aria-label={$i18n.t(
|
||||
'ASE.Web.AppStore.ArcadeLogo.AccessibilityValue',
|
||||
)}
|
||||
>
|
||||
<AppleArcadeLogo />
|
||||
</span>
|
||||
{:else if lockup.editorialTagline}
|
||||
<h3>{lockup.editorialTagline}</h3>
|
||||
{/if}
|
||||
|
||||
<h1>
|
||||
{lockup.title}
|
||||
</h1>
|
||||
|
||||
<h2 class="subtitle">
|
||||
{lockup.subtitle}
|
||||
</h2>
|
||||
|
||||
{#if exclusivityText}
|
||||
<p class="attributes">
|
||||
{$i18n.t(exclusivityText)}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if attributes.length > 0}
|
||||
<p class="attributes">
|
||||
{attributes}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if page.canonicalURL && (shouldShowLaunchNativeButton || shouldShowShareButton)}
|
||||
<div class="buttons-container">
|
||||
{#if shouldShowLaunchNativeButton}
|
||||
<span class="launch-native-button-container">
|
||||
<LaunchNativeButton url={page.canonicalURL} />
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if shouldShowShareButton}
|
||||
<!--
|
||||
If there is no launch native button, then we show a label for
|
||||
the share button, which helps to visually fill out the space.
|
||||
-->
|
||||
<ShareArrowButton
|
||||
url={page.canonicalURL}
|
||||
withLabel={!shouldShowLaunchNativeButton}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</ShelfWrapper>
|
||||
|
||||
{#if banner}
|
||||
<ShelfWrapper withBottomPadding={false} withTopMargin={false}>
|
||||
<Banner item={banner} />
|
||||
</ShelfWrapper>
|
||||
{/if}
|
||||
|
||||
{#if supportsArcade}
|
||||
<ShelfWrapper
|
||||
withBottomPadding={false}
|
||||
withTopMargin={true}
|
||||
centered={false}
|
||||
>
|
||||
<ProductPageArcadeBanner />
|
||||
</ShelfWrapper>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.container {
|
||||
--blend-mode: plus-lighter;
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
height: 200px;
|
||||
color: var(--systemPrimary-onDark);
|
||||
border-bottom: 1px solid var(--systemQuaternary-vibrant);
|
||||
border-bottom-right-radius: 2px;
|
||||
border-bottom-left-radius: 2px;
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent 20%,
|
||||
rgba(0, 0, 0, 0.8) 100%
|
||||
),
|
||||
var(--background-image), var(--background-color, #000);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
transition: border-bottom-left-radius 210ms ease-out,
|
||||
border-bottom-right-radius 210ms ease-out;
|
||||
transform: translate(0);
|
||||
|
||||
@media (--range-small-up) {
|
||||
height: 286px;
|
||||
}
|
||||
|
||||
@media (--range-xlarge-up) {
|
||||
border: 1px solid var(--systemQuaternary-vibrant);
|
||||
border-top: none;
|
||||
border-bottom-right-radius: 30px;
|
||||
border-bottom-left-radius: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.glow {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
transform: scale(1.5);
|
||||
filter: blur(60px);
|
||||
}
|
||||
|
||||
.blur {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
backdrop-filter: blur(100px) saturate(1.5);
|
||||
}
|
||||
|
||||
.rotate {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
filter: brightness(1.3) saturate(0) blur(50px);
|
||||
mix-blend-mode: overlay;
|
||||
height: 500%;
|
||||
background-image: var(--background-image);
|
||||
background-repeat: repeat;
|
||||
opacity: 0;
|
||||
transform-origin: top center;
|
||||
animation: shift-background 60s infinite linear 10s;
|
||||
}
|
||||
|
||||
.content-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
max-width: 840px;
|
||||
gap: 1em;
|
||||
margin: 0 var(--bodyGutter);
|
||||
|
||||
@media (--range-small-up) {
|
||||
gap: 1.5em;
|
||||
}
|
||||
|
||||
@media (--range-medium-up) {
|
||||
gap: 2em;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-contianer {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
|
||||
@media (--range-small-up) {
|
||||
width: 194px;
|
||||
}
|
||||
}
|
||||
|
||||
.app-icon-contianer:not(.without-border) :global(> .app-icon) {
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.33);
|
||||
border: 2px solid var(--systemQuaternary-onDark);
|
||||
}
|
||||
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.subtitle,
|
||||
.attributes {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
margin-bottom: 4px;
|
||||
font: var(--body);
|
||||
color: rgba(245.973, 245.973, 245.973, 0.6);
|
||||
text-wrap: pretty;
|
||||
mix-blend-mode: var(--blend-mode);
|
||||
|
||||
@media (--range-small-up) {
|
||||
margin-bottom: 8px;
|
||||
font: var(--title-2-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
.attributes {
|
||||
margin-bottom: 0;
|
||||
font: var(--body);
|
||||
}
|
||||
|
||||
.buttons-container {
|
||||
--share-arrow-size: 27px;
|
||||
--launch-native-button-arrow-size: 7px;
|
||||
--get-button-font: var(--footnote-bold);
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
|
||||
@media (--range-small-up) {
|
||||
--share-arrow-size: unset;
|
||||
--launch-native-button-arrow-size: unset;
|
||||
--get-button-font: unset;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 2px;
|
||||
font: var(--title-2-emphasized);
|
||||
color: white;
|
||||
text-shadow: 0 0 5px rgba(0, 0, 0, 0.2);
|
||||
text-wrap: pretty;
|
||||
|
||||
@media (--sidebar-visible) {
|
||||
font: var(--title-1-emphasized);
|
||||
}
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--header-emphasized);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-bottom: 0;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
mix-blend-mode: plus-lighter;
|
||||
font: var(--body-emphasized);
|
||||
|
||||
@media (--range-small-up) {
|
||||
font: var(--title-3-emphasized);
|
||||
}
|
||||
}
|
||||
|
||||
.arcade-logo {
|
||||
display: flex;
|
||||
height: 10px;
|
||||
margin-bottom: 4px;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
mix-blend-mode: plus-lighter;
|
||||
|
||||
@media (--range-small-up) {
|
||||
height: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.launch-native-button-container {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
@keyframes shift-background {
|
||||
0% {
|
||||
background-position: 50% 50%;
|
||||
background-size: 100%;
|
||||
transform: rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
10% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
20% {
|
||||
background-position: 65% 25%;
|
||||
background-size: 160%;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
45% {
|
||||
background-position: 90% 60%;
|
||||
background-size: 250%;
|
||||
transform: rotate(160deg);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
70% {
|
||||
background-position: 70% 40%;
|
||||
background-size: 200%;
|
||||
transform: rotate(250deg);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
100% {
|
||||
background-position: 50% 50%;
|
||||
background-size: 100%;
|
||||
transform: rotate(360deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
AccessibilityParagraph,
|
||||
Shelf,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
interface AccessibilityDeveloperLinkShelf extends Shelf {
|
||||
items: [AccessibilityParagraph];
|
||||
}
|
||||
|
||||
export function isAccessibilityDeveloperLinkShelf(
|
||||
shelf: Shelf,
|
||||
): shelf is AccessibilityDeveloperLinkShelf {
|
||||
let { contentType, items, title } = shelf;
|
||||
|
||||
return (
|
||||
contentType === 'accessibilityParagraph' &&
|
||||
!title &&
|
||||
Array.isArray(items)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
|
||||
import AccessibilityParagraphItem from '../item/AccessibilityParagraphItem.svelte';
|
||||
import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
|
||||
|
||||
export let shelf: AccessibilityDeveloperLinkShelf;
|
||||
|
||||
$: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
|
||||
</script>
|
||||
|
||||
<ShelfWrapper {shelf} centered {withBottomPadding}>
|
||||
<AccessibilityParagraphItem item={shelf.items[0]} />
|
||||
</ShelfWrapper>
|
||||
35
src/components/jet/shelf/AccessibilityFeaturesShelf.svelte
Normal file
35
src/components/jet/shelf/AccessibilityFeaturesShelf.svelte
Normal file
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" context="module">
|
||||
import type {
|
||||
AccessibilityFeatures,
|
||||
Shelf,
|
||||
} from '@jet-app/app-store/api/models';
|
||||
|
||||
export interface AccessibilityFeaturesShelf extends Shelf {
|
||||
items: AccessibilityFeatures[];
|
||||
}
|
||||
|
||||
export function isAccessibilityFeaturesShelf(
|
||||
shelf: Shelf,
|
||||
): shelf is AccessibilityFeaturesShelf {
|
||||
let { contentType, items } = shelf;
|
||||
|
||||
return contentType === 'accessibilityFeatures' && Array.isArray(items);
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
|
||||
import ShelfItemLayout from '~/components/ShelfItemLayout.svelte';
|
||||
import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte';
|
||||
import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout';
|
||||
|
||||
export let shelf: AccessibilityFeaturesShelf;
|
||||
|
||||
$: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf));
|
||||
</script>
|
||||
|
||||
<ShelfWrapper {shelf} {withBottomPadding}>
|
||||
<ShelfItemLayout {shelf} gridType="B" let:item>
|
||||
<AccessibilityFeaturesItem {item} />
|
||||
</ShelfItemLayout>
|
||||
</ShelfWrapper>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user