diff options
Diffstat (limited to 'src')
377 files changed, 24769 insertions, 0 deletions
diff --git a/src/App.svelte b/src/App.svelte new file mode 100644 index 0000000..846f1df --- /dev/null +++ b/src/App.svelte @@ -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> diff --git a/src/bootstrap.ts b/src/bootstrap.ts new file mode 100644 index 0000000..14ebbe8 --- /dev/null +++ b/src/bootstrap.ts @@ -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, + }; +} diff --git a/src/browser.ts b/src/browser.ts new file mode 100644 index 0000000..18c20f7 --- /dev/null +++ b/src/browser.ts @@ -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(); +} diff --git a/src/components/AmbientBackgroundArtwork.svelte b/src/components/AmbientBackgroundArtwork.svelte new file mode 100644 index 0000000..bc9563c --- /dev/null +++ b/src/components/AmbientBackgroundArtwork.svelte @@ -0,0 +1,202 @@ +<script lang="ts"> + import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models'; + import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import ResizeDetector from '@amp/web-app-components/src/components/helpers/ResizeDetector.svelte'; + import { colorAsString } from '~/utils/color'; + + export let artwork: JetArtworkType; + export let active: boolean = false; + + $: isBackgroundImageLoaded = false; + $: backgroundImage = artwork + ? buildSrc( + artwork.template, + { + crop: 'sr', + width: 400, + height: Math.floor(400 / 1.6667), + fileType: 'webp', + }, + {}, + ) + : undefined; + + $: if (backgroundImage) { + const img = new Image(); + img.onload = () => (isBackgroundImageLoaded = true); + img.src = backgroundImage; + } + + let resizing = false; + const handleResizeUpdate = (e: CustomEvent<{ isResizing: boolean }>) => + (resizing = e.detail.isResizing); + + let isOutOfView = true; + const handleIntersectionOberserverUpdate = ( + isIntersectingViewport: boolean, + ) => (isOutOfView = !isIntersectingViewport); +</script> + +{#if backgroundImage} + <ResizeDetector on:resizeUpdate={handleResizeUpdate} /> + + <div + class="container" + class:active + class:resizing + class:loaded={isBackgroundImageLoaded} + class:out-of-view={isOutOfView} + style:--background-image={`url(${backgroundImage})`} + style:--background-color={artwork.backgroundColor && + colorAsString(artwork.backgroundColor)} + use:intersectionObserver={{ + callback: handleIntersectionOberserverUpdate, + threshold: 0, + }} + > + <div class="overlay" /> + </div> +{/if} + +<style> + .container { + --veil: rgb(240, 240, 240, 0.65); + --speed: 0.66s; + --aspect-ratio: 16/9; + --scale: 1.2; + position: absolute; + top: 0; + left: 0; + width: 100%; + aspect-ratio: var(--aspect-ratio); + max-height: 900px; + opacity: 0; + + /* + This stack of background images represents the following three layers, listed front-to-back: + + 1) A gradient from transparent to white that acts as a mask for the entire container. + `mask-image` caused too much thrashing and CPU usage when animating and resizing, + so we are mimicking its functionality with this top-layer background image. + 2) A semi-transparent veil to evenly fade out the bg. Note that this is not technically + a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be + used in `background-image`. + 3) The joe color of the background image that will eventualy be loaded. + */ + background-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 50%, + var(--pageBg) 80% + ), + linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%), + linear-gradient( + 0deg, + var(--background-color) 0%, + var(--background-color) 80% + ); + background-position: center; + background-size: 120%; + + /* + Blurring via the CSS filter does not extend edge-to-edge of the contents width, but we + can mitigate that by ever-so-slightly bumping up the `scale` of content so it bleeds off + the page cleanly. + */ + filter: blur(20px) saturate(1.3); + transform: scale(var(--scale)); + transition: opacity calc(var(--speed) * 2) ease-out, + background-size var(--speed) ease-in; + + @media (prefers-color-scheme: dark) { + --veil: rgba(0, 0, 0, 0.5); + } + } + + .container.loaded { + /* + This stack of background images represents the following three layers, listed front-to-back: + + 1) A gradient from transparent to white that acts as a mask for the entire container. + `mask-image` caused too much thrashing and CPU usage when animating and resizing, + so we are mimicking its functionality with this top-layer background image. + 2) A semi-transparent veil to evenly fade out the image. Note that this is not technically + a gradient, but we are using `linear-gradient` because a regular `rgb` value can't be + used in `background-image`. + 3) The actual background image. + */ + background-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 50%, + var(--pageBg) 80% + ), + linear-gradient(0deg, var(--veil) 0%, var(--veil) 80%), + var(--background-image); + } + + .container.active { + opacity: 1; + transition: opacity calc(var(--speed) / 2) ease-in; + background-size: 100%; + } + + .overlay { + position: absolute; + z-index: 2; + top: 0; + left: 0; + width: 100%; + aspect-ratio: var(--aspect-ratio); + max-height: 900px; + opacity: 0; + background-image: var(--background-image); + background-position: 100% 100%; + background-size: 250%; + filter: brightness(1.3) saturate(0); + mix-blend-mode: overlay; + will-change: opacity, background-position; + animation: shift-background 60s infinite linear alternate; + animation-play-state: paused; + transition: opacity var(--speed) ease-in; + } + + .active .overlay { + opacity: 0.3; + animation-play-state: running; + transition: opacity calc(var(--speed) * 2) ease-in + calc(var(--speed) * 2); + } + + .active.out-of-view .overlay, + .active.resizing .overlay { + animation-play-state: paused; + opacity: 0; + } + + @keyframes shift-background { + 0% { + background-position: 0% 50%; + background-size: 250%; + } + + 25% { + background-position: 60% 20%; + background-size: 300%; + } + + 50% { + background-position: 100% 50%; + background-size: 320%; + } + + 75% { + background-position: 40% 100%; + background-size: 220%; + } + + 100% { + background-position: 20% 50%; + background-size: 300%; + } + } +</style> diff --git a/src/components/AppEventDate.svelte b/src/components/AppEventDate.svelte new file mode 100644 index 0000000..41ee248 --- /dev/null +++ b/src/components/AppEventDate.svelte @@ -0,0 +1,72 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import { fade } from 'svelte/transition'; + import type { Optional } from '@jet/environment/types/optional'; + import type { AppEvent } from '@jet-app/app-store/api/models'; + import { getJet } from '~/jet'; + import { + chooseAppEventDate, + renderDate, + computeAppEventFormattedDates, + type RequiredAppEventFormattedDate, + } from '~/jet/utils/app-event-formatted-date'; + + const jet = getJet(); + + /** + * New pattern (*prefered*): accept appEvent object and compute formattedDates on client-side. + * This avoids timezone differences in SSR server (UTC) which cause incorrect event date and time. + * By computing dates in the browser, we ensure the user sees dates in their local timezone. + */ + export let appEvent: + | Pick<AppEvent, 'appEventBadgeKind' | 'startDate' | 'endDate'> + | undefined = undefined; + + // Legacy pattern: accept pre-computed formattedDates from Jet + export let formattedDates: RequiredAppEventFormattedDate[] | undefined = + undefined; + + let appEventDate: Optional<RequiredAppEventFormattedDate>; + + onMount(() => { + const dates = appEvent + ? computeAppEventFormattedDates( + jet.objectGraph, + appEvent.appEventBadgeKind, + appEvent.startDate, + appEvent.endDate, + ) + : formattedDates; + + if (dates) { + appEventDate = chooseAppEventDate(dates); + } + }); + + /** + * `Date` instances in the view-model will have been serialized to `string` + * instances by ServerKit when delivered to the client; we need to normalize + * this so that we have a `string` both client- and server-side. + */ + function normalizeDate(date: Date | string): string { + return typeof date === 'string' ? date : date.toISOString(); + } +</script> + +{#if appEventDate} + <time + transition:fade={{ duration: 210 }} + datetime={appEventDate.displayFromDate && + normalizeDate(appEventDate.displayFromDate)} + > + {renderDate(jet.objectGraph.loc, appEventDate)} + </time> +{:else} + <span aria-hidden="true">…</span> +{/if} + +<style> + span { + color: transparent; + } +</style> diff --git a/src/components/AppIcon.svelte b/src/components/AppIcon.svelte new file mode 100644 index 0000000..4cb0262 --- /dev/null +++ b/src/components/AppIcon.svelte @@ -0,0 +1,131 @@ +<script lang="ts" context="module"> + import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models'; + import type { NamedProfile } from '~/config/components/artwork'; + + export type AppIconProfile = Extract< + NamedProfile, + | 'app-icon' + | 'app-icon-large' + | 'app-icon-medium' + | 'app-icon-small' + | 'app-icon-xlarge' + | 'app-icon-river' + | 'brick-app-icon' + >; + + export function doesAppIconNeedBorder(icon: JetArtworkType): boolean { + const doesIconHaveTransparentBackground = + icon.backgroundColor && + isNamedColor(icon.backgroundColor) && + icon.backgroundColor.name === 'clear'; + const isIconPrerendered = + icon.style === 'roundedRectPrerendered' || + icon.style === 'roundPrerendered'; + const isIconUnadorned = icon.style === 'unadorned'; + + return ( + !doesIconHaveTransparentBackground && + !isIconPrerendered && + !isIconUnadorned + ); + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork'; + import { isNamedColor } from '~/utils/color'; + + export let icon: JetArtworkType; + export let profile: AppIconProfile = 'app-icon'; + export let fixedWidth: boolean = true; + export let disableAutoCenter: boolean = false; + export let withBorder: boolean = false; + + const profiles = ArtworkConfig.get().PROFILES; + + $: computedProfile = ( + icon.style === 'pill' + ? `${profile}-pill` + : icon.style === 'tvRect' + ? `${profile}-tv-rect` + : profile + ) as NamedProfile; + $: widthFromProfile = profiles?.get(computedProfile)?.[0] ?? 0; + $: hasTransparentBackground = + !!icon.backgroundColor && + isNamedColor(icon.backgroundColor) && + icon.backgroundColor.name === 'clear'; + $: needsBorder = withBorder || doesAppIconNeedBorder(icon); + + // These prerendered "Solarium" icons need to use higher than normal quality due to how their + // rendering pipeline downscales/transforms sources. + $: quality = + icon.style && + ['roundedRectPrerendered', 'roundPrerendered'].includes(icon.style) + ? 75 + : undefined; +</script> + +<div + class="app-icon" + class:pill={icon.style === 'pill'} + class:round={icon.style === 'round'} + class:rounded-rect={icon.style === 'roundedRect'} + class:tv-rect={icon.style === 'tvRect'} + class:rounded-rect-prerendered={icon.style === 'roundedRectPrerendered'} + class:round-prerendered={icon.style === 'roundPrerendered'} + class:with-border={needsBorder} + style={fixedWidth ? `--profileWidth: ${widthFromProfile}px` : ''} +> + <Artwork + {disableAutoCenter} + {hasTransparentBackground} + {quality} + artwork={icon} + profile={computedProfile} + noShelfChevronAnchor={true} + /> +</div> + +<style> + .app-icon { + aspect-ratio: 1 / 1; + min-width: var(--profileWidth, auto); + } + + .app-icon.pill { + aspect-ratio: 4 / 3; + + /* + Creates elliptical corners with horizontal radii at 50% of the width and vertical radii + at 65% of the height, for a rounded, squished, pill-like effect + */ + border-radius: 50% 50% 50% 50% / 65% 65% 65% 65%; + } + + .app-icon.round { + border-radius: 50%; + } + + .app-icon.rounded-rect { + border-radius: 23%; + } + + .app-icon.tv-rect { + aspect-ratio: 16/9; + border-radius: 9% / 16%; + } + + .app-icon.rounded-rect-prerendered { + border-radius: 25%; + } + + .app-icon.round-prerendered { + border-radius: 50%; + } + + .app-icon.with-border { + box-shadow: 0 0 0 1px var(--systemQuaternary); + } +</style> diff --git a/src/components/AppIconRiver.svelte b/src/components/AppIconRiver.svelte new file mode 100644 index 0000000..b673dd0 --- /dev/null +++ b/src/components/AppIconRiver.svelte @@ -0,0 +1,92 @@ +<script lang="ts"> + import { onMount } from 'svelte'; + import type { Artwork } from '@jet-app/app-store/api/models'; + import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte'; + + export let icons: Artwork[]; + export let profile: AppIconProfile = 'app-icon-river'; + + $: aspectRatio = icons[0].width / icons[0].height; + + let mounted = false; + const numberOfIcons = icons.length; + + // We shift the order of the bottom row of icons to ensure that the same icons aren't shown + // next to each other. Note that this is different from purely shuffling the icons, as that + // could still lead to the same icons being next to one another, due to how small the set is. + // The input and output here is as such: + // in = [1, 2, 3, 4, 5, 6, 7] + // out = [4, 5, 6, 7, 1, 2, 3] + const iconsInShiftedOrder = [ + ...icons.slice(numberOfIcons / 2), + ...icons.slice(0, numberOfIcons / 2), + ]; + + // We are quadrupling the icons we render so the flow is seamless and stretches across the + // full width of the container. + const topRow = Array(4).fill(icons).flat(); + const bottomRow = Array(4).fill(iconsInShiftedOrder).flat(); + + // We use this `mounted` flag to defer the rendering of the `AppIconRiver`, since it's markup heavy + // and has no semantic meaning for SEO. This deferring saves about 190kb of initial HTML per instance. + onMount(() => (mounted = true)); +</script> + +{#if mounted} + {#each [topRow, bottomRow] as iconRow} + <ul class="app-icons"> + {#each iconRow as icon} + <li + class="app-icon-container" + style:--aspect-ratio={aspectRatio} + > + <AppIcon {icon} {profile} fixedWidth={false} /> + </li> + {/each} + </ul> + {/each} +{/if} + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .app-icons { + --icon-width: var(--app-icon-river-icon-width, 128px); + --speed: var(--app-icon-river-speed, 240s); + --direction: -50%; + + @include rtl { + --direction: 50%; + } + display: flex; + width: fit-content; + z-index: 2; + animation: scroll var(--speed) linear infinite; + } + + .app-icons:last-of-type { + margin-bottom: 20px; + } + + .app-icon-container { + width: var(--icon-width); + aspect-ratio: var(--aspect-ratio); + margin: 8px; + } + + .app-icons:last-of-type .app-icon-container { + position: relative; + right: calc((var(--icon-width) / 2) + 8px); + } + + @keyframes scroll { + 0% { + transform: translateX(0); + } + + 100% { + transform: translateX(var(--direction)); + } + } +</style> diff --git a/src/components/Artwork.svelte b/src/components/Artwork.svelte new file mode 100644 index 0000000..04de1d4 --- /dev/null +++ b/src/components/Artwork.svelte @@ -0,0 +1,118 @@ +<script lang="ts" context="module"> + import type { Artwork as JetArtworkType } from '@jet-app/app-store/api/models'; + import type { + Artwork as ComponentArtworkType, + Profile as ArtworkProfile, + CropCode, + ImageSizes, + } from '@amp/web-app-components/src/components/Artwork/types'; + + import type { NamedProfile } from '~/config/components/artwork'; + + /** + * Creates a {@linkcode Profile} on-the-fly based on the properties of + * the {@linkcode artwork} + */ + export function getNaturalProfile( + artwork: JetArtworkType, + imageSizes: ImageSizes = [artwork.width], + ): ArtworkProfile { + const aspectRatio = artwork.width / artwork.height; + + return [imageSizes, aspectRatio, artwork.crop as CropCode]; + } + + export type Profile = NamedProfile | ArtworkProfile; +</script> + +<script lang="ts"> + import type { ImageSettings } from '@amp/web-app-components/src/components/Artwork/types'; + import Artwork from '@amp/web-app-components/src/components/Artwork/Artwork.svelte'; + import { colorAsString, isNamedColor } from '~/utils/color'; + + import { + ArtworkConfig, + type ArtworkProfileMap, + } from '@amp/web-app-components/config/components/artwork'; + + export let artwork: JetArtworkType; + export let profile: Profile; + export let alt: string = ''; + export let topRoundedSecondary: boolean = false; + export let useContainerStyle: boolean = false; + export let forceFullWidth: boolean = true; + export let isDecorative: boolean = true; + export let lazyLoad: boolean = true; + export let disableAutoCenter: boolean = false; + export let noShelfChevronAnchor: boolean = false; + export let forceCropCode: boolean = false; + export let quality: number | undefined = undefined; + export let hasTransparentBackground: boolean = + !!artwork.backgroundColor && + isNamedColor(artwork.backgroundColor) && + artwork.backgroundColor.name === 'clear'; + export let useCropCodeFromArtwork: boolean = true; + export let withoutBorder: boolean = false; + + let imageSettings: ImageSettings; + $: imageSettings = { + forceCropCode, + hasTransparentBackground, + quality, + }; + + let PROFILES: ArtworkProfileMap<string> | undefined; + let computedProfileAttributes: Profile | undefined; + + $: { + const config = ArtworkConfig?.get(); + PROFILES = config?.PROFILES; + + const defaultProfileAttributes: Profile | undefined = + typeof profile === 'string' ? PROFILES?.get(profile) : profile; + + const cropCodeIndex = 2; + + if ( + useCropCodeFromArtwork && + artwork?.crop && + defaultProfileAttributes + ) { + computedProfileAttributes = [...defaultProfileAttributes]; + computedProfileAttributes[cropCodeIndex] = + artwork?.crop as CropCode; + } + } + + $: artworkForComponent = { + ...artwork, + backgroundColor: artwork.backgroundColor + ? colorAsString(artwork.backgroundColor) + : undefined, + } satisfies ComponentArtworkType; +</script> + +<Artwork + artwork={artworkForComponent} + profile={computedProfileAttributes || profile} + {topRoundedSecondary} + {useContainerStyle} + {forceFullWidth} + {imageSettings} + {alt} + {isDecorative} + {lazyLoad} + {disableAutoCenter} + {noShelfChevronAnchor} + {withoutBorder} +/> + +<style> + /* When a user enables the "Smart Invert" accessibility setting, images should not be inverted, + so we are re-inverting back to their normal state in this media query, which only currently works for Safari. */ + @media (inverted-colors: inverted) { + :global(.artwork-component img) { + filter: invert(1); + } + } +</style> diff --git a/src/components/CollapsableContent.svelte b/src/components/CollapsableContent.svelte new file mode 100644 index 0000000..e75fbf1 --- /dev/null +++ b/src/components/CollapsableContent.svelte @@ -0,0 +1,36 @@ +<script lang="ts"> + import ChevronDown from '~/sf-symbols/chevron.down.svg'; +</script> + +<details> + <summary> + <slot name="summary" /> + <ChevronDown /> + </summary> + + <slot /> +</details> + +<style> + details[open] summary { + display: none; + } + + summary { + list-style: none; + cursor: pointer; + } + + summary::-webkit-details-marker { + display: none; + } + + summary :global(svg) { + overflow: visible; + width: 14px; + fill: var(--systemTertiary); + position: relative; + top: 3px; + left: 2px; + } +</style> diff --git a/src/components/EditorsChoiceBadge.svelte b/src/components/EditorsChoiceBadge.svelte new file mode 100644 index 0000000..2c4efe1 --- /dev/null +++ b/src/components/EditorsChoiceBadge.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import LaurelIcon from '~/sf-symbols/laurel.left.svg'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); +</script> + +<h4> + <span class="icon-container left" aria-hidden="true"> + <LaurelIcon /> + </span> + {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')} + <span class="icon-container right" aria-hidden="true"> + <LaurelIcon /> + </span> +</h4> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + h4 { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + margin-bottom: 10px; + gap: 10px; + font: var(--font, var(--title-1-emphasized)); + color: var(--systemSecondary); + } + + .icon-container.right { + transform: rotateY(180deg); + + @include rtl { + transform: rotateY(0); + } + } + + .icon-container.left { + @include rtl { + transform: rotateY(180deg); + } + } + + .icon-container :global(svg) { + overflow: visible; + height: 42px; + transform: translateY(3px); + } + + .icon-container :global(svg path) { + fill: var(--systemSecondary); + } +</style> diff --git a/src/components/Error.svelte b/src/components/Error.svelte new file mode 100644 index 0000000..a0aeba1 --- /dev/null +++ b/src/components/Error.svelte @@ -0,0 +1,10 @@ +<script lang="ts"> + import ErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let error: Error; + + const i18n = getI18n(); +</script> + +<ErrorPage translateFn={$i18n.t} {error} /> diff --git a/src/components/GradientOverlay.svelte b/src/components/GradientOverlay.svelte new file mode 100644 index 0000000..5827a2c --- /dev/null +++ b/src/components/GradientOverlay.svelte @@ -0,0 +1,23 @@ +<script lang="ts"> + export let shouldDarken: boolean = true; +</script> + +<div class="gradient-overlay" style:--brightness={shouldDarken ? 0.85 : 1} /> + +<style> + .gradient-overlay { + position: absolute; + z-index: 1; + bottom: 0; + width: 100%; + height: var(--height, 60%); + border-radius: var(--border-radius, var(--global-border-radius-large)); + background: linear-gradient( + transparent, + var(--color, var(--systemSecondary-onLight)) var(--height, 100%) + ); + backdrop-filter: blur(10px); + filter: saturate(1.5) brightness(var(--brightness)); + mask-image: linear-gradient(180deg, transparent 6%, rgb(0, 0, 0.5) 85%); + } +</style> diff --git a/src/components/Grid.svelte b/src/components/Grid.svelte new file mode 100644 index 0000000..df2ca74 --- /dev/null +++ b/src/components/Grid.svelte @@ -0,0 +1,37 @@ +<script lang="ts" generics="T"> + import { getGridVars } from '@amp/web-app-components/src/components/Shelf/utils/getGridVars'; + import type { GridType } from '@amp/web-app-components/src/components/Shelf/types'; + + export let items: T[] = []; + export let gridType: GridType; + + $: style = getGridVars(gridType); +</script> + +<ul {style} class="grid" data-test-id="grid"> + {#each items as item} + <li> + <slot {item} /> + </li> + {/each} +</ul> + +<style lang="scss"> + @mixin grid-styles-for-viewport($viewport: null) { + grid-template-columns: repeat(var(--grid-#{$viewport}), 1fr); + column-gap: var(--grid-column-gap-#{$viewport}); + row-gap: var(--grid-row-gap-#{$viewport}); + } + + .grid { + display: grid; + width: 100%; + padding: 0 var(--bodyGutter); + + @each $viewport in ('xsmall', 'small', 'medium', 'large', 'xlarge') { + @media (--range-#{$viewport}-only) { + @include grid-styles-for-viewport($viewport); + } + } + } +</style> diff --git a/src/components/HoverWrapper.svelte b/src/components/HoverWrapper.svelte new file mode 100644 index 0000000..2d2742f --- /dev/null +++ b/src/components/HoverWrapper.svelte @@ -0,0 +1,54 @@ +<script lang="ts"> + export let element: keyof HTMLElementTagNameMap = 'article'; + export let hasChin: boolean = false; +</script> + +<svelte:element this={element} class="hover-wrapper" class:has-chin={hasChin}> + <slot /> +</svelte:element> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/mixins/scrim-opacity-controller' as *; + @use 'amp/stylekit/core/mixins/hover-style' as *; + + .hover-wrapper { + position: relative; + display: var(--display, flex); + overflow: hidden; + align-items: center; + cursor: pointer; + border-radius: var(--global-border-radius-large); + box-shadow: var(--shadow-small); + + @include scrim-opacity-controller; + } + + .hover-wrapper.has-chin, + .hover-wrapper.has-chin::after { + // For chins, we cannot use `border-raidus` due a Chrome bug with unequal radii + // (e.g. there is no rounding at the bottom) and mask-image. To get around that, + // we use clip-path to the same effect. + // https://issues.chromium.org/issues/40778541. + border-radius: unset; + clip-path: inset( + 0 0 0 0 round var(--global-border-radius-large) + var(--global-border-radius-large) 0 0 + ); + } + + /* stylelint-disable order/order */ + .hover-wrapper::after { + mix-blend-mode: soft-light; + + @include content-container-hover-style; + + // These properties are overriding those provided by `content-container-hover-style` + border-radius: var(--global-border-radius-large); + transition: opacity 210ms ease-out; + } + /* stylelint-enable order/order */ + + .hover-wrapper:hover::after { + @include scrim-opacity; + } +</style> diff --git a/src/components/LaunchNativeButton.svelte b/src/components/LaunchNativeButton.svelte new file mode 100644 index 0000000..eb7942b --- /dev/null +++ b/src/components/LaunchNativeButton.svelte @@ -0,0 +1,69 @@ +<script lang="ts"> + import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { getJet } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + import { launchAppOnMac } from '~/utils/launch-client'; + + export let url: string; + + const i18n = getI18n(); + const jet = getJet(); + + function handleButtonClick(event: MouseEvent) { + // Need to call both event.preventDefault() and event.stopPropagation() + // to prevent navigation to the production page on web + event.preventDefault(); + event.stopPropagation(); + + if (url) { + launchAppOnMac(url); + jet.recordCustomMetricsEvent({ + eventType: 'click', + targetId: 'OpenInMacAppStore', + targetType: 'button', + actionType: 'open', + }); + } + } +</script> + +<button + class="get-button blue" + aria-label={$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.AX')} + on:click={handleButtonClick} +> + <LineClamp clamp={1}> + {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.Action')} + <span> + {$i18n.t('ASE.Web.AppStore.CTA.MacAppStore.App')} + </span> + </LineClamp> + <ArrowIcon class="external-link-arrow" aria-hidden="true" /> +</button> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + button { + display: inline-flex; + } + + button span { + font-weight: 500; + } + + button :global(.external-link-arrow) { + align-self: center; + width: var(--launch-native-button-arrow-size, 9px); + height: var(--launch-native-button-arrow-size, 9px); + padding-top: 1px; + margin-inline-start: 4px; + fill: var(--systemPrimary-onDark); + + @include rtl { + transform: rotate(-90deg); + } + } +</style> diff --git a/src/components/LinkWrapper.svelte b/src/components/LinkWrapper.svelte new file mode 100644 index 0000000..0e5025d --- /dev/null +++ b/src/components/LinkWrapper.svelte @@ -0,0 +1,60 @@ +<!-- +@component +Wraps a link around the provided slot contents if a valid `FlowAction` or `ExternalUrlAction` is given. +If no valid action is provided, the contents are rendered as-is with no decoration. + +💡 For accessibility, this component should ideally wrap the entire visual block (e.g., `div`, `article`) so that +screen readers and keyboard users interpret the entire element as a single link. + +@example +``` + <LinkWrapper action={item.clickAction}> + <article> + <Artwork artwork={item.artwork} /> + {item.title} + </article> + </LinkWrapper> +``` +--> +<script lang="ts"> + import { type Action, isFlowAction } from '@jet-app/app-store/api/models'; + import { type Opt, isSome } from '@jet/environment/types/optional'; + + import FlowActionComponent from '~/components/jet/action/FlowAction.svelte'; + import { isExternalUrlAction } from '~/jet/models'; + import ExternalUrlAction from './jet/action/ExternalUrlAction.svelte'; + import ShelfBasedPageScrollAction, { + isShelfBasedPageScrollAction, + } from './jet/action/ShelfBasedPageScrollAction.svelte'; + + export let action: Opt<Action> = null; + export let label: Opt<string> = null; + export let withoutLabel: Opt<boolean> = false; + export let includeExternalLinkArrowIcon: boolean = true; +</script> + +{#if isSome(action) && isFlowAction(action) && isSome(action.pageUrl)} + <FlowActionComponent + destination={action} + aria-label={withoutLabel ? null : label || action.title} + > + <slot /> + </FlowActionComponent> +{:else if isSome(action) && isExternalUrlAction(action)} + <ExternalUrlAction + destination={action} + aria-label={withoutLabel ? null : label || action.title} + includeArrowIcon={includeExternalLinkArrowIcon} + > + <slot /> + </ExternalUrlAction> +{:else if isSome(action) && isShelfBasedPageScrollAction(action)} + <ShelfBasedPageScrollAction + destination={action} + aria-label={withoutLabel ? null : label || action.title} + > + <slot /> + </ShelfBasedPageScrollAction> +{:else} + <slot /> +{/if} diff --git a/src/components/Menu.svelte b/src/components/Menu.svelte new file mode 100644 index 0000000..8221c79 --- /dev/null +++ b/src/components/Menu.svelte @@ -0,0 +1,218 @@ +<script lang="ts" generics="T"> + import { tick } from 'svelte'; + import type { Opt } from '@jet/environment/types/optional'; + import type { MouseEventHandler } from 'svelte/elements'; + import { onDestroy, onMount } from 'svelte'; + import { generateUuid } from '@amp/web-apps-utils/src'; + import { + computePosition, + autoUpdate, + offset, + flip, + shift, + } from '@floating-ui/dom'; + + export let options: T[]; + // Allows the developer the override the floating-ui calculated offset to a fixed number + export let forcedXPosition: number | null = null; + + export let handleShowMenu: () => void = () => {}; + + let isMenuOpen = false; + + /** + * Display the menu + * + * @example + * <script> + * let menu; + * + * function showMenu() { + * menu.show(); + * } + * <\/script> + * + * <Menu bind:this={menu} /> + */ + export async function show() { + if (!menuEl) return; + + isMenuOpen = true; + + // Menu position should be updated *only* after the dialog has been shown + updateMenuPosition(); + + // Focuses the first link in the dropdown after the DOM updates + await tick(); + menuEl.querySelector('a')?.focus(); + + // When the modal is open, track viewport changes and update the menu position + floatingUIAutoUpdatePositionCleanupCallback = autoUpdate( + trigger!, + menuEl!, + updateMenuPosition, + ); + } + + /** + * Close the menu + * + * @example + * <script> + * let menu; + * + * function closeMenu() { + * menu.close(); + * } + * <\/script> + * + * <Menu bind:this={menu} /> + */ + export function close() { + if (!menuEl) return; + + isMenuOpen = false; + cleanUpFloatingUIAutoPosition(); + } + + function toggle() { + if (isMenuOpen) { + close(); + } else { + show(); + handleShowMenu?.(); + } + } + + const menuId = generateUuid(); + + let menuEl: HTMLUListElement | undefined; + let trigger: HTMLButtonElement | undefined; + + function handleKeyUp(event: KeyboardEvent) { + if (event.key === 'Escape') { + close(); + } + } + + /** + * Dismiss the dialog when clicking anywhere with the dialog open + */ + const handleBodyClick: MouseEventHandler<HTMLElement> = (event) => { + const clickedElement = event.target as HTMLElement; + + // Only close the dialog if the click is "outside" of the trigger + // Otherwise, it will be closed immediately + if (!trigger?.contains(clickedElement)) { + close(); + } + }; + + /// MARK: Menu Positioning through `FloatingUI` + + /** + * Update the position of the menu to align it with the trigger + */ + async function updateMenuPosition() { + const { x, y } = await computePosition(trigger!, menuEl!, { + middleware: [ + offset({ + mainAxis: 10, + }), + + flip(), + shift(), + ], + placement: 'bottom-end', + }); + + Object.assign(menuEl!.style, { + left: `${forcedXPosition || x}px`, + top: `${y}px`, + }); + } + + let floatingUIAutoUpdatePositionCleanupCallback: Opt<() => void>; + + /** + * Cleans up the `FloatingUI` auto-update listener, which should only be "active" + * while the menu is open + */ + function cleanUpFloatingUIAutoPosition() { + floatingUIAutoUpdatePositionCleanupCallback?.(); + floatingUIAutoUpdatePositionCleanupCallback = undefined; + } + + onMount(() => { + // Ensures menu is hidden initially + if (menuEl) isMenuOpen = false; + }); + + onDestroy(function () { + cleanUpFloatingUIAutoPosition(); + }); +</script> + +<svelte:body on:keyup={handleKeyUp} on:click={handleBodyClick} /> + +<button + class="menu-trigger" + aria-controls={menuId} + aria-haspopup="menu" + aria-expanded={isMenuOpen} + bind:this={trigger} + on:click={toggle} +> + <slot name="trigger" /> +</button> + +<ul + id={menuId} + hidden={!isMenuOpen} + tabindex="-1" + class="menu-popover focus-visible" + bind:this={menuEl} +> + {#each options as option} + <li class="menu-item" role="presentation"> + <slot name="option" {option} /> + </li> + {/each} +</ul> + +<style> + :root { + --menu-common-padding: 4px 8px; + } + + .menu-trigger { + background-color: var(--menu-trigger-background-color); + border-radius: var(--menu-trigger-border-radius); + font: var(--menu-trigger-font); + padding: var(--menu-trigger-padding, var(--menu-common-padding)); + } + + .menu-popover { + background-color: var(--menu-popover-background-color, var(--pageBg)); + padding: var(--menu-popover-padding, 0); + border: var(--menu-popover-border, none); + border-radius: var( + --menu-popover-border-radius, + var(--global-border-radius-large) + ); + box-shadow: var(--menu-popover-box-shadow, var(--shadow-medium)); + position: absolute; + inset: auto; + z-index: var(--menu-popover-z-index, 2); + } + + .menu-popover::backdrop { + background: var(--menu-popover-backdrop-background, none); + } + + .menu-item { + padding: var(--menu-item-padding, var(--menu-common-padding)); + margin: var(--menu-item-margin, 0); + white-space: nowrap; + } +</style> diff --git a/src/components/MotionArtwork.svelte b/src/components/MotionArtwork.svelte new file mode 100644 index 0000000..646df26 --- /dev/null +++ b/src/components/MotionArtwork.svelte @@ -0,0 +1,152 @@ +<script lang="ts"> + import { createEventDispatcher, onMount, onDestroy } from 'svelte'; + import { loggerFor } from '@amp/web-apps-logger'; + + const logger = loggerFor('components/MotionArtwork'); + + type HLSError = { + type: string; + message: string; + details: string; + fatal: boolean; + handled: boolean; + }; + + type MotionArtworkError = { + type: string; + reason: string; + fatal: boolean; + error?: Error; + }; + + /** HTML `id` attribute for the <video /> element */ + export let id: string; + + /** Source URL for the video, an HLS playlist ending in .m3u8 */ + export let src: string; + + /** Poster image to show while the video is loading */ + export let poster: string | undefined; + + /** If the video should loop from end to start. */ + export let loop: boolean = true; + + /** If the audio should be muted on the video. */ + export let muted: boolean = true; + + /** If the video should be paused when initially loaded. */ + export let paused: boolean = true; + + /** The constructor to use for creating an Hls playback session. */ + export let HLS: Window['Hls'] = window.Hls; + + /** RTCReportingAgent instance for RTC reporting on video playback. */ + export let reportingAgent: any = undefined; + + /** HTMLVideoElement used by HLS.js to render the video */ + export let videoElement: HTMLVideoElement | null = null; + + /** Internal error state for the component */ + let errorState: MotionArtworkError | undefined; + + let hlsSession: Window['Hls'] | undefined; + + /** Dispatcher for errors. */ + const dispatch = createEventDispatcher<{ error: MotionArtworkError }>(); + + function handleError(details: MotionArtworkError) { + logger.error( + `Error playing MotionArtwork with HLS: ${details?.reason}`, + details?.error, + ); + + errorState = { + type: details.type, + reason: details.reason, + fatal: details.fatal, + error: details?.error, + }; + + dispatch('error', errorState); + } + + const hlsSupported = HLS?.isSupported() ?? false; + + onMount(function () { + if (!hlsSupported) { + handleError({ + type: 'runtime', + reason: 'unsupported', + fatal: true, + }); + return; + } + + // Create a new HLS.js playback session + hlsSession = new HLS({ + debug: false, + debugLevel: 'error', + enablePerformanceLogging: false, + nativeControlsEnabled: false, + + appData: { + reportingAgent: reportingAgent, + serviceName: reportingAgent?.ServiceName, + }, + }); + + hlsSession.on( + HLS.Events.ERROR, + function (_event: string, error: HLSError) { + handleError({ + type: 'hls', + reason: error.message, + fatal: error.fatal, + error: error as unknown as Error, + }); + }, + ); + + // Direct HLS.js to the VideoElement to use and start loading the video source + hlsSession.attachMedia(videoElement); + hlsSession.loadSource(src, { + /* HLS.js loading options go here */ + }); + }); + + onDestroy(() => { + // Stop the video, release resources, and destroy the HLS context + hlsSession?.destroy(); + }); +</script> + +{#if errorState !== undefined} + <slot name="error" error={errorState} {poster} /> +{:else} + <!-- svelte-ignore a11y-media-has-caption --> + <video + {id} + {loop} + {poster} + preload="none" + data-loop={true} + playsinline={true} + controls={false} + bind:this={videoElement} + bind:muted + bind:paused + on:play + on:ended + on:loadedmetadata + /> +{/if} + +<style> + video { + width: 100%; + height: 100%; + object-fit: cover; + object-position: center center; + aspect-ratio: var(--aspect-ratio); + } +</style> diff --git a/src/components/Page.svelte b/src/components/Page.svelte new file mode 100644 index 0000000..5b44c06 --- /dev/null +++ b/src/components/Page.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import { + type Page, + hasVisionProUrl, + isAppEventDetailPage, + isArticlePage, + isChartsHubPage, + isGenericPage, + isSearchLandingPage, + isShelfBasedProductPage, + isTopChartsPage, + isTodayPage, + isSearchResultsPage, + isStaticMessagePage, + isSeeAllPage, + isErrorPage, + } from '~/jet/models'; + + import AppEventDetailPage from './pages/AppEventDetailPage.svelte'; + import ArticlePage from './pages/ArticlePage.svelte'; + import ChartsHubPage from './pages/ChartsHubPage.svelte'; + import DefaultPage from './pages/DefaultPage.svelte'; + import ErrorPage from './pages/ErrorPage.svelte'; + import ProductPage from './pages/ProductPage.svelte'; + import VisionProPage from './pages/VisionProPage.svelte'; + import StaticMessagePageComponent from './pages/StaticMessagePage.svelte'; + import SearchLandingPage from './pages/SearchLandingPage.svelte'; + import SearchResultsPage from './pages/SearchResultsPage.svelte'; + import TopChartsPage from './pages/TopChartsPage.svelte'; + import TodayPage from './pages/TodayPage.svelte'; + import SeeAllPage from './pages/SeeAllPage.svelte'; + import MetaTags from '~/components/structure/MetaTags.svelte'; + import PageModal from '~/components/PageModal.svelte'; + + export let page: Page; +</script> + +<MetaTags {page} /> + +<PageModal /> + +{#if isAppEventDetailPage(page)} + <AppEventDetailPage {page} /> +{:else if isArticlePage(page)} + <ArticlePage {page} /> +{:else if isChartsHubPage(page)} + <ChartsHubPage {page} /> +{:else if isSearchLandingPage(page)} + <SearchLandingPage {page} /> +{:else if isSearchResultsPage(page)} + <SearchResultsPage {page} /> +{:else if isShelfBasedProductPage(page)} + <ProductPage {page} /> +{:else if isTopChartsPage(page)} + <TopChartsPage {page} /> +{:else if isGenericPage(page) && hasVisionProUrl(page)} + <VisionProPage {page} /> +{:else if isTodayPage(page)} + <TodayPage {page} /> +{:else if isStaticMessagePage(page)} + <StaticMessagePageComponent {page} /> +{:else if isSeeAllPage(page)} + <SeeAllPage {page} /> +{:else if isErrorPage(page)} + <ErrorPage {page} /> +{:else} + <DefaultPage {page} /> +{/if} diff --git a/src/components/PageModal.svelte b/src/components/PageModal.svelte new file mode 100644 index 0000000..9e5ee50 --- /dev/null +++ b/src/components/PageModal.svelte @@ -0,0 +1,82 @@ +<script lang="ts"> + import { onMount, type SvelteComponent } from 'svelte'; + import type { GenericPage } from '@jet-app/app-store/api/models'; + + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import { getModalPageStore } from '~/stores/modalPage'; + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import { LICENSE_AGREEMENT_MODAL_ID } from '~/utils/metrics'; + + let modalElement: SvelteComponent; + let modalPage = getModalPageStore(); + let page: GenericPage | undefined; + + $: page = $modalPage?.page; + $: shelves = page?.shelves ?? []; + $: title = page?.title ?? null; + $: targetId = + $modalPage?.pageDetail === 'licenseAgreement' + ? LICENSE_AGREEMENT_MODAL_ID + : undefined; + + onMount(() => { + return modalPage.clearPage; + }); + + $: { + if ($modalPage) { + modalElement?.showModal(); + } else { + handleModalClose(); + } + } + + function handleModalClose() { + modalElement?.close(); + modalPage.clearPage(); + } +</script> + +<Modal + modalTriggerElement={null} + bind:this={modalElement} + on:close={handleModalClose} +> + <div class="modal-content"> + {#if page} + <ContentModal + {title} + subtitle={null} + on:close={handleModalClose} + {targetId} + > + <svelte:fragment slot="content"> + {#each shelves as shelf} + <ShelfComponent {shelf}> + <slot + name="marker-shelf" + slot="marker-shelf" + let:shelf + {shelf} + /> + </ShelfComponent> + {/each} + </svelte:fragment> + </ContentModal> + {/if} + </div> +</Modal> + +<style lang="scss"> + .modal-content :global(p) { + user-select: text; + margin-bottom: 15px; + overflow-wrap: break-word; + } + + :global(.noscroll) { + overflow: hidden; + touch-action: none; + } +</style> diff --git a/src/components/PageResolver.svelte b/src/components/PageResolver.svelte new file mode 100644 index 0000000..9f482aa --- /dev/null +++ b/src/components/PageResolver.svelte @@ -0,0 +1,25 @@ +<script lang="ts"> + import type { Page } from '~/jet/models'; + + import PageComponent from '~/components/Page.svelte'; + import ErrorComponent from '~/components/Error.svelte'; + import LoadingSpinner from '@amp/web-app-components/src/components/LoadingSpinner/LoadingSpinner.svelte'; + + export let page: Promise<Page> | Page; + export let isFirstPage: boolean; +</script> + +{#await page} + <div data-testid="page-loading"> + <!-- + Delay showing the spinner on initial page load after app boot. + After that, the FlowAction handler already waits 500ms before + it changes DOM, so we only need to wait 1000ms. + --> + <LoadingSpinner delay={isFirstPage ? 1500 : 1000} /> + </div> +{:then page} + <PageComponent {page} /> +{:catch error} + <ErrorComponent {error} /> +{/await} diff --git a/src/components/ProductPageArcadeBanner.svelte b/src/components/ProductPageArcadeBanner.svelte new file mode 100644 index 0000000..154c115 --- /dev/null +++ b/src/components/ProductPageArcadeBanner.svelte @@ -0,0 +1,188 @@ +<script lang="ts"> + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); +</script> + +<aside> + <div class="arcade-banner"> + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <h2> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')} + <br /> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')} + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')} + </h2> + + <a href="https://www.apple.com/apple-arcade/" target="_blank"> + <span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText', + )} + </span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark', + )} + </a> + </div> + </div> +</aside> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .logo-container { + width: 62px; + margin-bottom: 10px; + line-height: 0; + + @media (--range-xsmall-only) { + width: 48px; + margin-bottom: 8px; + } + } + + .logo-container :global(path) { + color: var(--systemPrimary-onDark); + } + + .metadata-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 60%; + height: 100%; + padding: 0 20px; + + @media (--range-xsmall-only) { + align-items: flex-start; + justify-content: center; + } + } + + h2 { + margin-bottom: 10px; + font: var(--title-1-emphasized); + + @media (--range-xsmall-only) { + margin-bottom: 8px; + font: var(--title-3-emphasized); + } + } + + a { + display: flex; + font: var(--title-3-emphasized); + + @media (--range-xsmall-only) { + font: var(--body-emphasized); + } + } + + a::after { + content: '↗'; + font-weight: normal; + margin-inline-start: 4px; + } + + a:hover { + text-decoration: none; + } + + a:hover span { + text-decoration: underline; + } + + aside { + width: 100%; + max-width: calc(viewport-content-for(xlarge)); + height: 152px; + margin: 0 auto 32px; + padding: 0 var(--bodyGutter); + + @media (--range-xsmall-only) { + max-width: 100%; + padding: 0; + } + } + + .arcade-banner { + width: 100%; + height: 100%; + color: var(--systemPrimary-onDark); + border-radius: var(--global-border-radius-medium); + background: #000; + background-repeat: no-repeat; + background-position: right; + background-size: contain; + + @media (prefers-color-scheme: dark) { + border: 1px solid var(--systemQuaternary-onDark); + } + + @media (--range-xsmall-only) { + border-radius: 0; + background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png'); + background-size: cover; + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png'); + background-position: left; + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png'); + background-position: left; + } + } + } + + @media (--range-small-only) { + background-image: url('/assets/images/arcade/upsell/banner-692@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@1x_RTL.png'); + background-position: left; + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-692@2x_RTL.png'); + background-position: left; + } + } + } + + @media (--range-medium-up) { + background-image: url('/assets/images/arcade/upsell/banner-980@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-980@1x_RTL.png'); + background-position: left; + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/banner-980@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/banner-980@2x_RTL.png'); + background-position: left; + } + } + } + } +</style> diff --git a/src/components/ProductPageArcadeFooter.svelte b/src/components/ProductPageArcadeFooter.svelte new file mode 100644 index 0000000..0cd9b65 --- /dev/null +++ b/src/components/ProductPageArcadeFooter.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); +</script> + +<article> + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <h2> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineOne')} + <br /> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineTwo')} + <br /> + {$i18n.t('ASE.Web.AppStore.Arcade.UpsellFooter.LineThree')} + </h2> + + <a href="https://www.apple.com/apple-arcade/" target="_blank"> + <span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionText', + )} + </span> + {$i18n.t( + 'ASE.Web.AppStore.Arcade.UpsellFooter.CallToActionDisclaimerMark', + )} + </a> + </div> +</article> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .logo-container { + width: 72px; + margin-bottom: 20px; + line-height: 0; + + @media (--range-xsmall-only) { + width: 62px; + margin-bottom: 16px; + } + } + + .logo-container :global(path) { + color: var(--systemPrimary-onDark); + } + + .metadata-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 60%; + height: 100%; + padding: 40px; + + @media (--range-xsmall-only) { + align-items: center; + justify-content: end; + width: unset; + text-align: center; + } + } + + h2 { + margin-bottom: 20px; + font: var(--header-emphasized); + line-height: 54px; + + @media (--range-xsmall-only) { + font: var(--title-1-emphasized); + } + } + + a { + display: flex; + font: var(--title-3-emphasized); + } + + a::after { + content: '↗'; + font-weight: normal; + margin-inline-start: 4px; + } + + a:hover { + text-decoration: none; + } + + a:hover span { + text-decoration: underline; + } + + article { + flex-grow: 1; + width: 100%; + max-width: calc(viewport-content-for(xlarge) - var(--bodyGutter) * 2); + aspect-ratio: 2.55; + margin: 0 auto; + color: var(--systemPrimary-onDark); + background: #000; + background-size: cover; + + @media (--range-xsmall-only) { + max-width: 338px; + aspect-ratio: 35/48; + border-radius: var(--global-border-radius-medium); + background-image: url('/assets/images/arcade/upsell/footer-280@1x.png'); + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/footer-280@2x.png'); + } + } + + @media (--range-small-only) { + aspect-ratio: 173/96; + background-image: url('/assets/images/arcade/upsell/footer-692@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-692@1x_RTL.png'); + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/footer-692@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-692@2x_RTL.png'); + } + } + } + + @media (--range-medium-up) { + background-image: url('/assets/images/arcade/upsell/footer-980@1x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-980@1x_RTL.png'); + } + + @media (resolution >= 192dpi) { + background-image: url('/assets/images/arcade/upsell/footer-980@2x_LTR.png'); + + @include rtl { + background-image: url('/assets/images/arcade/upsell/footer-980@2x_RTL.png'); + } + } + } + + @media (--range-xlarge-up) { + border-radius: var(--global-border-radius-medium); + } + } +</style> diff --git a/src/components/SFSymbol.svelte b/src/components/SFSymbol.svelte new file mode 100644 index 0000000..998ab06 --- /dev/null +++ b/src/components/SFSymbol.svelte @@ -0,0 +1,51 @@ +<!-- +@component +Renders a supported "SF Symbol" from the icons available in `~/sf-symbols` +--> +<script lang="ts" context="module"> + import type { ComponentType } from 'svelte'; + + const iconComponents = import.meta.glob('~/sf-symbols/*.svg', { + eager: true, + import: 'default', + }); + + const iconNameToComponent: Record<string, ComponentType | undefined> = + Object.fromEntries( + Object.entries(iconComponents).map( + ([fullPathToIcon, iconComponent]) => { + const iconName = fullPathToIcon + .replace('/src/sf-symbols/', '') + .replace('.svg', ''); + + return [iconName, iconComponent as ComponentType]; + }, + ), + ); + + /** + * The list of all supported icons + * + * This is exposed only for testing/Storybook purposes + */ + export const __iconNames = Object.keys(iconNameToComponent); + + export function getIconComponentByName(iconName: string) { + return iconNameToComponent[iconName]; + } +</script> + +<script lang="ts"> + /** + * The name of the SF Symbol to render + * + * Must match the name of an `.svg` file in `~/sf-symbols`. If a file with a matching + * name does not exist, nothing will be rendered + */ + export let name: string; + export let ariaHidden: boolean = false; + + $: icon = getIconComponentByName(name); +</script> + +<svelte:component this={icon} aria-hidden={ariaHidden ? 'true' : 'false'} /> diff --git a/src/components/ShareArrowButton.svelte b/src/components/ShareArrowButton.svelte new file mode 100644 index 0000000..7b822fc --- /dev/null +++ b/src/components/ShareArrowButton.svelte @@ -0,0 +1,90 @@ +<script lang="ts" context="module"> + export function isShareSupported() { + return ( + typeof navigator !== 'undefined' && + typeof navigator.share === 'function' + ); + } +</script> + +<script lang="ts"> + import SFSymbol from '~/components/SFSymbol.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let url: string; + export let withLabel: boolean = false; + + const i18n = getI18n(); + + $: isShareSheetOpen = false; + + async function handleShareClick() { + isShareSheetOpen = !isShareSheetOpen; + + try { + await navigator.share({ url }); + isShareSheetOpen = false; + } catch { + isShareSheetOpen = false; + } + } +</script> + +<button + on:click={handleShareClick} + aria-label={$i18n.t('ASE.Web.AppStore.Share.Button.AccessibilityValue')} + class:active={isShareSheetOpen} + class:with-label={withLabel} +> + <SFSymbol name="square.and.arrow.up" ariaHidden={true} /> + + {#if withLabel} + {$i18n.t('ASE.Web.AppStore.Share.Button.Value')} + {/if} +</button> + +<style lang="scss"> + button { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: var(--share-arrow-size, 32px); + height: var(--share-arrow-size, 32px); + border-radius: var(--share-arrow-size, 32px); + background: var(--systemQuaternary-onDark); + transition: background-color 210ms ease-out; + mix-blend-mode: plus-lighter; + } + + button.with-label { + display: flex; + align-items: center; + width: auto; + padding: 0 16px; + gap: 8px; + font: var(--body-emphasized); + + :global(svg) { + height: 16px; + width: auto; + top: -2px; + position: relative; + } + } + + button.active, + button:hover { + // stylelint-disable color-function-notation + background-color: rgb(from var(--systemTertiary-onDark) r g b/.13); + // stylelint-enable color-function-notation + } + + button :global(svg) { + width: 37%; + fill: var(--systemPrimary-onDark); + overflow: visible; + } +</style> diff --git a/src/components/Shelf/Title.svelte b/src/components/Shelf/Title.svelte new file mode 100644 index 0000000..e68f4b1 --- /dev/null +++ b/src/components/Shelf/Title.svelte @@ -0,0 +1,112 @@ +<!-- +@component + +Renders the "Title" and "See All action" for a `Shelf` + +### Supported CSS Variables + +- `--shelf-title-font`: overrides the font used for the "title" element + +--> +<script lang="ts"> + import { type Opt, isSome } from '@jet/environment/types/optional'; + import { type Action, isFlowAction } from '@jet-app/app-store/api/models'; + + import SFSymbol from '~/components/SFSymbol.svelte'; + import LinkWrapper from '../LinkWrapper.svelte'; + + export let title: string; + export let subtitle: Opt<string> = undefined; + export let seeAllAction: Opt<Action> = undefined; +</script> + +<div class="title-action-wrapper" class:with-subtitle={!!subtitle}> + <LinkWrapper action={seeAllAction} label={title}> + <div class="link-contents"> + <h2 class="shelf-title" data-test-id="shelf-title">{title}</h2> + + {#if isSome(seeAllAction) && isFlowAction(seeAllAction)} + <span + class="chevron-container" + data-test-id="shelf-see-all-chevron" + aria-hidden="true" + > + <SFSymbol name="chevron.forward" /> + </span> + {/if} + </div> + </LinkWrapper> +</div> + +{#if subtitle} + <p>{subtitle}</p> +{/if} + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .title-action-wrapper { + display: flex; + align-items: end; + justify-content: space-between; + margin: 0 var(--bodyGutter) 13px; + } + + .title-action-wrapper.with-subtitle { + margin-bottom: 3px; + } + + .title-action-wrapper :global(a:hover) { + text-decoration: none; + } + + p { + font: var(--title-3-tall); + color: var(--systemSecondary); + margin: 0 var(--bodyGutter) 13px; + } + + h2 { + color: var(--systemPrimary, #000); + font: var(--shelf-title-font, var(--title-2-emphasized)); + text-wrap: pretty; + } + + .link-contents { + display: flex; + align-items: center; + gap: 6px; + } + + .chevron-container { + line-height: 0; + padding: 6px 0 4px; + display: block; + } + + .chevron-container :global(svg) { + height: 12px; + display: block; + translate: 0 0; + transition: translate 210ms ease-out; + + @include rtl { + transform: rotate(180deg); + } + } + + .chevron-container :global(svg path:not([fill='none'])) { + fill: var(--systemGray2); + } + + .link-contents:hover .chevron-container :global(svg) { + translate: 1px 0; + + @include rtl { + transform: rotate(180deg); + translate: -1px 0; + } + } +</style> diff --git a/src/components/Shelf/Wrapper.svelte b/src/components/Shelf/Wrapper.svelte new file mode 100644 index 0000000..850b0d0 --- /dev/null +++ b/src/components/Shelf/Wrapper.svelte @@ -0,0 +1,81 @@ +<script lang="ts"> + import type { Shelf } from '@jet-app/app-store/api/models'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + + export let shelf: Shelf | undefined = undefined; + + /** + * Whether or not to automatically display the shelf "centered" at the normal + * page width for the App Store + * + * When `false`, the shelf is not constrained horizontally in any way + * + * The value of this property may be ignored when the shelf's `.presentationHints` + * indicate that it is being rendered in a context where "centering" would not be + * appropriate + * + * @default true + */ + export let centered: boolean = false; + + export let withTopBorder: boolean = false; + export let withTopMargin: boolean = false; + export let withPaddingTop: boolean = true; + export let withBottomPadding: boolean = true; + + $: seeAllAction = + shelf?.header?.titleAction ?? + shelf?.header?.accessoryAction ?? + shelf?.seeAllAction; +</script> + +<section + id={shelf?.id} + data-test-id="shelf-wrapper" + class="shelf" + class:centered + class:border-top={withTopBorder} + class:margin-top={withTopMargin} + class:padding-top={withPaddingTop} + class:padding-bottom={withBottomPadding} +> + {#if $$slots['title']} + <slot name="title" /> + {:else if shelf?.header?.title} + <ShelfTitle + title={shelf.header.title} + subtitle={shelf.header.subtitle} + {seeAllAction} + /> + {:else if shelf?.title} + <ShelfTitle + title={shelf.title} + subtitle={shelf.subtitle} + {seeAllAction} + /> + {/if} + + <slot /> +</section> + +<style> + .padding-top { + padding-top: 13px; + } + + .centered { + margin: 0 var(--bodyGutter); + } + + .margin-top { + margin-top: 13px; + } + + .border-top { + border-top: 1px solid var(--systemGray4); + } + + .shelf.padding-bottom { + padding-bottom: 32px; + } +</style> diff --git a/src/components/ShelfItemLayout.svelte b/src/components/ShelfItemLayout.svelte new file mode 100644 index 0000000..ef1d07c --- /dev/null +++ b/src/components/ShelfItemLayout.svelte @@ -0,0 +1,103 @@ +<!-- +@component +Renders a set of `Shelf` items in either a horizontal shelf +or a grid, depending on the `shelf` configuration + +Note: when configuring the `gridType` property, a single value will be used +for both the shelf-based or grid-based item layouts. If two different grid types +are needed instead, `gridTypeForShelf` and `gridTypeForGrid` are needed instead; +these properties cannot be used alongside the general-purpose `gridType`. +--> +<script lang="ts" generics="Item"> + import type { Shelf } from '@jet-app/app-store/api/models'; + + import type { GridType } from '@amp/web-app-components/src/components/Shelf/types'; + + import type { XOR } from '~/utils/types'; + import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte'; + import Grid from '~/components/Grid.svelte'; + + /** + * The sub-set of {@linkcode Shelf} that is necesary to render this component + */ + interface RequiredShelf + extends Pick<Shelf, 'rowsPerColumn' | 'isHorizontal'> { + items: Item[]; + } + + interface $$Slots { + default: { + item: Item; + }; + } + + /** + * Represents the `gridType` properties of this component + * + * Either a `gridType` that will be used for both the shelf or grid + * layouts can be provided, OR specific properties for the grid type + * for the shelf and grid respectively; this `XOR` here prevents + * these approachs from being mixed-and-matched. + */ + type GeneralOrIndividualGridType = XOR< + { + gridType: GridType; + }, + { + gridTypeForGrid: GridType; + gridTypeForShelf: GridType; + } + >; + + type $$Props = GeneralOrIndividualGridType & { + shelf: RequiredShelf; + rowsPerColumnOverride?: number | null; + }; + + /** + * The shelf to render items for + */ + export let shelf: RequiredShelf; + + /** + * An optional override of the shelfs `rowsPerColumn` property + */ + export let rowsPerColumnOverride: number | null = null; + + /** + * Determine the grid type configuration for the shelf or grid layouts + * based on the mutually-exclusive properties of {@linkcode GeneralOrIndividualGridType} + */ + function extractGridTypes(props: $$Props) { + if (typeof props.gridType === 'string') { + return { + gridTypeForShelf: props.gridType, + gridTypeForGrid: props.gridType, + }; + } else { + return props; + } + } + + $: ({ gridTypeForShelf, gridTypeForGrid } = extractGridTypes( + $$props as $$Props, + )); + + $: isHorizontal = shelf.isHorizontal; + $: gridRows = rowsPerColumnOverride ?? shelf.rowsPerColumn ?? undefined; +</script> + +{#if isHorizontal} + <HorizontalShelf + items={shelf.items} + {gridRows} + gridType={gridTypeForShelf} + let:item + > + <slot {item} /> + </HorizontalShelf> +{:else} + <Grid items={shelf.items} gridType={gridTypeForGrid} let:item> + <slot {item} /> + </Grid> +{/if} diff --git a/src/components/StarRating.svelte b/src/components/StarRating.svelte new file mode 100644 index 0000000..84da44b --- /dev/null +++ b/src/components/StarRating.svelte @@ -0,0 +1,80 @@ +<script lang="ts" context="module"> + export function calculateStarFillPercentages(rating: number) { + return [1, 2, 3, 4, 5].map((position) => { + if (position <= Math.floor(rating)) { + return 100; + } + + if (position > Math.ceil(rating)) { + return 0; + } + + return Math.round((rating % 1) * 100); + }); + } +</script> + +<script lang="ts"> + import StarFilledIcon from '@amp/web-app-components/assets/icons/star-filled.svg'; + import StarHollowIcon from '@amp/web-app-components/assets/icons/star-hollow.svg'; + import { getI18n } from '~/stores/i18n'; + + export let rating: number; + + const i18n = getI18n(); + + $: starFillPercentages = calculateStarFillPercentages(rating); + $: label = $i18n.t('ASE.Web.AppStore.Review.StarsAria', { + count: rating, + }); +</script> + +<ol class="stars" aria-label={label}> + {#each starFillPercentages as fillPercent} + <li class="star"> + {#if fillPercent === 100} + <StarFilledIcon /> + {:else if fillPercent === 0} + <StarHollowIcon /> + {:else} + <div + class="partial-star" + style:--partial-star-width={`${fillPercent}%`} + > + <StarFilledIcon /> + </div> + + <StarHollowIcon /> + {/if} + </li> + {/each} +</ol> + +<style> + .stars { + display: flex; + } + + .star { + position: relative; + margin-inline-end: 2px; + line-height: 0; + } + + .star :global(svg) { + height: var(--star-size, 10px); + width: var(--star-size, 10px); + fill: var(--fill-color, rgb(127, 127, 127)); + } + + .partial-star { + position: absolute; + overflow: hidden; + width: var(--partial-star-width); + fill: var(--fill-color, rgb(127, 127, 127)); + } + + .partial-star :global(path) { + stroke: transparent; + } +</style> diff --git a/src/components/SystemImage.svelte b/src/components/SystemImage.svelte new file mode 100644 index 0000000..40723dd --- /dev/null +++ b/src/components/SystemImage.svelte @@ -0,0 +1,52 @@ +<!-- +@component +Renders an `Artwork` view model that references an SF Symbol through a `systemimage://` or `resource://` template URL +--> +<script lang="ts" context="module"> + import type { Artwork } from '@jet-app/app-store/api/models'; + + const systemImagePrefix = 'systemimage://'; + const resourcePrefix = 'resource://'; + + type SystemImageTemplate = `${typeof systemImagePrefix}${string}`; + type ResourceTemplate = `${typeof resourcePrefix}${string}`; + + /** + * An {@linkcode Artwork} that references a system image + */ + interface FullSystemImageArtwork extends Artwork { + template: SystemImageTemplate | ResourceTemplate; + } + + /** + * The sub-set of {@linkcode FullSystemImageArtwork} required to render + * the icon + */ + type SystemImageArtwork = Pick<FullSystemImageArtwork, 'template'>; + + /** + * Determine if some {@linkcode Artwork} represents a "system image" + */ + export function isSystemImageArtwork( + artwork: Artwork, + ): artwork is FullSystemImageArtwork { + return ( + artwork.template.startsWith(systemImagePrefix) || + artwork.template.startsWith(resourcePrefix) + ); + } + + export function getIconNameFromTemplate(template: string) { + return new URL(template).host; + } +</script> + +<script lang="ts"> + import SFSymbol from '~/components/SFSymbol.svelte'; + + export let artwork: SystemImageArtwork; + + $: name = getIconNameFromTemplate(artwork.template); +</script> + +<SFSymbol {name} /> diff --git a/src/components/VideoPlayer.svelte b/src/components/VideoPlayer.svelte new file mode 100644 index 0000000..8012b9f --- /dev/null +++ b/src/components/VideoPlayer.svelte @@ -0,0 +1,412 @@ +<script lang="ts"> + import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer'; + import MotionArtwork from '~/components/MotionArtwork.svelte'; + import { getJet } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + import type { Video } from '@jet-app/app-store/api/models'; + import { + MetricsActionDetails, + MetricsActionType, + type MetricsActionDetailItem, + type MetricsActionTypeItem, + } from '~/constants/media-metrics'; + + /** HTML `id` attribute for the <video /> element */ + export let id: string; + + /** Source URL for the video, an HLS playlist ending in .m3u8 */ + export let src: string; + + /** Poster image to show while the video is loading */ + export let poster: string | undefined; + + /** If the video should play automatically when in view */ + export let autoplay: boolean = false; + + /* The whole-number percentage amount of the video needs to be in view before autoplay kicks in */ + export let autoplayVisibilityThreshold: number = 0; + + /** If the video should loop from end to start. */ + export let loop: boolean = false; + + /** If the audio should be muted on the video. */ + export let muted: boolean = true; + + /** If our controls should be shown in the video player. */ + export let useControls: boolean = true; + + /** The constructor to use for creating an Hls playback session. */ + export let HLS: Window['Hls'] = window.Hls; + + /** + * If we should bypass the `poster` attribute on the `video` tag, in favor of having the poster + * image overlaid as it's own DOM element, which covers an HLS playback bug in Safari, wherein + * the video is seeked to the first frame once the metadata is loaded, thus removing the poster. + */ + export let shouldSuperimposePosterImage: boolean = false; + + /** an optional metric template provided by jet */ + export let metricsTemplate: + | Record<string, unknown> + | Video['templateMediaEvent'] = {}; + + export function play(isAutoPlay = true) { + videoRef?.play(); + recordMediaEvent( + MetricsActionType.PLAY, + isAutoPlay + ? MetricsActionDetails.AUTOPLAY + : MetricsActionDetails.PLAY, + ); + } + + export function pause(isAutoPause = true) { + recordMediaEvent( + MetricsActionType.STOP, + isAutoPause + ? MetricsActionDetails.AUTOPAUSE + : MetricsActionDetails.PAUSE, + ); + + videoRef?.pause(); + } + + let isPaused: boolean = !autoplay; + let isMuted: boolean = muted; + let shouldShowReplayControl: boolean = false; + let shouldShowPlaybackControls: boolean = true; + let hasPlaybackBeenInitiated: boolean = false; + let videoRef: HTMLVideoElement | null = null; + + const i18n = getI18n(); + const jet = getJet(); + + const handleFullScreenButtonClick = () => { + videoRef?.requestFullscreen(); + }; + + const handleReplayButtonClick = () => { + if (videoRef) { + videoRef.currentTime = 0; + videoRef.play(); + shouldShowPlaybackControls = true; + } + }; + + const handlePlayButtonClick = () => { + if (isPaused) { + play(false); + } else { + pause(false); + } + }; + + const handleMuteButtonClick = () => { + isMuted = !isMuted; + }; + + const handleVideoEnded = () => { + if (!loop) { + shouldShowPlaybackControls = true; + + if (videoRef) { + videoRef.currentTime = 1; + videoRef.pause(); + } + + recordMediaEvent( + MetricsActionType.STOP, + MetricsActionDetails.COMPLETE, + ); + } + }; + + const handleVideoPlay = () => { + // Display the replay button after the first play + shouldShowReplayControl = true; + hasPlaybackBeenInitiated = true; + }; + + // metric events that are waiting for loadMetadata from video element + let queuedMetricEvents: Array<() => void> = []; + + // flush any metric events once load metadata has been called + const flushMetricEvents = () => { + queuedMetricEvents.forEach((recordFn) => recordFn()); + + queuedMetricEvents = []; + }; + + const recordMediaEvent = ( + actionType: MetricsActionTypeItem, + actionDetail: MetricsActionDetailItem, + ) => { + if (!metricsTemplate?.fields) { + return; + } + + const recordEvent = () => { + const duration = Math.floor(videoRef?.duration ?? 0) * 1000; + const position = Math.min( + Math.floor((videoRef?.currentTime ?? 0) * 1000), + duration, + ); + jet.recordCustomMetricsEvent({ + ...(metricsTemplate?.fields ?? {}), + actionType: actionType, + actionDetails: actionDetail, + url: src, + duration, + position, + topic: metricsTemplate?.topic ?? '', + }); + }; + + if (Number.isNaN(videoRef?.duration)) { + queuedMetricEvents.push(() => recordEvent()); + } else { + recordEvent(); + } + }; + + const isVideoPlaying = (video: HTMLVideoElement | null) => { + if (!video) { + return false; + } + return !!( + video.currentTime > 0 && + !video.paused && + !video.ended && + video.readyState > 2 + ); + }; + + const intersectionObserverConfig = { + threshold: autoplayVisibilityThreshold, + callback: (isIntersectingViewport: boolean) => { + if (isIntersectingViewport) { + play(); + } else if (isVideoPlaying(videoRef)) { + pause(); + } + }, + }; +</script> + +<div + class="video-container" + use:intersectionObserver={autoplay ? intersectionObserverConfig : undefined} +> + <div class="video"> + <MotionArtwork + {id} + {HLS} + {src} + {loop} + poster={!shouldSuperimposePosterImage ? poster : undefined} + bind:muted={isMuted} + bind:paused={isPaused} + bind:videoElement={videoRef} + on:play={handleVideoPlay} + on:ended={handleVideoEnded} + on:loadedmetadata={flushMetricEvents} + /> + </div> + + {#if shouldSuperimposePosterImage && !hasPlaybackBeenInitiated} + <img + src={poster} + class="fake-poster" + aria-hidden="true" + loading="lazy" + alt="" + /> + {/if} + + {#if useControls} + <div class="video-control"> + {#if shouldShowReplayControl} + <button + class="video-control-replay" + aria-label={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Replay', + )} + on:click={handleReplayButtonClick} + > + <img + class="btn-img" + src="/assets/images/video-control/video-control-replay.png" + alt={$i18n.t('ASE.Web.AppStore.VideoPlayer.AX.Replay')} + aria-hidden="true" + /> + </button> + {/if} + + {#if shouldShowPlaybackControls} + <div class="video-control-playback"> + <button + class="video-control-play" + aria-label={$i18n.t( + isPaused + ? 'ASE.Web.AppStore.VideoPlayer.AX.Play' + : 'ASE.Web.AppStore.VideoPlayer.AX.Pause', + )} + on:click={handlePlayButtonClick} + > + {#if isPaused} + <img + class="btn-img" + src="/assets/images/video-control/video-control-play.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Play', + )} + aria-hidden="true" + /> + {:else} + <img + class="btn-img" + src="/assets/images/video-control/video-control-pause.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Pause', + )} + aria-hidden="true" + /> + {/if} + </button> + + <button + class="video-control-unmute" + aria-label={$i18n.t( + isMuted + ? 'ASE.Web.AppStore.VideoPlayer.AX.Unmute' + : 'ASE.Web.AppStore.VideoPlayer.AX.Mute', + )} + on:click={handleMuteButtonClick} + > + {#if isMuted} + <img + class="btn-img" + src="/assets/images/video-control/video-control-volume-muted.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Mute', + )} + aria-hidden="true" + /> + {:else} + <img + class="btn-img" + src="/assets/images/video-control/video-control-volume.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Unmute', + )} + aria-hidden="true" + /> + {/if} + </button> + + <button + class="video-control-fullscreen" + aria-label={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen', + )} + on:click={handleFullScreenButtonClick} + > + <img + class="btn-img" + src="/assets/images/video-control/video-control-fullscreen.png" + alt={$i18n.t( + 'ASE.Web.AppStore.VideoPlayer.AX.Fullscreen', + )} + aria-hidden="true" + /> + </button> + </div> + {/if} + </div> + {/if} +</div> + +<style> + .video-container { + --button-size: 32px; + display: grid; + position: relative; + container-type: inline-size; + container-name: video-container; + width: 100%; + height: 100%; + background-color: var(--systemQuaternary); + } + + .video { + width: 100%; + height: 100%; + grid-column: 1; + grid-row: 1; + line-height: 0; + } + + .video-control { + grid-column: 1; + grid-row: 1; + display: inline-flex; + justify-content: space-between; + z-index: 1; + align-self: end; + color: white; + margin: 0 12px 12px; + } + + .video-control::after { + position: absolute; + content: ''; + z-index: -1; + bottom: 0; + left: 0; + display: block; + box-sizing: border-box; + width: 100%; + height: calc(var(--button-size) * 2); + background: linear-gradient( + 0deg, + rgb(0, 0, 0, 0.68), + rgb(0, 0, 0, 0.2), + transparent + ); + mask-image: linear-gradient(360deg, #000 47%, transparent); + } + + .video-control-playback { + display: inline-flex; + margin-inline-start: auto; + gap: 6px; + } + + .btn-img { + height: var(--button-size); + width: var(--button-size); + border-radius: 50%; + border: 1px solid var(--systemQuaternary-onDark); + background: rgba(0, 0, 0, 0.11); + backdrop-filter: blur(20px); + object-fit: cover; + transition: background 105ms ease-out; + } + + .btn-img:hover { + background: rgba(0, 0, 0, 0.05); + } + + @container video-container (max-width: 500px) { + .btn-img { + --button-size: 24px; + } + } + + .fake-poster { + width: 100%; + position: absolute; + top: 0; + left: 0; + } +</style> diff --git a/src/components/decorators/HlsJSDecorator.svelte b/src/components/decorators/HlsJSDecorator.svelte new file mode 100644 index 0000000..591cb0d --- /dev/null +++ b/src/components/decorators/HlsJSDecorator.svelte @@ -0,0 +1,67 @@ +<script lang="ts" context="module"> + // This store is used to keep track of in-flight requests, ensuring that we don't attempt + // to load the same src (which is stored in the Map key) multiple times. + const inFlightRequests = new Map<string, Promise<void>>(); +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import { generateHLSJSURL } from '~/config/hlsjs'; + import { generateRTCJSURL } from '~/config/rtcjs'; + + export let version: string | undefined = undefined; + + let hlsjsSourceURL = generateHLSJSURL(version).toString(); + let rtcjsSourceURL = generateRTCJSURL(version).toString(); + + function loadScript(src: string): Promise<void> { + // If we have an in-flight request for this `src`, return it. + const inFlightRequest = inFlightRequests.get(src); + if (inFlightRequest) { + return inFlightRequest; + } + + const promise = new Promise<void>(function (resolve, reject) { + const scriptElement = document.createElement('script'); + scriptElement.src = src; + scriptElement.onload = () => resolve(); + scriptElement.onerror = () => { + // If a script fails to load due to a network blip, we remove it from the store, + // so that the next attempt in an `onMount` will try to load the `src` again. + inFlightRequests.delete(src); + reject(); + }; + + document.head.appendChild(scriptElement); + }); + + // Add the given `src` to the store so we can keep track of in-flight requests. + inFlightRequests.set(src, promise); + + return promise; + } + + let loading: Promise<[void, void]> | undefined; + + onMount(() => { + loading = Promise.all([ + window.Hls ?? loadScript(hlsjsSourceURL), + window.rtc ?? loadScript(rtcjsSourceURL), + ]); + }); +</script> + +{#if loading} + {#await loading} + <slot name="loading-component" /> + {:then} + <slot HLS={window.Hls} RTC={window.rtc} /> + {:catch} + <div> + <p> + Failed to load HLS.js {version} from + <a href={hlsjsSourceURL}>{hlsjsSourceURL}</a> + </p> + </div> + {/await} +{/if} diff --git a/src/components/hero/AppLockupDetail.svelte b/src/components/hero/AppLockupDetail.svelte new file mode 100644 index 0000000..e4abe47 --- /dev/null +++ b/src/components/hero/AppLockupDetail.svelte @@ -0,0 +1,109 @@ +<!-- +@component +Component for rendering App information into the `details` slot +of the `Hero.svelte` component +--> +<script lang="ts"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + + import { getI18n } from '~/stores/i18n'; + import AppIcon from '~/components/AppIcon.svelte'; + + const i18n = getI18n(); + + export let lockup: Lockup; + export let isOnDarkBackground: boolean = true; +</script> + +<div class="lockup-container"> + {#if lockup.icon} + <div class="app-icon-container"> + <AppIcon icon={lockup.icon} profile="app-icon-small" /> + </div> + {/if} + + <div class="text-container"> + {#if lockup.heading} + <LineClamp clamp={1}> + <h4>{lockup.heading}</h4> + </LineClamp> + {/if} + + {#if lockup.title} + <LineClamp clamp={2}> + <h3>{lockup.title}</h3> + </LineClamp> + {/if} + + {#if lockup.subtitle} + <LineClamp clamp={1}> + <p>{lockup.subtitle}</p> + </LineClamp> + {/if} + </div> + + <div class="button-container"> + <span + class="get-button" + class:transparent={isOnDarkBackground} + class:dark-gray={!isOnDarkBackground} + > + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> +</div> + +<style lang="scss"> + .lockup-container { + display: flex; + align-items: center; + width: 100%; + max-width: 350px; + margin-top: 20px; + padding-top: 20px; + color: var(--hero-primary-color, var(--systemPrimary-onDark)); + border-top: 1px solid + var(--hero-divider-color, var(--systemQuaternary-onDark)); + + @media (--range-xsmall-down) { + text-align: left; + padding: 20px 0 10px; + max-width: unset; + } + } + + .app-icon-container { + flex-shrink: 0; + width: 64px; + margin-inline-end: 16px; + } + + .text-container { + width: 100%; + margin-inline-end: 16px; + } + + h3 { + font: var(--title-3-emphasized); + text-wrap: pretty; + } + + h4 { + color: var(--hero-secondary-color, var(--systemSecondary-onDark)); + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: var(--hero-text-blend-mode, plus-lighter); + } + + p { + mix-blend-mode: var(--hero-text-blend-mode, plus-lighter); + } + + .button-container { + --get-button-font: var(--title-3-bold); + position: relative; + z-index: 1; + } +</style> diff --git a/src/components/hero/Carousel.svelte b/src/components/hero/Carousel.svelte new file mode 100644 index 0000000..218813b --- /dev/null +++ b/src/components/hero/Carousel.svelte @@ -0,0 +1,132 @@ +<!-- +@component +Component for rendering a carousel of `Hero.svelte` components in a way taht's decoupled from +any particular data model +--> +<script lang="ts" generics="Item"> + import type { Opt } from '@jet/environment/types/optional'; + import type { Artwork, Shelf } from '@jet-app/app-store/api/models'; + + import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { intersectionObserver } from '@amp/web-app-components/src/actions/intersection-observer'; + import mediaQueries from '~/utils/media-queries'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + import HeroCarouselBackgroundPortal, { + id as portalId, + } from '~/components/hero/CarouselBackgroundPortal.svelte'; + import AmbientBackgroundArtwork from '~/components/AmbientBackgroundArtwork.svelte'; + import portal from '~/utils/portal'; + import { carouselMediaStyle } from '~/stores/carousel-media-style'; + + interface $$Slots { + default: { + /** + * The `Item` to render as a `Hero` in the carousel + */ + item: Item; + }; + } + + /** + * The shelf being rendered + * + * Used to derrive any shelf-specific presentation + */ + export let shelf: Shelf; + + /** + * The items to render in the hero carousel + * + * This is decoupled from `shelf` to avoid assuming that `shelf.items` is, itself, + * the set of items that we need to present; some shelves model their items as chilren + * of the first shelf item. + */ + export let items: Item[]; + + /** + * Callback that determines the "background artwork" to use behind the + * active `Hero` for the given `Item` + */ + export let deriveBackgroundArtworkFromItem: (item: Item) => Opt<Artwork>; + + $: gridRows = shelf.rowsPerColumn ?? undefined; + $: isXSmallViewport = $mediaQueries === 'xsmall'; + + let activeIndex: number | undefined = 0; + + function createIntersectionObserverCallback(index: number) { + return (isIntersectingViewport: boolean) => { + if (isIntersectingViewport) { + // Many different types of `item`s can be rendered in this Carousel, and all those + // different items have different ways of determining whether or not the background + // is dark or light, so we are running through all the options here. + const { style, mediaOverlayStyle, isMediaDark } = items[ + index + ] as any; + const fallbackStyle = 'dark'; + let derivedStyle; + + if (typeof isMediaDark !== 'undefined') { + derivedStyle = isMediaDark ? 'dark' : 'light'; + } + + carouselMediaStyle.set( + style || mediaOverlayStyle || derivedStyle || fallbackStyle, + ); + + activeIndex = index; + } + }; + } +</script> + +<HeroCarouselBackgroundPortal /> + +<ShelfWrapper {shelf} --shelfGridGutterWidth="0"> + <HorizontalShelf + {gridRows} + {items} + --shelfScrollPaddingInline="0" + --grid-max-content-xsmall={!$sidebarIsHidden + ? 'calc(100% + 50px)' + : '100vw'} + gridType="Spotlight" + let:item + let:index + > + {#if isXSmallViewport} + <div + use:intersectionObserver={{ + callback: createIntersectionObserverCallback(index), + threshold: 0.5, + }} + > + <slot {item} /> + </div> + {:else} + <div + use:intersectionObserver={{ + callback: createIntersectionObserverCallback(index), + threshold: 0, + }} + > + {#if !import.meta.env.SSR} + {@const backgroundArtwork = + deriveBackgroundArtworkFromItem(item)} + + {#if backgroundArtwork} + <div use:portal={portalId}> + <AmbientBackgroundArtwork + artwork={backgroundArtwork} + active={activeIndex === index} + /> + </div> + {/if} + {/if} + + <slot {item} /> + </div> + {/if} + </HorizontalShelf> +</ShelfWrapper> diff --git a/src/components/hero/CarouselBackgroundPortal.svelte b/src/components/hero/CarouselBackgroundPortal.svelte new file mode 100644 index 0000000..4580ce0 --- /dev/null +++ b/src/components/hero/CarouselBackgroundPortal.svelte @@ -0,0 +1,17 @@ +<script lang="ts" context="module"> + export const id = 'hero-carousel-shelf-background-portal'; +</script> + +<div {id} /> + +<style> + #hero-carousel-shelf-background-portal { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow-x: hidden; + z-index: -1; + } +</style> diff --git a/src/components/hero/Hero.svelte b/src/components/hero/Hero.svelte new file mode 100644 index 0000000..f643ffa --- /dev/null +++ b/src/components/hero/Hero.svelte @@ -0,0 +1,536 @@ +<!-- +@component +Component for rendering an item in a "Hero Carousel" without coupling to any specific data model +--> +<script lang="ts"> + import type { Opt } from '@jet/environment/types/optional'; + import type { + Action, + Artwork as ArtworkModel, + Color, + Video as VideoModel, + } from '@jet-app/app-store/api/models'; + + import mediaQueries from '~/utils/media-queries'; + import { prefersReducedMotion } from '@amp/web-app-components/src/stores/prefers-reduced-motion'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + import { + colorAsString, + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + + /** + * The main text for the carousel item + */ + export let title: Opt<string> = undefined; + + /** + * Additional text above the title. + * Note: If a slot is defined with the name `eyebrow`, the slot takes precedence. + */ + export let eyebrow: Opt<string> = undefined; + + /** + * Additional text below the title + */ + export let subtitle: Opt<string> = undefined; + + /** + * Primary accent color for the carousel item + */ + export let backgroundColor: Opt<Color> = undefined; + + /** + * Static artwork to display in the carousel item + */ + export let artwork: Opt<ArtworkModel> = undefined; + + /** + * Video to display in the carousel item + * + * Takes precedence over `artwork` + */ + export let video: Opt<VideoModel> = undefined; + + /** + * Action to perform when clicking on the carousel item + */ + export let action: Opt<Action> = undefined; + + /** + * Whether the artwork should be aligned to the end (e.g. the right edge in LTR) of the container + */ + export let pinArtworkToHorizontalEnd: boolean = false; + + /** + * Whether the artwork should be pinned to the vertical middle of the container (it's pinned to the top by default) + */ + export let pinArtworkToVerticalMiddle: boolean = false; + + /** + * Whether the text (e.g. title, description, etc) should be pinned to the top of the container + */ + export let pinTextToVerticalStart: boolean = false; + + /** + * Allows for the absolute overriding of the profile used for the Hero artwork + */ + export let profileOverride: Opt<NamedProfile> = null; + + export let isMediaDark: boolean = true; + + export let collectionIcons: ArtworkModel[] | undefined = undefined; + + let isPortraitLayout: boolean; + let profile: NamedProfile; + let collectionIconsBackgroundGradientCssVars: string | undefined = + undefined; + + $: isPortraitLayout = $mediaQueries === 'xsmall'; + + $: { + if (profileOverride) { + profile = profileOverride; + } else if (isPortraitLayout) { + profile = 'large-hero-portrait'; + } else if (pinArtworkToHorizontalEnd && isRtl()) { + profile = 'large-hero-east'; + } else if (pinArtworkToHorizontalEnd) { + profile = 'large-hero-west'; + } else { + profile = 'large-hero'; + } + } + + const color: string = backgroundColor + ? colorAsString(backgroundColor) + : '#000'; + + if (collectionIcons && collectionIcons.length > 1) { + // If there are multiple app icons, we build a string of CSS variables from the icons + // background colors to fill as many of the lockups quadrants as possible. + collectionIconsBackgroundGradientCssVars = + getBackgroundGradientCSSVarsFromArtworks(collectionIcons, { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + shouldRemoveGreys: true, + }); + } +</script> + +<LinkWrapper {action} includeExternalLinkArrowIcon={false}> + <article + data-test-id="hero" + class:with-dark-media={isMediaDark} + class:with-collection-icons={!artwork && !video && collectionIcons} + class:text-pinned-to-vertical-start={pinTextToVerticalStart} + > + {#if video || artwork} + <div + class={`image-container ${profile}`} + class:pinned-to-horizontal-end={pinArtworkToHorizontalEnd} + class:pinned-to-vertical-middle={pinArtworkToVerticalMiddle} + style:--color={color} + > + {#if video && !$prefersReducedMotion} + <Video + loop + autoplay + useControls={false} + {video} + {profile} + /> + {:else if artwork} + <Artwork + {artwork} + {profile} + noShelfChevronAnchor={true} + useCropCodeFromArtwork={false} + withoutBorder={true} + /> + {/if} + </div> + {:else if collectionIcons} + <ul class="app-icons"> + {#each collectionIcons?.slice(0, 5) as collectionIcon} + <li class="app-icon-container"> + <AppIcon + icon={collectionIcon} + profile="app-icon-large" + fixedWidth={false} + /> + </li> + {/each} + </ul> + + <div + class="collection-icons-background-gradient" + style={collectionIconsBackgroundGradientCssVars} + /> + {/if} + + <div class="gradient" style="--color: {color};" /> + + <slot name="badge" {isPortraitLayout} /> + + <div class="metadata-container"> + {#if $$slots.eyebrow} + <h3><slot name="eyebrow" /></h3> + {:else if eyebrow} + <h3>{eyebrow}</h3> + {/if} + + {#if title} + <h2>{@html sanitizeHtml(title)}</h2> + {/if} + + {#if subtitle} + <p class="subtitle">{@html sanitizeHtml(subtitle)}</p> + {/if} + + <slot name="details" {isPortraitLayout} /> + </div> + </article> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + --hero-primary-color: var(--systemPrimary-onLight); + --hero-secondary-color: var(--systemSecondary-onLight); + --hero-text-blend-mode: normal; + --hero-divider-color: var(--systemQuaternary-onLight); + position: relative; + display: flex; + overflow: hidden; + align-items: end; + aspect-ratio: 3 / 4; + container-name: hero-container; + container-type: size; + + @media (--range-small-up) { + aspect-ratio: 16 / 9; + width: 100%; + height: auto; + min-height: 360px; + max-height: min(60vh, 770px); + border-radius: var(--global-border-radius-large); + border: 1px solid var(--systemQuaternary); + } + } + + article.with-dark-media, + article.with-collection-icons { + --hero-primary-color: var(--systemPrimary-onDark); + --hero-secondary-color: var(--systemSecondary-onDark); + --hero-divider-color: var(--systemQuaternary-onDark); + --hero-text-blend-mode: plus-lighter; + } + + .image-container { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-color: var(--color); + } + + .image-container.pinned-to-vertical-middle { + display: flex; + align-items: center; + } + + .image-container.pinned-to-vertical-middle :global(.video-container), + .image-container.pinned-to-vertical-middle :global(.artwork-component) { + width: 100%; + height: auto; + } + + .image-container.pinned-to-horizontal-end :global(.artwork-component) { + height: 100%; + display: flex; + } + + .image-container.pinned-to-horizontal-end :global(.artwork-component img) { + height: 100%; + width: auto; + position: absolute; + inset-inline-end: 0; + + @container hero-container (aspect-ratio >= 279/100) { + width: 100%; + height: auto; + } + } + + .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl + :global(.artwork-component img) { + inset-inline-start: 0; + } + + // This is terrible but essentially the `large-hero-story-card` profile has an aspect ratio of + // 2.25:1, so whenever the image container gets expanded past that aspect ratio, we make the + // artwork full-width rather than full-height. This should eventually be fixed when Editorial + // can prescribe us only 16x9 (1.77:1) hero images. + .image-container.pinned-to-horizontal-end.large-hero-story-card, + .image-container.pinned-to-horizontal-end.large-hero-story-card-rtl { + @container hero-container (aspect-ratio >= 225/100) { + :global(.artwork-component img) { + width: 100%; + height: auto; + } + } + } + + .metadata-container { + position: absolute; + width: 40%; + padding-bottom: 40px; + padding-inline-start: 40px; + text-wrap: pretty; + color: var(--hero-primary-color); + + @media (--range-small-only) { + width: 50%; + padding: 0 20px 20px; + } + + @media (--range-xsmall-down) { + width: 100%; + padding: 0 20px 20px; + text-align: center; + } + } + + .text-pinned-to-vertical-start .metadata-container { + @media (--range-small-only) { + top: 20px; + } + + @media (--range-medium-up) { + top: 40px; + } + } + + h2 { + position: relative; + z-index: 1; + text-wrap: balance; + font: var(--header-emphasized); + + @media (--range-xsmall-down) { + font: var(--title-1-emphasized); + } + } + + @container hero-container (height < 420px) { + h2 { + font: var(--large-title-emphasized); + } + } + + h3 { + margin-bottom: 8px; + position: relative; + z-index: 1; + color: var(--hero-secondary-color); + font: var(--callout-emphasized-tall); + mix-blend-mode: var(--hero-text-blend-mode); + + @media (--range-xsmall-down) { + margin-bottom: 4px; + } + } + + p { + mix-blend-mode: var(--hero-text-blend-mode); + } + + .subtitle { + margin-top: 8px; + position: relative; + z-index: 1; + font: var(--body-tall); + color: var(--hero-secondary-color); + } + + .gradient { + --rotation: 55deg; + + &:dir(rtl) { + --rotation: -55deg; + mask-image: radial-gradient( + ellipse 127% 130% at 95% 100%, + rgb(0, 0, 0) 18%, + rgb(0, 0, 0.33) 24%, + rgba(0, 0, 0, 0.66) 32%, + transparent 40% + ), + linear-gradient( + -129deg, + rgb(0, 0, 0) 0%, + rgba(255, 255, 255, 0) 55% + ); + } + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + // stylelint-disable color-function-notation + background: linear-gradient( + var(--rotation), + rgb(from var(--color) r g b / 0.25) 0%, + transparent 50% + ); + // stylelint-enable color-function-notation + filter: saturate(1.5) brightness(0.9); + backdrop-filter: blur(40px); + mask-image: radial-gradient( + ellipse 127% 130% at 5% 100%, + rgb(0, 0, 0) 18%, + rgb(0, 0, 0.33) 24%, + rgba(0, 0, 0, 0.66) 32%, + transparent 40% + ), + linear-gradient(51deg, rgb(0, 0, 0) 0%, rgba(255, 255, 255, 0) 55%); + + @media (--range-xsmall-down) { + --rotation: 0deg; + mask-image: linear-gradient( + var(--rotation), + rgb(0, 0, 0) 28%, + rgba(0, 0, 0, 0) 56% + ); + } + } + + // When the text is pinned to the top of the lockup, we use a different gradient for legibility + article.text-pinned-to-vertical-start .gradient { + --rotation: -170deg; + mask-image: radial-gradient( + ellipse 118% 121% at 100% 0%, + rgb(0, 0, 0) 18%, + rgb(0, 0, 0.33) 22%, + rgba(0, 0, 0, 0.66) 33%, + transparent 43% + ); + } + + .app-icons { + display: grid; + align-self: center; + width: 90%; + grid-template-rows: auto auto; + grid-auto-flow: column; + gap: 24px; + margin-inline-start: -4%; + position: absolute; + inset-inline-end: 24px; + + @media (--range-small-up) { + width: 44%; + } + } + + .app-icons li:nth-child(even) { + inset-inline-start: 44%; + } + + .app-icon-container { + position: relative; + flex-shrink: 0; + max-width: 200px; + } + + @property --top-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 20%; + } + + @property --bottom-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 40%; + } + + @property --top-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 55%; + } + + @property --bottom-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 50%; + } + + .collection-icons-background-gradient { + width: 100%; + height: 100%; + position: absolute; + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) var(--top-left-stop), + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) var(--bottom-left-stop), + transparent 80% + ), + radial-gradient( + circle at 66% -175%, + var(--top-right, #000) var(--top-right-stop), + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) var(--bottom-right-stop), + transparent 100% + ); + animation: collection-icons-background-gradient-shift 16s infinite + alternate-reverse; + animation-play-state: paused; + + @media (--range-small-up) { + animation-play-state: running; + } + } + + @keyframes collection-icons-background-gradient-shift { + 0% { + --top-left-stop: 20%; + --bottom-left-stop: 40%; + --top-right-stop: 55%; + --bottom-right-stop: 50%; + background-size: 100% 100%; + } + + 50% { + --top-left-stop: 25%; + --bottom-left-stop: 15%; + --top-right-stop: 70%; + --bottom-right-stop: 30%; + background-size: 130% 130%; + } + + 100% { + --top-left-stop: 15%; + --bottom-left-stop: 20%; + --top-right-stop: 55%; + --bottom-right-stop: 20%; + background-size: 110% 110%; + } + } +</style> diff --git a/src/components/icons/AppStoreLogo.svg b/src/components/icons/AppStoreLogo.svg new file mode 100644 index 0000000..185032f --- /dev/null +++ b/src/components/icons/AppStoreLogo.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__PaJpmjhr__"
\ No newline at end of file diff --git a/src/components/icons/AppleArcadeLogo.svg b/src/components/icons/AppleArcadeLogo.svg new file mode 100644 index 0000000..52902b2 --- /dev/null +++ b/src/components/icons/AppleArcadeLogo.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20384%2080'%20preserveAspectRatio='xMinYMin%20meet'%20%3e%3cpath%20fill='currentColor'%20d='M43.873%2012.699C46.606%209.28%2048.461%204.69%2047.972%200c-4.001.199-8.883%202.64-11.71%206.06-2.538%202.93-4.784%207.712-4.198%2012.206%204.49.39%208.978-2.245%2011.81-5.567M47.92%2019.144c-6.521-.389-12.067%203.701-15.182%203.701-3.116%200-7.885-3.506-13.044-3.411-6.714.098-12.945%203.895-16.352%209.933-7.008%2012.079-1.85%2029.996%204.966%2039.833%203.31%204.867%207.298%2010.226%2012.553%2010.034%204.966-.195%206.912-3.216%2012.948-3.216%206.032%200%207.785%203.216%2013.041%203.118%205.451-.097%208.859-4.869%2012.168-9.74%203.797-5.549%205.351-10.906%205.449-11.2-.098-.097-10.511-4.092-10.608-16.07-.098-10.03%208.176-14.801%208.565-15.097-4.672-6.91-11.972-7.69-14.503-7.885'%20/%3e%3cpath%20fill='currentColor'%20d='M115.598%2058.881H87.752L81.07%2078.627H69.273L95.651%205.569h12.252l26.377%2073.058h-12l-6.682-19.746zm-24.96-9.113h22.074L101.827%2017.72h-.304L90.638%2049.768zM140.503%2025.365h10.43v9.062h.253c1.773-6.226%206.531-9.923%2012.81-9.923%201.569%200%202.936.253%203.746.406v10.175c-.86-.354-2.784-.607-4.911-.607-7.038%200-11.391%204.71-11.391%2012.252v31.897h-10.937V25.365zM207.744%2043.693c-1.114-5.671-5.367-10.177-12.505-10.177-8.455%200-14.025%207.037-14.025%2018.48%200%2011.695%205.62%2018.48%2014.126%2018.48%206.734%200%2011.138-3.696%2012.404-9.873h10.53c-1.164%2011.34-10.227%2019.036-23.035%2019.036-15.24%200-25.162-10.43-25.162-27.643%200-16.91%209.923-27.593%2025.06-27.593%2013.72%200%2022.074%208.81%2023.036%2019.29h-10.43zM223.9%2063.489c0-9.317%207.14-15.037%2019.797-15.746l14.58-.86v-4.101c0-5.924-4-9.468-10.682-9.468-6.329%200-10.278%203.037-11.24%207.797h-10.328c.607-9.62%208.81-16.708%2021.973-16.708%2012.91%200%2021.163%206.835%2021.163%2017.517v36.707h-10.48v-8.76h-.254c-3.088%205.925-9.821%209.67-16.808%209.67-10.43%200-17.72-6.48-17.72-16.048zm34.378-4.81v-4.202l-13.113.81c-6.532.456-10.227%203.341-10.227%207.898%200%204.657%203.848%207.695%209.72%207.695%207.645%200%2013.62-5.265%2013.62-12.2zM276.853%2051.996c0-16.809%208.91-27.492%2022.276-27.492%207.645%200%2013.721%203.848%2016.707%209.721h.204V5.57h10.986v73.058h-10.632v-9.063h-.203c-3.139%206.075-9.214%209.974-16.96%209.974-13.468%200-22.378-10.734-22.378-27.542zm11.189%200c0%2011.239%205.417%2018.277%2014.075%2018.277%208.404%200%2014.023-7.139%2014.023-18.277%200-11.037-5.619-18.277-14.023-18.277-8.658%200-14.075%207.088-14.075%2018.277zM382.956%2062.982c-1.519%209.72-10.734%2016.657-22.935%2016.657-15.644%200-25.111-10.58-25.111-27.39%200-16.707%209.619-27.846%2024.656-27.846%2014.783%200%2023.997%2010.43%2023.997%2026.58v3.747h-37.616v.658c0%209.265%205.568%2015.39%2014.327%2015.39%206.228%200%2010.835-3.138%2012.303-7.796h10.379zm-36.96-15.897h26.631c-.252-8.15-5.417-13.873-13.061-13.873-7.646%200-13.012%205.823-13.57%2013.873z'%20/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/components/jet/Video.svelte b/src/components/jet/Video.svelte new file mode 100644 index 0000000..8d2e4f3 --- /dev/null +++ b/src/components/jet/Video.svelte @@ -0,0 +1,66 @@ +<script lang="ts"> + import type { Video } from '@jet-app/app-store/api/models'; + import VideoPlayer from '../VideoPlayer.svelte'; + import HlsJsDecorator from '../decorators/HlsJSDecorator.svelte'; + import { buildPoster } from '~/utils/video-poster'; + import { generateUuid } from '@amp/web-apps-utils/src'; + import type { NamedProfile } from 'src/config/components/artwork'; + import type { Profile } from '@amp/web-app-components/src/components/Artwork/types'; + import mediaQueries from '~/utils/media-queries'; + import { colorAsString } from '~/utils/color'; + + export let video: Video; + export let autoplay: boolean = false; + export let loop: boolean = false; + export let muted: boolean = true; + export let useControls: boolean = true; + export let profile: NamedProfile | Profile; + export let autoplayVisibilityThreshold: number = 0; + export let videoPlayerRef: InstanceType<typeof VideoPlayer> | null = null; + export let shouldSuperimposePosterImage: boolean = false; + + $: poster = + video.preview && buildPoster(video.preview, profile, $mediaQueries); + $: backgroundColor = video.preview.backgroundColor + ? colorAsString(video.preview.backgroundColor) + : '#f1f1f1'; + + $: metricsTemplate = video?.templateMediaEvent ?? {}; + const uuid = generateUuid(); +</script> + +<HlsJsDecorator let:HLS> + <VideoPlayer + {HLS} + {loop} + {muted} + {autoplay} + {useControls} + {autoplayVisibilityThreshold} + {metricsTemplate} + {shouldSuperimposePosterImage} + id={uuid} + src={video.videoUrl} + poster={poster ?? undefined} + --aspect-ratio={video.preview.width / video.preview.height} + bind:this={videoPlayerRef} + /> + + <div + class="loader" + slot="loading-component" + style:--aspect-ratio={video.preview.width / video.preview.height} + style:--background-image={`url(${poster})`} + style:--background-color={backgroundColor} + /> +</HlsJsDecorator> + +<style> + .loader { + aspect-ratio: var(--aspect-ratio); + width: 100%; + background-image: var(--background-image); + background-color: var(--background-color); + background-size: cover; + } +</style> diff --git a/src/components/jet/action/ExternalUrlAction.svelte b/src/components/jet/action/ExternalUrlAction.svelte new file mode 100644 index 0000000..e8a2ad6 --- /dev/null +++ b/src/components/jet/action/ExternalUrlAction.svelte @@ -0,0 +1,52 @@ +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + import type { ExternalUrlAction } from '@jet-app/app-store/api/models'; + import ArrowIcon from '@amp/web-app-components/assets/icons/arrow.svg'; + import { getJetPerform } from '~/jet'; + + type AllowedAnchorAttributes = Omit< + HTMLAnchorAttributes, + // The `href` attribute is not allowed because it will be provided + // by the `ExternalUrlAction` + 'href' + >; + + interface $$Props extends AllowedAnchorAttributes { + destination: ExternalUrlAction; + includeArrowIcon?: boolean; + } + + const perform = getJetPerform(); + + export let destination: ExternalUrlAction; + export let includeArrowIcon: boolean = true; + + function handleClickAction() { + perform(destination); + } +</script> + +<a + {...$$restProps} + data-test-id="external-link" + href={destination.url} + target="_blank" + rel="nofollow noopener noreferrer" + on:click={handleClickAction} +> + <slot /> + {#if includeArrowIcon} + <ArrowIcon class="external-link-arrow" aria-hidden="true" /> + {/if} +</a> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + a :global(.external-link-arrow) { + @include rtl { + transform: rotate(-90deg); + } + } +</style> diff --git a/src/components/jet/action/FlowAction.svelte b/src/components/jet/action/FlowAction.svelte new file mode 100644 index 0000000..3e55e82 --- /dev/null +++ b/src/components/jet/action/FlowAction.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + import type { FlowAction } from '@jet-app/app-store/api/models'; + import { getJetPerform } from '~/jet'; + + type AllowedAnchorAttributes = Omit< + HTMLAnchorAttributes, + // The `href` attribute is not allowed because it will be provided + // by the `FlowAction` + 'href' + >; + + interface $$Props extends AllowedAnchorAttributes { + destination: FlowAction; + } + + const perform = getJetPerform(); + + export let destination: FlowAction; + + // Web cannot support internal protocols, so this guard prevents + // them from showing up in anchor tags. + $: pageUrl = destination.pageUrl?.includes('x-as3-internal:') + ? '#' + : destination?.pageUrl; + + function onClick(event: MouseEvent) { + event.preventDefault(); + + perform(destination); + } +</script> + +<a + {...$$restProps} + href={pageUrl} + data-test-id="internal-link" + on:click={onClick} +> + <slot /> +</a> diff --git a/src/components/jet/action/ShelfBasedPageScrollAction.svelte b/src/components/jet/action/ShelfBasedPageScrollAction.svelte new file mode 100644 index 0000000..9c1c13e --- /dev/null +++ b/src/components/jet/action/ShelfBasedPageScrollAction.svelte @@ -0,0 +1,51 @@ +<script lang="ts" context="module"> + import type { + Action, + ShelfBasedPageScrollAction, + } from '@jet-app/app-store/api/models'; + + export function isShelfBasedPageScrollAction( + action: Action, + ): action is ShelfBasedPageScrollAction { + return ( + action.$kind === 'ShelfBasedPageScrollAction' && 'shelfId' in action + ); + } +</script> + +<script lang="ts"> + import type { HTMLAnchorAttributes } from 'svelte/elements'; + + interface $$Props extends HTMLAnchorAttributes { + destination: ShelfBasedPageScrollAction; + } + + export let destination: ShelfBasedPageScrollAction; + + function handleLinkClick(e: Event) { + const anchorElement = e.currentTarget as HTMLAnchorElement; + const hash = anchorElement.hash; + const elementToScrollTo = document.querySelector(hash); + if (!elementToScrollTo) { + return; + } + elementToScrollTo.scrollIntoView({ + behavior: 'smooth', + block: 'start', + }); + history.replaceState(null, '', hash); + } +</script> + +{#if destination.shelfId} + <a + {...$$restProps} + data-test-id="scroll-link" + href={`#${destination.shelfId}`} + on:click|preventDefault|stopPropagation={handleLinkClick} + > + <slot /> + </a> +{:else} + <slot /> +{/if} diff --git a/src/components/jet/badge/ContentRatingBadge.svelte b/src/components/jet/badge/ContentRatingBadge.svelte new file mode 100644 index 0000000..ff3a2c3 --- /dev/null +++ b/src/components/jet/badge/ContentRatingBadge.svelte @@ -0,0 +1,61 @@ +<script lang="ts" context="module"> + import type { Badge, BadgeType } from '@jet-app/app-store/api/models'; + + const ARTWORK_TYPE: BadgeType = 'artwork'; + const CONTENT_RATING_TYPE: BadgeType = 'contentRating'; + const CONTENT_RATING_KEY = 'contentRating'; + + interface ContentRatingBadge extends Badge { + type: typeof CONTENT_RATING_TYPE; + } + + export function isContentRatingBadge( + badge: Badge, + ): badge is ContentRatingBadge { + return ( + badge.type === CONTENT_RATING_TYPE || + (badge.key === CONTENT_RATING_KEY && badge.type === ARTWORK_TYPE) + ); + } +</script> + +<script lang="ts"> + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let badge: ContentRatingBadge; + + $: ({ artwork, accessibilityTitle } = badge); +</script> + +{#if artwork && isSystemImageArtwork(artwork)} + <div class="pictogram-container" aria-label={accessibilityTitle}> + <SystemImage {artwork} /> + </div> +{:else} + <span> + {badge.content.contentRating} + </span> +{/if} + +<style> + span { + height: 25px; + margin: 4px 0 2px; + font: var(--title-1-emphasized); + color: var(--color); + } + + .pictogram-container { + height: 25px; + padding: 2px; + aspect-ratio: 1/1; + margin: 4px 0 2px; + } + + .pictogram-container :global(svg) { + width: 21px; + height: 21px; + } +</style> diff --git a/src/components/jet/item/AccessibilityFeaturesItem.svelte b/src/components/jet/item/AccessibilityFeaturesItem.svelte new file mode 100644 index 0000000..bcbeb6c --- /dev/null +++ b/src/components/jet/item/AccessibilityFeaturesItem.svelte @@ -0,0 +1,159 @@ +<script lang="ts"> + import type { AccessibilityFeatures } from '@jet-app/app-store/api/models'; + + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let item: AccessibilityFeatures; + export let isDetailView: boolean = false; +</script> + +<article + class:is-detail-view={isDetailView} + role={isDetailView ? 'presentation' : 'article'} +> + {#if !isDetailView} + {#if item.artwork && isSystemImageArtwork(item.artwork)} + <span class="icon-container" aria-hidden="true"> + <SystemImage artwork={item.artwork} /> + </span> + {/if} + <h2>{item.title}</h2> + {/if} + + <ul class:grid={item.features.length > 1 && !isDetailView}> + {#each item.features as feature} + <li> + {#if isSystemImageArtwork(feature.artwork)} + <span class="feature-icon-container" aria-hidden="true"> + <SystemImage artwork={feature.artwork} /> + </span> + {/if} + <div class="feature-content"> + <h3 class="feature-title">{feature.title}</h3> + {#if feature.description} + <span class="feature-description"> + {feature.description} + </span> + {/if} + </div> + </li> + {/each} + </ul> +</article> + +<style lang="scss"> + @use 'amp/stylekit/core/border-radiuses' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + display: flex; + flex-direction: column; + height: 100%; + padding: 30px; + gap: 8px; + text-align: center; + font: var(--body-tall); + border-radius: $global-border-radius-rounded-large; + background-color: var(--systemQuinary); + + &.is-detail-view { + padding: 0; + text-align: start; + background-color: transparent; + } + } + + .icon-container { + width: 30px; + margin: 0 auto; + } + + .icon-container :global(svg) { + width: 100%; + fill: var(--keyColor); + } + + h2 { + font: var(--title-3-emphasized); + margin-bottom: 8px; + } + + ul { + display: flex; + flex-direction: column; + gap: 25px; + } + + ul.grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + li { + display: flex; + align-items: center; + justify-content: center; + text-align: start; + padding: 4px 0; + gap: 8px; + + .is-detail-view & { + gap: 10px; + justify-content: start; + align-items: flex-start; + } + } + + .grid li { + justify-content: start; + } + + .feature-icon-container { + display: inline-flex; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + + .is-detail-view & { + display: flex; + align-items: center; + + @media (prefers-color-scheme: dark) { + filter: none; + } + } + } + + .feature-icon-container :global(svg) { + width: 20px; + + .is-detail-view & { + width: 30px; + fill: var(--keyColor); + } + } + + .feature-content { + display: flex; + flex-direction: column; + gap: 6px; + } + + .feature-title { + font: var(--body-tall); + + .is-detail-view & { + color: var(--systemPrimary); + font: var(--title-2-emphasized); + } + } + + .feature-description { + color: var(--systemSecondary); + font: var(--body); + } +</style> diff --git a/src/components/jet/item/AccessibilityParagraphItem.svelte b/src/components/jet/item/AccessibilityParagraphItem.svelte new file mode 100644 index 0000000..836b52f --- /dev/null +++ b/src/components/jet/item/AccessibilityParagraphItem.svelte @@ -0,0 +1,22 @@ +<script lang="ts"> + import type { AccessibilityParagraph } from '@jet-app/app-store/api/models'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let item: AccessibilityParagraph; +</script> + +<div> + <p> + <LinkableTextItem item={item.text} /> + </p> +</div> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/item/Annotation/AnnotationItem.svelte b/src/components/jet/item/Annotation/AnnotationItem.svelte new file mode 100644 index 0000000..38bb269 --- /dev/null +++ b/src/components/jet/item/Annotation/AnnotationItem.svelte @@ -0,0 +1,17 @@ +<script lang="ts"> + import { type Annotation } from '@jet-app/app-store/api/models'; + import ModernAnnotationItemRenderer from '~/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte'; + import LegacyAnnotationRenderer from '~/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte'; + + export let item: Annotation; + + $: ({ items, items_V3, linkAction, summary } = item); + + $: shouldRenderModernAnnotation = items_V3.length > 0; +</script> + +{#if shouldRenderModernAnnotation} + <ModernAnnotationItemRenderer items={items_V3} {summary} /> +{:else} + <LegacyAnnotationRenderer {items} {linkAction} /> +{/if} diff --git a/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte new file mode 100644 index 0000000..fc6586f --- /dev/null +++ b/src/components/jet/item/Annotation/LegacyAnnotationRenderer.svelte @@ -0,0 +1,146 @@ +<script lang="ts"> + import { isSome } from '@jet/environment'; + import { + type AnnotationItem, + type Action, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem[]; + export let linkAction: Action | undefined; + + const shouldRenderAsDefinitionList = (items: AnnotationItem[]) => + !!items[0]?.heading; + + const shouldRenderAsOrderedList = (items: AnnotationItem[]) => + !!items[0]?.textPairs; + + const shouldRenderAsUnorderedList = (items: AnnotationItem[]) => + !items[0]?.text; + + const shouldRenderAsDefinitionListWithHeading = (items: AnnotationItem[]) => + items[0]?.text && items[1]?.heading; +</script> + +{#if shouldRenderAsDefinitionList(items)} + <dl class="secondary-definition-list"> + {#each items as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> +{:else if shouldRenderAsOrderedList(items)} + <ol> + {#each items as annotationItem} + {#if annotationItem.textPairs} + {#each annotationItem.textPairs as [text, subtext]} + <li> + <span class="text">{text}</span> + <span class="subtext">{subtext}</span> + </li> + {/each} + {:else} + <li>{annotationItem.text}</li> + {/if} + {/each} + </ol> +{:else if shouldRenderAsUnorderedList(items)} + <ul> + {#each items as annotationItem} + <li> + <span class="text"> + {annotationItem.text} + </span> + </li> + {/each} + </ul> +{:else if shouldRenderAsDefinitionListWithHeading(items)} + {@const [heading, ...remainingItems] = items} + <dd> + <p class="secondary-definition-list-heading">{heading.text}</p> + + <dl class="secondary-definition-list"> + {#each remainingItems as annotationItem} + <dt>{annotationItem.heading}</dt> + <dd>{annotationItem.text}</dd> + {/each} + </dl> + </dd> +{:else} + <dd> + <ul> + {#each items as annotationItem} + <li>{annotationItem.text}</li> + {/each} + </ul> + {#if isSome(linkAction) && isFlowAction(linkAction)} + <LinkWrapper action={linkAction}> + {linkAction.title} + </LinkWrapper> + {/if} + </dd> +{/if} + +<style> + dt { + color: var(--systemSecondary); + font: var(--body-tall); + } + + dd { + white-space: pre-line; + font: var(--body-tall); + } + + ol { + counter-reset: section; + } + + ol li { + display: table-row; + font: var(--body-tall); + } + + ol li::before { + counter-increment: section; + content: counter(section) '.'; + display: table-cell; + padding-inline-end: 6px; + } + + ol li .text { + display: table-cell; + width: 100%; + } + + ol li .subtext { + display: table-cell; + } + + .secondary-definition-list-heading { + margin-bottom: 16px; + } + + .secondary-definition-list dt { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .secondary-definition-list dd:not(:last-of-type) { + margin-bottom: 16px; + } + + dd li:not(:last-of-type) { + margin-bottom: 16px; + } + + dd :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + dd :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte new file mode 100644 index 0000000..20611d3 --- /dev/null +++ b/src/components/jet/item/Annotation/ModernAnnotationItemRenderer.svelte @@ -0,0 +1,114 @@ +<script lang="ts"> + import type { AnnotationItem_V3 } from '@jet-app/app-store/api/models'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let items: AnnotationItem_V3[]; + export let summary: string | undefined; + + const formatStyledText = (text: string): string => { + return ( + text + // Replace \n with <br> + .replace(/\n/g, '<br>') + // Replace **text** with <strong>text</strong> + .replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>') + ); + }; +</script> + +<ul> + {#each items as annotationItem} + <li> + {#if annotationItem.$kind === 'textEncapsulation'} + <div class="text-encapsulation"> + {annotationItem.text} + </div> + {:else if annotationItem.$kind === 'linkableText'} + <div class="styled-text"> + {@html sanitizeHtml( + formatStyledText( + annotationItem.linkableText.styledText.rawText, + ), + )} + </div> + {:else if annotationItem.$kind === 'artwork'} + {#if isSystemImageArtwork(annotationItem.artwork)} + <div class="artwork-wrapper" aria-label={summary}> + <SystemImage artwork={annotationItem.artwork} /> + </div> + {/if} + {:else if annotationItem.$kind === 'textPair'} + <div class="text-pair"> + <span>{annotationItem.leadingText}</span> + <span> + {annotationItem.trailingText} + </span> + </div> + {:else if annotationItem.$kind === 'button'} + <div class="button-wrapper"> + <LinkWrapper action={annotationItem.action}> + {annotationItem.action.title} + </LinkWrapper> + </div> + {:else if annotationItem.$kind === 'spacer'} + <div class="spacer" /> + {/if} + </li> + {/each} +</ul> + +<style> + li { + font: var(--body-tall); + } + + .styled-text :global(strong) { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .text-encapsulation { + width: fit-content; + color: var(--keyColor); + border: 1px solid; + border-radius: 3px; + padding-inline: 3px; + border-color: var(--keyColor); + margin-block: 3px; + } + + .artwork-wrapper :global(svg) { + height: 18px; + width: 18px; + margin-top: 4px; + } + + .spacer { + height: 16px; + } + + .button-wrapper :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + .button-wrapper :global(a:hover) { + text-decoration: underline; + } + + .button-wrapper :global(a) :global(.external-link-arrow) { + width: 7px; + height: 7px; + fill: var(--keyColor); + margin-top: 3px; + } + + .text-pair { + display: flex; + justify-content: space-between; + } +</style> diff --git a/src/components/jet/item/AppEventItem.svelte b/src/components/jet/item/AppEventItem.svelte new file mode 100644 index 0000000..c1e5e5a --- /dev/null +++ b/src/components/jet/item/AppEventItem.svelte @@ -0,0 +1,176 @@ +<script lang="ts"> + import type { AppEvent } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import SmallLockupItem from './SmallLockupItem.svelte'; + + export let item: AppEvent; + export let isArticleContext: boolean = false; + + $: artwork = item.moduleArtwork; + $: video = item.moduleVideo; + $: hasLightArtwork = item.mediaOverlayStyle === 'light'; + $: gradientColor = hasLightArtwork + ? 'rgb(240 240 240 / 48%)' + : 'rgb(83 83 83 / 48%)'; + $: shouldShowLockup = !!item.lockup && !item.hideLockupWhenNotInstalled; +</script> + +<div + class="app-event-item" + class:with-lockup={!!item.lockup && !item.hideLockupWhenNotInstalled} +> + <span class="time-indicator"> + <AppEventDate appEvent={item} /> + </span> + + <div class="lockup-container"> + <HoverWrapper hasChin={shouldShowLockup} --display="block"> + <LinkWrapper action={item.clickAction}> + <div class="text-over-artwork"> + {#if video} + <div class="video-container"> + <Video + {video} + autoplay + loop={true} + useControls={false} + profile="app-promotion" + /> + </div> + {:else if artwork} + <div class="artwork-container"> + <Artwork + {artwork} + profile={isArticleContext + ? 'app-promotion-in-article' + : 'app-promotion'} + /> + </div> + {/if} + + <div class="gradient-container"> + <GradientOverlay + --border-radius={0} + --color={gradientColor} + --height="80%" + shouldDarken={!hasLightArtwork} + /> + </div> + + <div class="text-container" class:dark={hasLightArtwork}> + <h4>{item.kind}</h4> + + <h3>{item.title}</h3> + + <LineClamp clamp={1}> + <p>{item.detail}</p> + </LineClamp> + </div> + </div> + </LinkWrapper> + </HoverWrapper> + + {#if item.lockup && shouldShowLockup} + <div class="small-lockup-container"> + <SmallLockupItem item={item.lockup} appIconProfile="app-icon" /> + </div> + {/if} + </div> +</div> + +<style> + .app-event-item { + height: 100%; + display: grid; + grid-template-areas: + 'time-indicator' + 'lockup'; + grid-template-rows: 1rem 1fr; + gap: 4px; + } + + .time-indicator { + grid-area: time-indicator; + color: var(--keyColor); + font-weight: bold; + } + + .lockup-container { + grid-area: lockup; + } + + .text-over-artwork { + /* Allow artwork, overlay and text containers to overlap by targeting the same grid area */ + display: grid; + grid-template-areas: 'content'; + } + + .artwork-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + } + + .video-container { + grid-area: content; + border-radius: var(--global-border-radius-large); + line-height: 0; + } + + .app-event-item.with-lockup .artwork-container, + .app-event-item.with-lockup .video-container { + border-radius: 0; + } + + .gradient-container { + grid-area: content; + z-index: 1; + position: relative; + } + + .text-container { + color: var(--systemPrimary-onDark); + padding: 12px 16px; + grid-area: content; + z-index: 2; + + /* Float text to the bottom of the lockup */ + display: flex; + flex-direction: column; + justify-content: flex-end; + } + + .text-container.dark { + color: var(--systemPrimary-onLight); + } + + .small-lockup-container { + background: var(--systemPrimary-onDark); + border-radius: 0 0 var(--global-border-radius-large) + var(--global-border-radius-large); + box-shadow: var(--shadow-small); + padding: 12px; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuinary-onDark); + } + } + + h3 { + font: var(--title-2-tall); + } + + h4 { + font: var(--callout-emphasized-tall); + } + + p { + font: var(--callout-emphasized); + } +</style> diff --git a/src/components/jet/item/ArcadeFooterItem.svelte b/src/components/jet/item/ArcadeFooterItem.svelte new file mode 100644 index 0000000..94fe61d --- /dev/null +++ b/src/components/jet/item/ArcadeFooterItem.svelte @@ -0,0 +1,83 @@ +<script lang="ts"> + import type { + ArcadeFooter, + Artwork, + ImpressionableArtwork, + } from '@jet-app/app-store/api/models'; + import { unwrapOptional as unwrap } from '@jet/environment/types/optional'; + + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: ArcadeFooter; + + $: action = unwrap(item.buttonAction); + + function isImpressionableArtwork( + item: ImpressionableArtwork | Artwork, + ): item is ImpressionableArtwork { + return 'art' in item; + } + + // Sometimes data used to render an app icon is directly on `icon` but other times, in the case + // of `ImpressionableArtwork`, it's on `icon.art`. Here we are plucking the data no matter where it is. + const icons = (item.icons ?? []).map((icon) => + isImpressionableArtwork(icon) ? icon.art : icon, + ); +</script> + +<LinkWrapper {action}> + <article> + {#if icons.length} + <AppIconRiver {icons} /> + {/if} + + <div class="metadata-container"> + <div class="logo-container"> + <AppleArcadeLogo /> + </div> + + <button class="get-button gray"> + {action.title} + </button> + </div> + </article> +</LinkWrapper> + +<style> + article { + --app-icon-river-speed: 120s; + display: flex; + overflow: hidden; + flex-flow: column; + padding: 20px 0 30px; + margin-bottom: 20px; + text-align: center; + border-radius: var(--global-border-radius-large); + background: var(--footerBg); + + @media (--range-small-down) { + --app-icon-river-icon-width: 88px; + } + + @media (--range-medium-up) { + --get-button-font: var(--title-3-emphasized); + } + } + + .metadata-container { + display: flex; + align-items: center; + flex-flow: column; + gap: 20px; + } + + .logo-container { + width: 128px; + + @media (--range-small-down) { + width: 88px; + } + } +</style> diff --git a/src/components/jet/item/BannerItem.svelte b/src/components/jet/item/BannerItem.svelte new file mode 100644 index 0000000..819f621 --- /dev/null +++ b/src/components/jet/item/BannerItem.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { isFlowAction, type Banner } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: Banner; +</script> + +<div class="banner"> + <p> + {item.message} + {#if isSome(item.action) && isFlowAction(item.action)} + <LinkWrapper action={item.action}> + {item.action.title} + </LinkWrapper> + {/if} + </p> +</div> + +<style> + .banner { + background: rgba(var(--keyColor-rgb), 0.07); + padding: 8px 16px; + margin: 0 var(--bodyGutter); + text-align: center; + border-radius: var(--global-border-radius-small); + } + + .banner :global(a) { + color: var(--keyColor); + text-decoration: none; + } + + .banner :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/jet/item/BrickItem.svelte b/src/components/jet/item/BrickItem.svelte new file mode 100644 index 0000000..a9e6319 --- /dev/null +++ b/src/components/jet/item/BrickItem.svelte @@ -0,0 +1,300 @@ +<script lang="ts"> + import type { Brick } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { + colorAsString, + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + + export let item: Brick; + export let shouldOverlayDescription: boolean = false; + + const rtlArtwork = item.artworks?.[1] || item.rtlArtwork; + const artwork = isRtl() && rtlArtwork ? rtlArtwork : item.artworks?.[0]; + const { collectionIcons } = item; + + const gradientColor: string = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : 'rgb(0 0 0 / 62%)'; + + let backgroundGradientCssVars: string | undefined = undefined; + + if (collectionIcons && collectionIcons.length > 1) { + // If there are multiple app icons, we build a string of CSS variables from the icons + // background colors to fill as many of the lockups quadrants as possible. + backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks( + collectionIcons, + { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + shouldRemoveGreys: true, + }, + ); + } +</script> + +<LinkWrapper + action={item.clickAction} + label={item.accessibilityLabel || item.clickAction?.title} +> + <div class="container"> + <HoverWrapper> + {#if artwork} + <Artwork + {artwork} + profile={shouldOverlayDescription ? 'small-brick' : 'brick'} + /> + {:else if backgroundGradientCssVars} + <div + class="background-gradient" + style={backgroundGradientCssVars} + /> + {/if} + + {#if item.title} + <GradientOverlay --color={gradientColor} /> + {/if} + + <div class="text-container"> + <div class="metadata-container"> + {#if item.caption} + <LineClamp clamp={1}> + <h4>{item.caption}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={3}> + <h3 class="title"> + {@html sanitizeHtml(item.title)} + </h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={2}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + {#if !artwork && collectionIcons} + <ul class="app-icons"> + {#each collectionIcons?.slice(0, 8) as collectionIcon} + <li class="app-icon-container"> + <AppIcon + icon={collectionIcon} + profile="brick-app-icon" + fixedWidth={false} + /> + </li> + {/each} + </ul> + {/if} + </div> + </HoverWrapper> + + {#if item.shortEditorialDescription} + <h3 + class="editorial-description" + class:overlaid={shouldOverlayDescription} + > + {item.shortEditorialDescription} + </h3> + {/if} + </div> +</LinkWrapper> + +<style> + .container { + position: relative; + container-type: inline-size; + container-name: container; + } + + .metadata-container { + width: 100%; + align-self: end; + } + + .text-container { + position: absolute; + z-index: 2; + bottom: 0; + display: flex; + align-items: flex-end; + width: 100%; + height: 100%; + padding: 20px; + color: var(--systemPrimary-onDark); + } + + .app-icon-container { + position: relative; + flex-shrink: 0; + width: 60px; + margin-inline-end: 5%; + } + + .title { + font: var(--title-1-emphasized); + text-wrap: pretty; + } + + h4 { + margin-bottom: 3px; + font: var(--callout-emphasized); + } + + p { + margin-top: 6px; + font: var(--body-emphasized); + } + + .editorial-description { + margin-top: 8px; + font: var(--title-3); + } + + .editorial-description.overlaid { + position: absolute; + z-index: 1; + bottom: 9px; + padding: 0 20px; + color: white; + font: var(--title-2-emphasized); + } + + @property --top-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 20%; + } + + @property --bottom-left-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 40%; + } + + @property --top-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 55%; + } + + @property --bottom-right-stop { + syntax: '<percentage>'; + inherits: false; + initial-value: 50%; + } + + .container .background-gradient { + width: 100%; + aspect-ratio: 16 / 9; + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) var(--top-left-stop), + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) var(--bottom-left-stop), + transparent 80% + ), + radial-gradient( + circle at 66% -175%, + var(--top-right, #000) var(--top-right-stop), + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) var(--bottom-right-stop), + transparent 100% + ); + animation: gradient-hover 8s infinite alternate-reverse; + animation-play-state: paused; + } + + @keyframes gradient-hover { + 0% { + --top-left-stop: 20%; + --bottom-left-stop: 40%; + --top-right-stop: 55%; + --bottom-right-stop: 50%; + background-size: 100% 100%; + } + + 50% { + --top-left-stop: 25%; + --bottom-left-stop: 15%; + --top-right-stop: 70%; + --bottom-right-stop: 30%; + background-size: 130% 130%; + } + + 100% { + --top-left-stop: 15%; + --bottom-left-stop: 20%; + --top-right-stop: 55%; + --bottom-right-stop: 20%; + background-size: 110% 110%; + } + } + + .container:hover .background-gradient { + animation-play-state: running; + } + + .app-icons { + display: grid; + align-self: center; + flex-direction: row; + width: 44%; + grid-template-rows: auto auto; + grid-auto-flow: column; + gap: 8px; + } + + .app-icons li:nth-child(even) { + inset-inline-start: 40px; + } + + @container container (max-width: 298px) { + .title { + font: var(--title-2-emphasized); + } + + .text-container { + padding: 16px; + } + + .editorial-description.overlaid { + bottom: 16px; + padding-inline: 16px; + } + + .app-icons { + width: 36%; + } + + .app-icon-container { + width: 50px; + } + } + + @container container (min-width: 440px) { + .app-icon-container { + width: 83px; + } + } +</style> diff --git a/src/components/jet/item/ContentModal.svelte b/src/components/jet/item/ContentModal.svelte new file mode 100644 index 0000000..486937d --- /dev/null +++ b/src/components/jet/item/ContentModal.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import ContentModal from '@amp/web-app-components/src/components/Modal/ContentModal.svelte'; + import { getI18n } from '~/stores/i18n'; + import { createEventDispatcher } from 'svelte'; + import { getJet } from '~/jet'; + + export let title: string | null; + export let subtitle: string | null; + export let text: string | null = null; + export let dialogTitleId: string | null = null; + export let targetId: string = 'close'; + + const i18n = getI18n(); + const jet = getJet(); + const dispatch = createEventDispatcher(); + + const translateFn = (key: string) => $i18n.t(key); + + const handleCloseModal = () => { + dispatch('close'); + jet.recordCustomMetricsEvent({ + eventType: 'click', + targetId, + targetType: 'button', + actionType: 'close', + }); + }; +</script> + +<ContentModal + on:close={handleCloseModal} + {translateFn} + {title} + {subtitle} + text={text || undefined} + {dialogTitleId} +> + <slot name="content" slot="content" /> +</ContentModal> diff --git a/src/components/jet/item/EditorialCardItem.svelte b/src/components/jet/item/EditorialCardItem.svelte new file mode 100644 index 0000000..2998b05 --- /dev/null +++ b/src/components/jet/item/EditorialCardItem.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { EditorialCard } from '@jet-app/app-store/api/models'; + + import Hero from '~/components/hero/Hero.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import AppLockupDetail from '~/components/hero/AppLockupDetail.svelte'; + import mediaQueries from '~/utils/media-queries'; + import { isRtl } from '~/utils/locale'; + + export let item: EditorialCard; + + $: isPortraitLayout = $mediaQueries === 'xsmall'; +</script> + +<Hero + action={item.clickAction} + artwork={item.artwork} + subtitle={item.subtitle} + title={item.title} + pinArtworkToHorizontalEnd={true} + backgroundColor={item.artwork?.backgroundColor} + isMediaDark={item.mediaOverlayStyle === 'dark'} + profileOverride={isPortraitLayout ? 'large-hero-portrait-iphone' : null} +> + <svelte:fragment slot="eyebrow"> + {#if item.appEventFormattedDates} + <AppEventDate formattedDates={item.appEventFormattedDates} /> + {:else} + {item.caption} + {/if} + </svelte:fragment> + + <svelte:fragment slot="details"> + {#if item.lockup} + <AppLockupDetail + lockup={item.lockup} + isOnDarkBackground={item.mediaOverlayStyle === 'dark'} + /> + {/if} + </svelte:fragment> +</Hero> diff --git a/src/components/jet/item/FooterLockupItem.svelte b/src/components/jet/item/FooterLockupItem.svelte new file mode 100644 index 0000000..848885d --- /dev/null +++ b/src/components/jet/item/FooterLockupItem.svelte @@ -0,0 +1,93 @@ +<script lang="ts"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Lockup; + + const i18n = getI18n(); +</script> + +<div class="footer-lockup-item"> + <LinkWrapper + action={item.clickAction} + label={`${$i18n.t('ASE.Web.AppStore.View')} ${ + item.title ? item.title : null + }`} + > + {#if item.icon} + <AppIcon icon={item.icon} profile="app-icon-small" /> + {/if} + + <div> + {#if item.heading} + <LineClamp clamp={1}> + <h4 dir="auto">{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={1}> + <h3 dir="auto">{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p dir="auto">{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + <span class="get-button blue" aria-hidden="true"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </LinkWrapper> +</div> + +<style> + .footer-lockup-item > :global(a) { + display: flex; + align-items: center; + flex-direction: column; + width: 100%; + padding: 32px; + gap: 16px; + text-align: center; + border-radius: var(--global-border-radius-small); + background-color: var(--systemQuinary); + transition: background-color 210ms ease-out; + } + + .footer-lockup-item > :global(a:hover) { + --darken-amount: 2%; + background-color: color-mix( + in srgb, + var(--systemQuinary) calc(100% - var(--darken-amount)), + black + ); + + @media (prefers-color-scheme: dark) { + --darken-amount: 10%; + } + } + + h3 { + margin-bottom: 4px; + font: var(--title-2-emphasized); + color: var(--title-color); + } + + h4 { + text-transform: uppercase; + font: var(--subhead-emphasized); + color: var(--systemSecondary); + } + + p { + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/HeroCarouselItem.svelte b/src/components/jet/item/HeroCarouselItem.svelte new file mode 100644 index 0000000..295aa8a --- /dev/null +++ b/src/components/jet/item/HeroCarouselItem.svelte @@ -0,0 +1,60 @@ +<!-- +@component +Component for rendering a `HeroCarouselItem` view-model from the App Store Client +--> +<script lang="ts"> + import type { HeroCarouselItem } from '@jet-app/app-store/api/models'; + + import Hero from '~/components/hero/Hero.svelte'; + import HeroAppLockup from '~/components/hero/AppLockupDetail.svelte'; + import mediaQueries from '~/utils/media-queries'; + + export let item: HeroCarouselItem; + + const { + titleText, + badgeText, + overlayType, + callToActionText, + lockup: overlayLockup, + clickAction, + descriptionText, + } = item.overlay || {}; + + $: artwork = item.artwork || item.video?.preview; + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: video = isXSmallViewport ? item.portraitVideo : item.video; +</script> + +<Hero + {artwork} + {video} + title={titleText} + eyebrow={badgeText} + action={clickAction} + backgroundColor={item.backgroundColor} + subtitle={descriptionText} + isMediaDark={item.isMediaDark} + collectionIcons={item.collectionIcons} +> + <svelte:fragment slot="details" let:isPortraitLayout> + {#if overlayLockup && overlayType === 'singleModule'} + <HeroAppLockup lockup={overlayLockup} /> + {:else if callToActionText && !isPortraitLayout} + <div class="button-container"> + <span class="get-button transparent"> + {callToActionText} + </span> + </div> + {/if} + </svelte:fragment> +</Hero> + +<style> + .button-container { + --get-button-font: var(--title-3-bold); + margin-top: 16px; + position: relative; + z-index: 1; + } +</style> diff --git a/src/components/jet/item/InAppPurchaseLockup.svelte b/src/components/jet/item/InAppPurchaseLockup.svelte new file mode 100644 index 0000000..29b7196 --- /dev/null +++ b/src/components/jet/item/InAppPurchaseLockup.svelte @@ -0,0 +1,74 @@ +<script lang="ts"> + import type { InAppPurchaseLockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import PlusIcon from '~/sf-symbols/plus.heavy.svg'; + + export let item: InAppPurchaseLockup; +</script> + +<article> + <div class="artwork-container"> + <PlusIcon class="plus-icon" aria-hidden="true" /> + <Artwork artwork={item.icon} profile="in-app-purchase" /> + </div> + + <div class="metadata-container"> + {#if item.title} + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + {/if} + + {#if item.productDescription} + <LineClamp clamp={1}> + <p>{item.productDescription}</p> + </LineClamp> + {/if} + + {#if item.offerDisplayProperties.titles} + <p> + {item.offerDisplayProperties.titles.discountUnownedParent || + item.offerDisplayProperties.titles.standard} + </p> + {/if} + </div> +</article> + +<style> + .artwork-container { + position: relative; + flex-shrink: 0; + width: 100%; + margin-bottom: 8px; + padding: 8%; + border-radius: var(--global-border-radius-small); + background: var(--systemQuinary); + } + + .artwork-container :global(.plus-icon) { + position: absolute; + top: 6%; + width: 9%; + inset-inline-end: 5%; + } + + .artwork-container :global(.artwork-component) { + border-radius: var(--global-border-radius-small) 43% + var(--global-border-radius-small) var(--global-border-radius-small); + } + + .metadata-container { + margin-inline-end: 16px; + } + + h3 { + font: var(--body-tall); + } + + p { + font: var(--callout-tall); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/LargeBrickItem.svelte b/src/components/jet/item/LargeBrickItem.svelte new file mode 100644 index 0000000..5ce9974 --- /dev/null +++ b/src/components/jet/item/LargeBrickItem.svelte @@ -0,0 +1,106 @@ +<script lang="ts"> + import type { Brick } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + export let item: Brick; + const artwork = + isRtl() && item.rtlArtwork ? item.rtlArtwork : item.artworks?.[0]; + const collectionIcon = item.collectionIcons?.[0]; + let artworkFallbackColor: string | null = null; + + const gradientOverlayColor: string = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : '#000'; + + if (!artwork) { + artworkFallbackColor = collectionIcon?.backgroundColor + ? colorAsString(collectionIcon.backgroundColor) + : '#000'; + } +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + {#if artwork} + <div class="artwork-container"> + <Artwork {artwork} profile="large-brick" /> + </div> + {:else} + <div + class="gradient-container" + style={`--color: ${artworkFallbackColor};`} + /> + {/if} + + <div class="text-container"> + <div class="metadata-container"> + {#if item.caption} + <LineClamp clamp={1}> + <h4>{item.caption}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={2}> + <h3>{@html sanitizeHtml(item.title)}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={2}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + </div> + </div> + + <GradientOverlay --color={gradientOverlayColor} /> + </HoverWrapper> +</LinkWrapper> + +<style> + .artwork-container { + width: 100%; + } + + .gradient-container { + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--color); + } + + .text-container { + position: absolute; + z-index: 2; + bottom: 0; + display: flex; + align-items: center; + width: 66%; + padding-inline: 20px; + padding-bottom: 20px; + color: var(--systemPrimary-onDark); + } + + h3 { + font: var(--title-1-emphasized); + text-wrap: balance; + } + + h4 { + font: var(--callout-emphasized); + margin-bottom: 3px; + } + + p { + font: var(--body-emphasized); + margin-top: 6px; + } +</style> diff --git a/src/components/jet/item/LargeHeroBreakoutItem.svelte b/src/components/jet/item/LargeHeroBreakoutItem.svelte new file mode 100644 index 0000000..d07eec8 --- /dev/null +++ b/src/components/jet/item/LargeHeroBreakoutItem.svelte @@ -0,0 +1,268 @@ +<script lang="ts"> + import { + type Artwork as JetArtworkType, + type LargeHeroBreakout, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment/types/optional'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import mediaQueries from '~/utils/media-queries'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import Video from '~/components/jet/Video.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + import { colorAsString, isRGBColor, isDark } from '~/utils/color'; + import { isRtl } from '~/utils/locale'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + export let item: LargeHeroBreakout; + + let profile: NamedProfile; + let artwork: JetArtworkType | undefined; + let gradientColor: string; + + const { + collectionIcons = [], + editorialDisplayOptions, + rtlArtwork, + video, + details: { callToActionButtonAction: action }, + } = item; + const canUseRTLArtwork = isRtl() && rtlArtwork; + const shouldShowCollectionIcons = + collectionIcons?.length > 1 && !editorialDisplayOptions.suppressLockup; + + $: artwork = + (canUseRTLArtwork ? rtlArtwork : item.artwork) || video?.preview; + $: doesArtworkHaveDarkBackground = + artwork?.backgroundColor && + isRGBColor(artwork.backgroundColor) && + isDark(artwork.backgroundColor); + $: isBackgroundDark = item.isMediaDark ?? doesArtworkHaveDarkBackground; + + $: profile = + $mediaQueries === 'xsmall' + ? 'large-hero-portrait-iphone' + : canUseRTLArtwork + ? 'large-hero-breakout-rtl' + : 'large-hero-breakout'; + + $: gradientColor = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : '#000'; +</script> + +<LinkWrapper {action}> + <HoverWrapper> + <div class="artwork-container"> + {#if video && $mediaQueries !== 'xsmall' && !canUseRTLArtwork} + <Video {video} {profile} autoplay loop useControls={false} /> + {:else if artwork} + <Artwork {artwork} {profile} /> + {/if} + </div> + + <div class="gradient" style="--color: {gradientColor};" /> + + <div + class="text-container" + class:on-dark={isBackgroundDark} + class:on-light={!isBackgroundDark} + > + {#if item.details?.badge} + <LineClamp clamp={1}> + <h4>{item.details.badge}</h4> + </LineClamp> + {/if} + + {#if item.details.title} + <LineClamp clamp={2}> + <h3>{@html sanitizeHtml(item.details.title)}</h3> + </LineClamp> + {/if} + + {#if item.details.description} + <LineClamp clamp={3}> + <p>{@html sanitizeHtml(item.details.description)}</p> + </LineClamp> + {/if} + + {#if isSome(action) && isFlowAction(action)} + <span class="link-container"> + {action.title} + <span aria-hidden="true"> + <SFSymbol name="chevron.forward" /> + </span> + </span> + {/if} + + {#if shouldShowCollectionIcons} + <ul class="collection-icons"> + {#each collectionIcons.slice(0, 6) as collectionIcon} + <li class="app-icon-container"> + <AppIcon icon={collectionIcon} /> + </li> + {/each} + </ul> + {/if} + </div> + </HoverWrapper> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .artwork-container { + width: 100%; + + @media (--range-small-up) { + aspect-ratio: 8 / 3; + } + } + + .artwork-container :global(.video-container) { + display: flex; + } + + .text-container { + position: absolute; + z-index: 2; + bottom: 0; + align-items: center; + width: 100%; + padding-inline: 20px; + padding-bottom: 20px; + text-wrap: pretty; + + @media (--range-small-up) { + width: 50%; + } + + @media (--range-large-up) { + width: 33%; + } + } + + .text-container.on-dark { + color: var(--systemPrimary-onDark); + + h4 { + color: var(--systemSecondary-onDark); + } + + :global(svg) { + fill: var(--systemPrimary-onDark); + } + } + + .text-container.on-light { + color: var(--systemPrimary-onLight); + + h4 { + color: var(--systemSecondary-onLight); + } + + :global(svg) { + fill: var(--systemPrimary-onLight); + } + } + + .link-container { + margin-top: 8px; + display: flex; + gap: 4px; + font: var(--body-emphasized); + + @media (--range-small-up) { + margin-top: 16px; + font: var(--title-2-emphasized); + } + } + + .link-container :global(svg) { + width: 8px; + height: 8px; + + @include rtl { + transform: rotate(180deg); + } + + @media (--range-small-up) { + width: 10px; + height: 10px; + } + } + + h3 { + text-wrap: balance; + font: var(--title-1-emphasized); + + @media (--range-small-up) { + font: var(--large-title-emphasized); + } + } + + h4 { + font: var(--subhead-emphasized); + + @media (--range-small-up) { + font: var(--callout-emphasized); + } + } + + p { + margin-top: 4px; + font: var(--body); + + @media (--range-small-up) { + margin-top: 8px; + font: var(--title-3); + } + } + + .collection-icons { + display: flex; + gap: 8px; + margin-top: 16px; + padding-top: 16px; + border-top: 2px solid var(--systemTertiary-onDark); + } + + .app-icon-container { + aspect-ratio: 1/1; + } + + .gradient { + --rotation: 35deg; + position: absolute; + z-index: 1; + width: 100%; + height: 100%; + filter: saturate(1.5) brightness(0.9); + background: linear-gradient( + var(--rotation), + var(--color) 20%, + transparent 50% + ); + + // In non-XS viewports with an RTL text direction, we flip the legibility gradient to + // accomodate the right-justified text. + @include rtl { + @media (--range-small-up) { + --rotation: -35deg; + } + } + + // In XS viewports, this component is renderd in a 3/4 card layout, so we always want the + // gradient to be at 0deg rotation, as it goes from botttom to top. + @media (--range-xsmall-down) { + --rotation: 0deg; + } + } +</style> diff --git a/src/components/jet/item/LargeImageLockupItem.svelte b/src/components/jet/item/LargeImageLockupItem.svelte new file mode 100644 index 0000000..1df51c2 --- /dev/null +++ b/src/components/jet/item/LargeImageLockupItem.svelte @@ -0,0 +1,130 @@ +<script lang="ts"> + import type { ImageLockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: ImageLockup; + + const color: string = item.artwork.backgroundColor + ? colorAsString(item.artwork.backgroundColor) + : '#000'; +</script> + +<LinkWrapper action={item.lockup.clickAction}> + <HoverWrapper> + <div class="container"> + <div class="artwork-container"> + <Artwork artwork={item.artwork} profile="large-image-lockup" /> + </div> + + {#if item.lockup} + <div + class="lockup-container" + class:on-dark={item.isDark} + class:on-light={!item.isDark} + > + {#if item.lockup.icon} + <div class="app-icon-container"> + <AppIcon icon={item.lockup.icon} /> + </div> + {/if} + + <div class="metadata-container"> + {#if item.lockup.heading} + <LineClamp clamp={1}> + <p>{item.lockup.heading}</p> + </LineClamp> + {/if} + + {#if item.lockup.title} + <LineClamp clamp={2}> + <h3>{item.lockup.title}</h3> + </LineClamp> + {/if} + + {#if item.lockup.subtitle} + <LineClamp clamp={1}> + <p>{item.lockup.subtitle}</p> + </LineClamp> + {/if} + </div> + </div> + {/if} + + <div class="gradient-container"> + <GradientOverlay --color={color} --height="85%" /> + </div> + </div> + </HoverWrapper> +</LinkWrapper> + +<style> + .artwork-container { + position: absolute; + z-index: -1; + width: 100%; + } + + .container { + width: 100%; + aspect-ratio: 16/9; + container-type: inline-size; + container-name: container; + } + + .gradient-container { + position: absolute; + z-index: -1; + top: 0; + left: 0; + width: 100%; + height: 100%; + } + + .lockup-container { + display: flex; + align-items: flex-end; + width: 100%; + height: 100%; + padding: 0 20px 20px; + } + + .lockup-container.on-dark { + color: var(--systemPrimary-onDark); + } + + .lockup-container.on-light { + color: var(--systemPrimary-onLight); + } + + @container container (max-width: 260px) { + .lockup-container { + padding: 0 10px 10px; + } + } + + .app-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 8px; + } + + h3 { + margin: 2px 0; + font: var(--title-1-emphasized); + } + + p { + font: var(--callout-emphasized); + } + + .lockup-container.on-dark p { + mix-blend-mode: plus-lighter; + } +</style> diff --git a/src/components/jet/item/LargeLockupItem.svelte b/src/components/jet/item/LargeLockupItem.svelte new file mode 100644 index 0000000..93adc6e --- /dev/null +++ b/src/components/jet/item/LargeLockupItem.svelte @@ -0,0 +1,121 @@ +<script lang="ts"> + import { + isFlowAction, + type FlowAction, + type Lockup, + } from '@jet-app/app-store/api/models'; + import type { Opt } from '@jet/environment'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Lockup; + const i18n = getI18n(); + const { clickAction } = item; + const destination: Opt<FlowAction> = isFlowAction(clickAction) + ? clickAction + : undefined; + + $: secondaryLine = item.editorialTagline || item.subtitle; +</script> + +<LinkWrapper action={destination}> + <article> + <div class="app-icon-container"> + <AppIcon + fixedWidth={false} + icon={item.icon} + profile="app-icon-large" + /> + </div> + + <div class="metadata-container"> + {#if item.heading} + <LineClamp clamp={2}> + <h4>{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={2}> + <h3>{item.title}</h3> + </LineClamp> + {/if} + + {#if !item.heading && secondaryLine} + <LineClamp clamp={1}> + <p>{secondaryLine}</p> + </LineClamp> + {/if} + + {#if item.tertiaryTitle} + <LineClamp clamp={1}> + <p class="tertiary-text">{item.tertiaryTitle}</p> + </LineClamp> + {/if} + </div> + + {#if destination} + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + {/if} + </article> +</LinkWrapper> + +<style> + article { + display: flex; + flex-direction: column; + min-height: 290px; + padding: 20px; + border-radius: var(--global-border-radius-large); + background: var(--systemPrimary-onDark); + box-shadow: var(--shadow-small); + } + + @media (prefers-color-scheme: dark) { + article { + background: var(--systemQuaternary); + } + } + + .app-icon-container { + --artwork-override-height: 100px; + --artwork-override-width: auto; + display: flex; + margin-bottom: 10px; + } + + .metadata-container { + flex-grow: 1; + } + + h3 { + margin-bottom: 3px; + font: var(--title-2-emphasized); + } + + h4 { + margin-bottom: 3px; + color: var(--systemSecondary); + font: var(--subhead-emphasized); + text-transform: uppercase; + } + + p { + margin: 3px 0; + font: var(--body); + color: var(--systemSecondary); + text-wrap: pretty; + } + + .tertiary-text { + font: var(--callout); + color: var(--systemTertiary); + } +</style> diff --git a/src/components/jet/item/LargeStoryCardItem.svelte b/src/components/jet/item/LargeStoryCardItem.svelte new file mode 100644 index 0000000..66079c2 --- /dev/null +++ b/src/components/jet/item/LargeStoryCardItem.svelte @@ -0,0 +1,38 @@ +<script lang="ts"> + import type { TodayCard } from '@jet-app/app-store/api/models'; + + import Hero from '~/components/hero/Hero.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + import mediaQueries from '~/utils/media-queries'; + import { isRtl } from '~/utils/locale'; + + export let item: TodayCard; + + let profile: NamedProfile; + + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: artwork = item.heroMedia?.artworks[0]; + $: video = isXSmallViewport ? null : item.heroMedia?.videos[0]; + $: ({ backgroundColor, clickAction, heading, inlineDescription, title } = + item); + $: profile = isXSmallViewport + ? 'large-hero-story-card-portrait' + : isRtl() + ? 'large-hero-story-card-rtl' + : 'large-hero-story-card'; +</script> + +<Hero + {artwork} + {backgroundColor} + {title} + {video} + action={clickAction} + eyebrow={heading} + subtitle={inlineDescription} + pinArtworkToVerticalMiddle={true} + pinArtworkToHorizontalEnd={true} + pinTextToVerticalStart={isRtl()} + profileOverride={profile} + isMediaDark={item.style !== 'white'} +/> diff --git a/src/components/jet/item/LinkableTextItem.svelte b/src/components/jet/item/LinkableTextItem.svelte new file mode 100644 index 0000000..a5a3e74 --- /dev/null +++ b/src/components/jet/item/LinkableTextItem.svelte @@ -0,0 +1,88 @@ +<script lang="ts"> + import type { LinkableText, Action } from '@jet-app/app-store/api/models'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: LinkableText; + + type Fragment = { + text: string; + action?: Action; + isTrailingPunctuation?: boolean; + }; + + const { + linkedSubstrings = {}, + styledText: { rawText }, + } = item; + + // `LinkableText` items contain a `rawText` string, and an object of `linkedSubstrings`, + // where the key of the object is the substring to replace in the `rawText` and whose value + // is the `Action` that the link should trigger. + // + // That means we have to render replace the keys from `linkedSubstrings` in the `rawText`. + // To do this, we build a regex to match all the strings that are supposed to be linked, + // then build an array of objects representing the fully text, with the `Action` appended + // to the fragments that need to be linked. + const fragmentsToLink = Object.keys(linkedSubstrings); + let fragments: Fragment[]; + + if (fragmentsToLink.length === 0) { + fragments = [{ text: rawText }]; + } else { + // Escapes regex-sensitive characters in the text, so characters like `.` or `+` don't act as regex operators + const cleanedFragmentsToLink = fragmentsToLink.map((fragment) => + fragment.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + ); + + const pattern = new RegExp( + `(${cleanedFragmentsToLink.join('|')})`, + 'g', + ); + + // After we split our text into an array representing the seqence of the raw text, with the + // linkable items as their own entries, we transform the array to contain include the linkable + // items actions, which we then use to determine if we want to render a `LinkWrapper` or plain-text. + fragments = rawText.split(pattern).map((fragment): Fragment => { + const action = linkedSubstrings[fragment]; + + if (action) { + return { action, text: fragment }; + } else { + const isTrailingPunctuation = /^[.,;:!?)\]}"”»']+$/.test( + fragment.trim(), + ); + + return { + isTrailingPunctuation, + text: fragment, + }; + } + }); + } +</script> + +{#each fragments as fragment} + {#if fragment.action} + <LinkWrapper + action={fragment.action} + includeExternalLinkArrowIcon={false} + > + {fragment.text} + </LinkWrapper> + {:else if fragment.isTrailingPunctuation} + <span class="trailing-punctuation">{fragment.text}</span> + {:else} + {@html sanitizeHtml(fragment.text)} + {/if} +{/each} + +<style> + span :global(a:hover) { + text-decoration: underline; + } + + .trailing-punctuation { + margin-inline-start: -0.45ch; + } +</style> diff --git a/src/components/jet/item/MediumImageLockupItem.svelte b/src/components/jet/item/MediumImageLockupItem.svelte new file mode 100644 index 0000000..8b93453 --- /dev/null +++ b/src/components/jet/item/MediumImageLockupItem.svelte @@ -0,0 +1,118 @@ +<script lang="ts"> + import type { ImageLockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: ImageLockup; + + const color: string = item.artwork.backgroundColor + ? colorAsString(item.artwork.backgroundColor) + : '#000'; +</script> + +<LinkWrapper action={item.lockup.clickAction}> + <div class="container"> + <HoverWrapper> + <div class="artwork-container"> + <Artwork artwork={item.artwork} profile="brick" /> + </div> + + {#if item.lockup} + <div + class="lockup-container" + class:on-dark={item.isDark} + class:on-light={!item.isDark} + > + {#if item.lockup.icon} + <div class="app-icon-container"> + <AppIcon icon={item.lockup.icon} /> + </div> + {/if} + + <div class="metadata-container"> + {#if item.lockup.heading} + <LineClamp clamp={1}> + <p class="eyebrow">{item.lockup.heading}</p> + </LineClamp> + {/if} + + {#if item.lockup.title} + <LineClamp clamp={2}> + <h3>{item.lockup.title}</h3> + </LineClamp> + {/if} + + {#if item.lockup.subtitle} + <LineClamp clamp={1}> + <p class="subtitle">{item.lockup.subtitle}</p> + </LineClamp> + {/if} + </div> + </div> + {/if} + + <GradientOverlay --color={color} --height="90%" /> + </HoverWrapper> + </div> +</LinkWrapper> + +<style> + .artwork-container { + width: 100%; + } + + .container { + container-type: inline-size; + container-name: container; + } + + .lockup-container { + position: absolute; + z-index: 2; + bottom: 0; + display: flex; + align-items: center; + width: 100%; + padding: 0 20px 20px; + } + + .lockup-container.on-dark { + color: var(--systemPrimary-onDark); + } + + .lockup-container.on-light { + color: var(--systemPrimary-onLight); + } + + @container container (max-width: 260px) { + .lockup-container { + padding: 0 10px 10px; + } + } + + .app-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 8px; + } + + h3 { + font: var(--title-3-emphasized); + } + + .eyebrow { + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: plus-lighter; + } + + .subtitle { + font: var(--callout-emphasized); + } +</style> diff --git a/src/components/jet/item/MediumLockupItem.svelte b/src/components/jet/item/MediumLockupItem.svelte new file mode 100644 index 0000000..be70acb --- /dev/null +++ b/src/components/jet/item/MediumLockupItem.svelte @@ -0,0 +1,96 @@ +<script lang="ts"> + import { + type FlowAction, + type Lockup, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + import type { Opt } from '@jet/environment'; + + export let item: Lockup; + + const i18n = getI18n(); + + const { clickAction } = item; + const destination: Opt<FlowAction> = isFlowAction(clickAction) + ? clickAction + : undefined; +</script> + +<LinkWrapper action={destination}> + <article> + <div class="app-icon-container"> + <AppIcon + icon={item.icon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + + <div class="metadata-container"> + {#if item.heading} + <span class="heading">{item.heading}</span> + {/if} + + {#if item.title} + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + + {#if destination} + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + {/if} + </div> + </article> +</LinkWrapper> + +<style> + article { + display: flex; + align-items: center; + } + + .app-icon-container { + flex-shrink: 0; + width: 85px; + margin-inline-end: 16px; + } + + .metadata-container { + margin-inline-end: 16px; + } + + h3 { + font: var(--title-3); + margin-bottom: 2px; + } + + p { + font: var(--callout); + color: var(--systemSecondary); + } + + .heading { + font: var(--callout-emphasized); + } + + .button-container { + margin-inline-start: auto; + margin-top: 8px; + } +</style> diff --git a/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte new file mode 100644 index 0000000..7b7807c --- /dev/null +++ b/src/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte @@ -0,0 +1,304 @@ +<script lang="ts"> + import { + isFlowAction, + type EditorialStoryCard, + type FlowAction, + } from '@jet-app/app-store/api/models'; + import type { Opt } from '@jet/environment'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + + export let item: EditorialStoryCard; + + let { + clickAction, + collectionIcons, + title, + lockup: { title: lockupTitle, subtitle, heading: lockupHeading } = {}, + } = item; + const i18n = getI18n(); + const hasMultipleCollectionIcons = (collectionIcons?.length ?? 0) > 1; + const destination: Opt<FlowAction> = + clickAction && isFlowAction(clickAction) ? clickAction : undefined; +</script> + +<LinkWrapper action={destination}> + <article> + {#if item.artwork} + <div class="artwork-container"> + <HoverWrapper element="div"> + <Artwork + artwork={item.artwork} + profile="editorial-story-card" + /> + </HoverWrapper> + </div> + {/if} + <div class="details-container"> + <div + class="title-container" + class:on-dark={item.isMediaDark} + class:on-light={!item.isMediaDark} + > + {#if item.badge} + <h4>{item.badge.title}</h4> + {/if} + + {#if item.title} + <h3>{@html sanitizeHtml(item.title)}</h3> + {/if} + + {#if item.description} + <p>{@html sanitizeHtml(item.description)}</p> + {/if} + </div> + + {#if collectionIcons && !item.editorialDisplayOptions.suppressLockup} + <div class="lockup-container"> + <ul class:with-multiple-icons={hasMultipleCollectionIcons}> + {#each collectionIcons as collectionIcon} + <li class="app-icon-container"> + <AppIcon + icon={collectionIcon} + fixedWidth={false} + profile={hasMultipleCollectionIcons + ? 'app-icon-medium' + : 'app-icon'} + /> + </li> + {/each} + </ul> + + {#if !hasMultipleCollectionIcons} + <div class="metadata-container"> + {#if lockupHeading} + <span class="lockup-eyebrow"> + {lockupHeading} + </span> + {/if} + + <!-- + Some cards with the lockup UI don't have a `lockup` property, + so we use the title of the item as a fallback. + --> + {#if lockupTitle || title} + <LineClamp clamp={1}> + <h4 class="lockup-title"> + {lockupTitle || title} + </h4> + </LineClamp> + {/if} + + {#if subtitle} + <LineClamp clamp={1}> + <p class="lockup-subtitle">{subtitle}</p> + </LineClamp> + {/if} + </div> + + {#if destination} + <div class="button-container"> + <span class="get-button transparent"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + {/if} + {/if} + </div> + {/if} + </div> + <div + class="blur-overlay" + style:--brightness={item.isMediaDark ? 0.75 : 1.25} + /> + </article> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + article { + position: relative; + overflow: hidden; + border-radius: var(--global-border-radius-large); + box-shadow: var(--shadow-medium); + aspect-ratio: 3/4; + container-type: inline-size; + container-name: card; + } + + .artwork-container { + position: absolute; + width: 100%; + height: 100%; + } + + .details-container { + display: flex; + flex-direction: column; + justify-content: end; + height: 100%; + border-radius: var(--global-border-radius-large); + overflow: hidden; + z-index: 1; + } + + .title-container { + padding: 20px; + z-index: 2; + } + + .title-container h3 { + margin-bottom: 2px; + font: var(--title-1-emphasized); + text-wrap: pretty; + } + + .title-container h4 { + font: var(--callout-emphasized); + } + + .on-dark { + color: var(--systemPrimary-onDark); + } + + .on-light { + color: var(--systemPrimary-onLight); + } + + .title-container.on-dark h4 { + color: var(--systemSecondary-onDark); + mix-blend-mode: plus-lighter; + } + + .title-container.on-light h4 { + color: var(--systemSecondary-onLight); + } + + .title-container.on-dark p { + font: var(--body); + color: var(--systemSecondary-onDark); + } + + .title-container.on-light p { + font: var(--body); + color: var(--systemSecondary-onLight); + } + + .lockup-container { + display: flex; + align-items: center; + min-height: 80px; + padding: 10px 20px; + color: var(--systemPrimary-onDark); + background-image: linear-gradient(rgba(0, 0, 0, 0.2) 0 0); + z-index: 2; + } + + .metadata-container { + flex-grow: 1; + margin-inline-end: 16px; + } + + .lockup-title { + font: var(--title-3-emphasized); + } + + .lockup-eyebrow { + color: var(--systemSecondary-onDark); + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: plus-lighter; + } + + .lockup-subtitle { + color: var(--systemSecondary-onDark); + font: var(--callout); + mix-blend-mode: plus-lighter; + } + + .app-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 16px; + } + + article:hover .blur-overlay { + height: 52%; + backdrop-filter: blur(70px) saturate(1.5) + brightness(calc(var(--brightness) * 0.9)); + } + + .blur-overlay { + position: absolute; + z-index: 1; + top: unset; + bottom: 0; + width: 100%; + height: 50%; + border-radius: var(--global-border-radius-large); + mask-image: linear-gradient( + 180deg, + rgba(255, 255, 255, 0) 5%, + rgba(0, 0, 0, 1) 50% + ); + backdrop-filter: blur(50px) saturate(1.5) + brightness((var(--brightness))); + transition-property: height, backdrop-filter; + transition-duration: 210ms; + transition-timing-function: ease-out; + } + + ul.with-multiple-icons { + width: 100%; + display: grid; + gap: 12px; + + .app-icon-container { + width: 100%; + margin-inline-end: unset; + } + } + + // In the following container queries, we are specifying column counts and hiding icons past + // that number to ensure a reasonable number of icons are shown for different size cards. + @container card (max-width: 300px) { + ul.with-multiple-icons { + // Think of "4" as the number of columns to show + grid-template-columns: repeat(4, 1fr); + } + + // And "5" as the number of columns to hide past + .app-icon-container:nth-child(n + 5) { + display: none; + } + } + + @container card (min-width: 300px) and (max-width: 400px) { + ul.with-multiple-icons { + grid-template-columns: repeat(5, 1fr); + } + + .app-icon-container:nth-child(n + 6) { + display: none; + } + } + + @container card (min-width: 400px) { + ul.with-multiple-icons { + grid-template-columns: repeat(6, 1fr); + } + + .app-icon-container:nth-child(n + 7) { + display: none; + } + } +</style> diff --git a/src/components/jet/item/MediumStoryCardItem.svelte b/src/components/jet/item/MediumStoryCardItem.svelte new file mode 100644 index 0000000..80ead7d --- /dev/null +++ b/src/components/jet/item/MediumStoryCardItem.svelte @@ -0,0 +1,27 @@ +<script lang="ts" context="module"> + import type { + EditorialStoryCard, + TodayCard, + } from '@jet-app/app-store/api/models'; + + export type Item = EditorialStoryCard | TodayCard; + + function isEditorialStoryCard(item: Item): item is EditorialStoryCard { + return 'artwork' in item; + } +</script> + +<script lang="ts"> + import EditorialStoryCardItem from '~/components/jet/item/MediumStoryCard/EditorialStoryCardItem.svelte'; + import SmallStoryCardWithMediaItem, { + isSmallStoryCardWithMediaItem, + } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte'; + + export let item: Item; +</script> + +{#if isEditorialStoryCard(item)} + <EditorialStoryCardItem {item} /> +{:else if isSmallStoryCardWithMediaItem(item)} + <SmallStoryCardWithMediaItem {item} /> +{/if} diff --git a/src/components/jet/item/MixedMediaLockupItem.svelte b/src/components/jet/item/MixedMediaLockupItem.svelte new file mode 100644 index 0000000..4874419 --- /dev/null +++ b/src/components/jet/item/MixedMediaLockupItem.svelte @@ -0,0 +1,39 @@ +<script lang="ts"> + import type { MixedMediaLockup } from '@jet-app/app-store/api/models'; + + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: MixedMediaLockup; + + let video = item.trailers?.[0]?.videos[0]; +</script> + +<div class="mixed-media-lockup-item"> + <div class="video-wrapper"> + {#if video} + <Video {video} profile="brick" shouldSuperimposePosterImage /> + {/if} + </div> + <SmallLockupItem {item} /> +</div> + +<style> + .mixed-media-lockup-item { + display: flex; + flex-direction: column; + gap: 8px; + } + + .video-wrapper { + --mixed-media-lockup-video-aspect-ratio: 16/9; + aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio); + overflow: hidden; + border-radius: 7px; + } + + .video-wrapper :global(video) { + aspect-ratio: var(--mixed-media-lockup-video-aspect-ratio); + object-fit: cover; + } +</style> diff --git a/src/components/jet/item/ParagraphShelfItem.svelte b/src/components/jet/item/ParagraphShelfItem.svelte new file mode 100644 index 0000000..9adf09c --- /dev/null +++ b/src/components/jet/item/ParagraphShelfItem.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { Paragraph } from '@jet-app/app-store/api/models'; + import he from 'he'; + + export let item: Paragraph; +</script> + +<p> + {@html he.decode(item.text)} +</p> + +<style> + p { + font: var(--title-2-medium); + color: var(--systemSecondary); + } + + p :global(b) { + color: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/item/PosterLockupItem.svelte b/src/components/jet/item/PosterLockupItem.svelte new file mode 100644 index 0000000..08b34e2 --- /dev/null +++ b/src/components/jet/item/PosterLockupItem.svelte @@ -0,0 +1,121 @@ +<script lang="ts"> + import type { PosterLockup } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import Video from '~/components/jet/Video.svelte'; + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + + export let item: PosterLockup; +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + <article> + <div class="background"> + {#if item.epicHeading} + <div class="title-container"> + <Artwork + hasTransparentBackground + artwork={item.epicHeading} + alt={item.heading} + profile="poster-title" + /> + </div> + {/if} + + {#if item.posterVideo} + <div class="video-container"> + <Video + autoplay + loop + video={item.posterVideo} + useControls={false} + profile="poster-lockup" + /> + </div> + {:else if item.posterArtwork} + <div class="artwork-container"> + <Artwork + artwork={item.posterArtwork} + profile="poster-lockup" + /> + </div> + {/if} + </div> + + <div class="content"> + <div class="logo-container"> + <AppleArcadeLogo aria-label={item.heading} /> + </div> + + <span> + {item.footerText} + {#if item.tertiaryTitle} + | {item.tertiaryTitle} + {/if} + </span> + </div> + </article> + </HoverWrapper> +</LinkWrapper> + +<style> + article { + position: relative; + width: 100%; + aspect-ratio: 16/9; + overflow: hidden; + color: var(--systemPrimary-onDark); + border-radius: var(--global-border-radius-large); + container-type: inline-size; + container-name: poster-lockup-item; + } + + .title-container { + position: absolute; + z-index: 2; + width: 100%; + } + + .background { + position: absolute; + z-index: -1; + width: 100%; + line-height: 0; + } + + .content { + display: flex; + align-items: center; + flex-direction: column; + justify-content: space-between; + height: 100%; + padding: 12px 0; + font: var(--body); + background: linear-gradient( + 0deg, + rgba(0, 0, 0, 0.5) 0%, + rgba(255, 255, 255, 0) 25%, + rgba(255, 255, 255, 0) 50%, + rgba(255, 255, 255, 0) 80%, + rgba(0, 0, 0, 0.4) 100% + ); + } + + .logo-container { + width: 62px; + margin-bottom: 10px; + line-height: 0; + } + + @container poster-lockup-item (min-width: 550px) { + .logo-container { + width: 78px; + } + } + + .logo-container :global(path) { + color: var(--systemPrimary-onDark); + } +</style> diff --git a/src/components/jet/item/PrivacyHeaderItem.svelte b/src/components/jet/item/PrivacyHeaderItem.svelte new file mode 100644 index 0000000..f9611a6 --- /dev/null +++ b/src/components/jet/item/PrivacyHeaderItem.svelte @@ -0,0 +1,41 @@ +<script lang="ts"> + import type { PrivacyHeader } from '@jet-app/app-store/api/models'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let item: PrivacyHeader; +</script> + +<div> + <p> + <LinkableTextItem item={item.bodyText} /> + </p> + + {#if item.supplementaryItems.length} + <div class="supplementary-items-container"> + {#each item.supplementaryItems as supItem} + <p> + <LinkableTextItem item={supItem.bodyText} /> + </p> + {/each} + </div> + {/if} +</div> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } + + .supplementary-items-container { + display: flex; + flex-direction: column; + gap: 10px; + padding: 20px 0 0; + margin-top: 20px; + border-top: 1px solid var(--systemGray4); + } +</style> diff --git a/src/components/jet/item/PrivacyTypeItem.svelte b/src/components/jet/item/PrivacyTypeItem.svelte new file mode 100644 index 0000000..5e63966 --- /dev/null +++ b/src/components/jet/item/PrivacyTypeItem.svelte @@ -0,0 +1,193 @@ +<script lang="ts"> + import type { PrivacyType } from '@jet-app/app-store/api/models'; + + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let item: PrivacyType; + export let isDetailView: boolean = false; +</script> + +<article class:is-detail-view={isDetailView}> + {#if item.artwork && isSystemImageArtwork(item.artwork)} + <span class="icon-container" aria-hidden="true"> + <SystemImage artwork={item.artwork} /> + </span> + {/if} + + <h2>{item.title}</h2> + <p>{item.detail}</p> + + <ul class:grid={item.categories.length > 1 && !isDetailView}> + {#each item.categories as category} + <li> + {#if isSystemImageArtwork(category.artwork)} + <span aria-hidden="true" class="category-icon-container"> + <SystemImage artwork={category.artwork} /> + </span> + {/if} + {category.title} + </li> + {/each} + </ul> + + {#each item.purposes as purpose} + <section class="purpose-section"> + <h3>{purpose.title}</h3> + + {#each purpose.categories as category} + <li class="purpose-category"> + {#if isSystemImageArtwork(category.artwork)} + <span + aria-hidden="true" + class="category-icon-container" + > + <SystemImage artwork={category.artwork} /> + </span> + {/if} + + <span class="category-title">{category.title}</span> + + <ul class="privacy-data-types"> + {#each category.dataTypes as type} + <li>{type}</li> + {/each} + </ul> + </li> + {/each} + </section> + {/each} +</article> + +<style lang="scss"> + @use 'amp/stylekit/core/border-radiuses' as *; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + article { + display: flex; + flex-direction: column; + height: 100%; + padding: 30px; + gap: 8px; + text-align: center; + font: var(--body-tall); + border-radius: $global-border-radius-rounded-large; + background-color: var(--systemQuinary); + + &.is-detail-view { + padding: 20px 0 0; + margin-top: 20px; + text-align: left; + border-radius: 0; + background-color: transparent; + border-top: 1px solid var(--defaultLine); + } + } + + .icon-container { + width: 30px; + margin: 0 auto; + + .is-detail-view & { + display: block; + width: 32px; + margin: 0; + } + } + + .icon-container :global(svg) { + width: 100%; + fill: var(--keyColor); + } + + h2 { + font: var(--title-3-emphasized); + + .is-detail-view & { + font: var(--title-2-emphasized); + } + } + + p { + text-wrap: pretty; + font: var(--body-tall); + color: var(--systemSecondary); + } + + .grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + } + + li { + display: flex; + align-items: center; + justify-content: center; + text-align: start; + padding: 4px 0; + gap: 8px; + + .is-detail-view & { + justify-content: start; + } + } + + .category-title { + font: var(--title-3); + } + + .grid li { + justify-content: start; + } + + .category-icon-container { + display: inline-flex; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + + .is-detail-view & { + display: flex; + align-items: center; + } + } + + .category-icon-container :global(svg) { + width: 20px; + + .is-detail-view & { + width: 20px; + height: 18px; + } + } + + .purpose-section { + border-top: 1px solid var(--defaultLine); + padding-top: 16px; + } + + .purpose-section + .purpose-section { + margin-top: 4px; + } + + .purpose-section h3 { + margin-bottom: 8px; + } + + .purpose-category { + display: grid; + grid-template-areas: + 'icon title' + '. types'; + align-items: center; + } + + .privacy-data-types { + grid-area: types; + color: var(--systemSecondary); + font: var(--body); + } +</style> diff --git a/src/components/jet/item/ProductBadgeItem.svelte b/src/components/jet/item/ProductBadgeItem.svelte new file mode 100644 index 0000000..fa32e6f --- /dev/null +++ b/src/components/jet/item/ProductBadgeItem.svelte @@ -0,0 +1,188 @@ +<script lang="ts"> + import type { Badge } from '@jet-app/app-store/api/models'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import StarRating from '~/components/StarRating.svelte'; + import GameController from '~/sf-symbols/gamecontroller.fill.svg'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import ContentRatingBadge, { + isContentRatingBadge, + } from '../badge/ContentRatingBadge.svelte'; + + export let item: Badge; + + const { artwork, content, type } = item; + + $: isParagraph = type === 'paragraph'; + $: isRating = type === 'rating'; + $: isEditorsChoice = type === 'editorsChoice'; + $: isController = type === 'controller'; + $: hasImageArtwork = artwork && !isSystemImageArtwork(artwork); +</script> + +<LinkWrapper withoutLabel action={item.clickAction}> + <div class="badge-container"> + <div class="badge"> + <div class="badge-dt" role="term"> + <LineClamp clamp={1}> + {item.heading} + </LineClamp> + </div> + + <div class="badge-dd" role="definition"> + {#if isContentRatingBadge(item)} + <ContentRatingBadge badge={item} /> + {:else if isParagraph} + <span class="text-container">{content.paragraphText}</span> + {:else if isRating && !content.rating} + <span class="text-container"> + {content.ratingFormatted} + </span> + {:else if isEditorsChoice} + <span class="editors-choice"> + <SFSymbol name="laurel.leading" ariaHidden={true} /> + + <span> + <LineClamp clamp={2}> + {item.accessibilityTitle} + </LineClamp> + </span> + + <SFSymbol name="laurel.trailing" ariaHidden={true} /> + </span> + {:else if artwork && hasImageArtwork} + <div class="artwork-container" aria-hidden="true"> + <Artwork + {artwork} + profile="app-icon" + hasTransparentBackground + /> + </div> + {:else if artwork && isSystemImageArtwork(artwork)} + <div class="icon-container color" aria-hidden="true"> + <SystemImage {artwork} /> + </div> + {:else if isController} + <div class="icon-container" aria-hidden="true"> + <GameController /> + </div> + {/if} + + {#if isRating && content.rating} + <span class="text-container" aria-hidden="true"> + {content.ratingFormatted} + </span> + <StarRating rating={content.rating} /> + {:else} + <LineClamp clamp={1}>{item.caption}</LineClamp> + {/if} + </div> + </div> + </div> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .badge-container { + --color: var(--systemGray3-onDark); + --accent-color: var(--systemSecondary); + display: flex; + align-items: center; + flex-direction: column; + transition: filter 210ms ease-in; + + @media (prefers-color-scheme: dark) { + --color: var(--systemGray3-onLight); + } + } + + .badge { + text-align: center; + } + + .artwork-container { + height: 25px; + aspect-ratio: 1/1; + margin: 4px 0 2px; + opacity: 0.7; + + @media (prefers-color-scheme: dark) { + filter: invert(1); + } + } + + .icon-container { + display: flex; + width: 35px; + height: 25px; + margin: 4px 0 2px; + line-height: 0; + } + + .icon-container.color { + filter: brightness(1); + } + + .badge-dt { + text-transform: uppercase; + font: var(--subhead-emphasized); + color: var(--accent-color); + margin-bottom: 4px; + } + + .text-container { + height: 25px; + margin: 4px 0 2px; + font: var(--title-1-emphasized); + color: var(--color); + } + + .editors-choice { + display: flex; + align-items: center; + justify-content: center; + height: 30px; + + :global(svg) { + height: 20px; + flex-shrink: 0; + + @include rtl { + transform: rotateY(180deg); + } + } + + @media (--range-medium-only) { + gap: 2px; + } + + :global(svg path:not([fill='none'])) { + fill: var(--color); + } + } + + .editors-choice span { + width: 50%; + font: var(--subhead-medium); + + @media (--range-medium-only) { + width: 55%; + } + } + + .badge-dd { + --fill-color: var(--color); + display: flex; + align-items: center; + flex-direction: column; + font: var(--subhead-tall); + color: var(--color); + gap: 4px; + } +</style> diff --git a/src/components/jet/item/ProductCapabilityItem.svelte b/src/components/jet/item/ProductCapabilityItem.svelte new file mode 100644 index 0000000..21e97cd --- /dev/null +++ b/src/components/jet/item/ProductCapabilityItem.svelte @@ -0,0 +1,84 @@ +<script lang="ts"> + import { + type ProductCapability, + type ProductCapabilityType, + } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + type CapabilityIcons = Record<ProductCapabilityType, string | undefined>; + + const capabilityIcons: CapabilityIcons = { + gameCenter: '/assets/images/supports/supports-GameCenter@2x.png', + siri: '/assets/images/supports/supports-Siri@2x.png', + wallet: '/assets/images/supports/supports-Wallet@2x.png', + controllers: '/assets/images/supports/supports-GameController@2x.png', + familySharing: '/assets/images/supports/supports-FamilySharing@2x.png', + sharePlay: '/assets/images/supports/supports-Shareplay@2x.png', + spatialControllers: + '/assets/images/supports/supports-SpatialController@2x.png', + safariExtensions: '/assets/images/supports/supports-Safari@2x.png', + }; + + export let item: ProductCapability; +</script> + +<article> + <div class="capability-icon-container"> + <img + src={capabilityIcons[item.type]} + class="capability-icon" + alt="" + aria-hidden="true" + /> + </div> + + <div class="metadata-container"> + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + + <p> + <LinkableTextItem item={item.caption} /> + </p> + </div> +</article> + +<style> + article { + display: flex; + align-items: center; + } + + .capability-icon-container { + flex-shrink: 0; + width: 48px; + margin-inline-end: 16px; + } + + .capability-icon { + margin-top: 2px; + min-width: 46px; + height: 46px; + } + + .metadata-container { + margin-inline-end: 16px; + } + + .metadata-container :global(a) { + color: var(--keyColor); + } + + h3 { + color: var(--systemPrimary); + font-size: 1em; + margin-bottom: 1px; + } + + p { + color: var(--systemSecondary); + font: var(--body-tall); + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte new file mode 100644 index 0000000..516ed32 --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaMacItem.svelte @@ -0,0 +1,31 @@ +<script lang="ts"> + import type { ProductMediaItem } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; +</script> + +{#if item.screenshot} + <article> + <Artwork artwork={item.screenshot} profile="screenshot-mac" /> + </article> +{:else if item.video} + <article> + <Video autoplay video={item.video} profile="screenshot-mac" /> + </article> +{/if} + +<style> + article { + overflow: hidden; + } + + article :global(.video) { + aspect-ratio: 16/10; + } + + article :global(video) { + object-fit: cover; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte new file mode 100644 index 0000000..6b9886c --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaPadItem.svelte @@ -0,0 +1,89 @@ +<script lang="ts"> + import type { + ProductMediaItem, + MediaType, + } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; + export let hasPortraitMedia: boolean; + export let mediaType: MediaType | undefined; +</script> + +{#if item.screenshot || item.video} + <article> + <div + class="artwork-container" + class:ipad-pro-2018={mediaType === 'ipadPro_2018'} + class:ipad-11={mediaType === 'ipad_11'} + class:portrait={hasPortraitMedia} + > + {#if item.screenshot} + <Artwork + artwork={item.screenshot} + profile={hasPortraitMedia + ? 'screenshot-pad-portrait' + : 'screenshot-pad'} + /> + {:else if item.video} + <Video + autoplay + video={item.video} + profile={hasPortraitMedia + ? 'screenshot-pad-portrait' + : 'screenshot-pad'} + /> + {/if} + </div> + </article> +{/if} + +<style> + .artwork-container, + .artwork-container :global(video) { + mask-position: center; + mask-repeat: no-repeat; + mask-size: contain; + border-radius: 1.3% / 1.9%; + overflow: hidden; + + /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */ + transform: translateZ(0); + } + + .artwork-container.portrait { + aspect-ratio: 3/4; + background: var(--systemQuaternary); + } + + .artwork-container.portrait, + .artwork-container.portrait :global(video) { + border-radius: 1.9% / 1.3%; + } + + .ipad-pro-2018, + .ipad-pro-2018 :global(video) { + mask-image: url('/assets/images/masks/ipad-pro-2018-mask-landscape.svg'); + } + + .ipad-pro-2018.portrait, + .ipad-pro-2018.portrait :global(video) { + mask-image: url('/assets/images/masks/ipad-pro-2018-mask.svg'); + } + + .ipad-11, + .ipad-11 :global(video) { + mask-image: url('/assets/images/masks/ipad-11-mask-landscape.svg'); + } + + .ipad-11.portrait, + .ipad-11.portrait :global(video) { + mask-image: url('/assets/images/masks/ipad-11-mask.svg'); + } + + .artwork-container :global(video):fullscreen { + mask-image: none; + border-radius: 0; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte new file mode 100644 index 0000000..255b663 --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte @@ -0,0 +1,142 @@ +<script lang="ts"> + import type { + ProductMediaItem, + MediaType, + } from '@jet-app/app-store/api/models'; + import { getAspectRatio } from '@amp/web-app-components/src/components/Artwork/utils/artProfile'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import type { NamedProfile } from '~/config/components/artwork'; + + export let item: ProductMediaItem; + export let hasPortraitMedia: boolean; + export let mediaType: MediaType | undefined; + + const getArtworkProfile = ( + mediaType: MediaType | undefined, + hasPortraitMedia: boolean, + ): NamedProfile => { + const suffix = hasPortraitMedia ? '_portrait' : ''; + + // Map specific media types to their artwork profile names + const mediaTypeProfiles: Record<string, string> = { + iphone_6_5: 'screenshot-iphone_6_5', + iphone_5_8: 'screenshot-iphone_5_8', + iphone_d74: 'screenshot-iphone_d74', + }; + + const baseProfile = + mediaType && mediaTypeProfiles[mediaType] + ? mediaTypeProfiles[mediaType] + : 'screenshot-phone'; + + return `${baseProfile}${suffix}` as NamedProfile; + }; + + $: isLandscapeScreenshot = + item.screenshot && item.screenshot.width > item.screenshot.height; + $: profile = getArtworkProfile(mediaType, !isLandscapeScreenshot); + $: restOfShelfAspectRatio = getAspectRatio( + getArtworkProfile(mediaType, hasPortraitMedia), + ); +</script> + +{#if item.screenshot || item.video} + <article + class:with-rotated-artwork={isLandscapeScreenshot && hasPortraitMedia} + style:--aspect-ratio={`${restOfShelfAspectRatio}`} + > + <div + class="artwork-container" + class:iphone-6-5={mediaType === 'iphone_6_5'} + class:iphone-5-8={mediaType === 'iphone_5_8'} + class:iphone-d74={mediaType === 'iphone_d74'} + class:portrait={hasPortraitMedia} + > + {#if item.screenshot} + <Artwork + {profile} + artwork={item.screenshot} + disableAutoCenter={true} + withoutBorder={true} + /> + {:else if item.video} + <Video autoplay video={item.video} {profile} /> + {/if} + </div> + </article> +{/if} + +<style> + article.with-rotated-artwork { + position: relative; + aspect-ratio: var(--aspect-ratio); + } + + /* + * For iPhone screenshots that are landscape, but in a shelf/list with portrait screenshots, + * as denoted by `hasPortraitMedia`, we rotate the landscape screenshot to be in the portrait + * orientation, and scale it up so it fills the container. + */ + article.with-rotated-artwork .artwork-container { + position: absolute; + top: 50%; + left: 50%; + height: auto; + width: calc((1 / var(--aspect-ratio)) * 100%); + transform: translate(-50%, -50%) rotate(-90deg); + transform-origin: center; + } + + .artwork-container, + .artwork-container :global(video) { + mask-position: center; + mask-repeat: no-repeat; + mask-size: 100%; + border-radius: 20px; + overflow: hidden; + + /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */ + transform: translateZ(0); + } + + .iphone-5-8, + .iphone-5-8 :global(video) { + /* need to confirm with design for correct value */ + border-radius: 23px; + mask-image: url('/assets/images/masks/iphone-5-8-mask-landscape.svg'); + } + + .iphone-5-8.portrait, + .iphone-5-8.portrait :global(video) { + mask-image: url('/assets/images/masks/iphone-5-8-mask.svg'); + } + + .iphone-6-5, + .iphone-6-5 :global(video) { + /* need to confirm with design for correct value */ + border-radius: 21px; + mask-image: url('/assets/images/masks/iphone-6-5-mask-landscape.svg'); + } + + .iphone-6-5.portrait, + .iphone-6-5.portrait :global(video) { + mask-image: url('/assets/images/masks/iphone-6-5-mask.svg'); + } + + .iphone-d74, + .iphone-d74 :global(video) { + border-radius: 5.7% / 12.8%; + } + + .iphone-d74.portrait, + .iphone-d74.portrait :global(video) { + border-radius: 12.8% / 5.7%; + } + + .artwork-container :global(video):fullscreen { + mask-image: none; + border-radius: 0; + object-fit: contain; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte new file mode 100644 index 0000000..7f7fd7a --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaTVItem.svelte @@ -0,0 +1,34 @@ +<script lang="ts"> + import type { ProductMediaItem } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; +</script> + +{#if item.screenshot || item.video} + <article> + <div class="artwork-container"> + {#if item.screenshot} + <Artwork artwork={item.screenshot} profile="screenshot-tv" /> + {:else if item.video} + <Video autoplay video={item.video} profile="screenshot-tv" /> + {/if} + </div> + </article> +{/if} + +<style> + .artwork-container, + .artwork-container :global(video) { + border-radius: 1.3% / 1.9%; + overflow: hidden; + + /* This `transform` is required to make the `overflow: hidden` clip properly on Chrome */ + transform: translateZ(0); + } + + .artwork-container :global(video):fullscreen { + border-radius: 0; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte new file mode 100644 index 0000000..e893dd6 --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte @@ -0,0 +1,38 @@ +<script lang="ts"> + import type { ProductMediaItem } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: ProductMediaItem; +</script> + +{#if item.screenshot || item.video} + <article> + <div class="artwork-container"> + {#if item.screenshot} + <Artwork + artwork={item.screenshot} + profile="screenshot-vision" + /> + {:else if item.video} + <Video + autoplay + video={item.video} + profile="screenshot-vision" + /> + {/if} + </div> + </article> +{/if} + +<style> + .artwork-container, + .artwork-container :global(video) { + border-radius: 20px; + overflow: hidden; + } + + .artwork-container :global(video):fullscreen { + border-radius: 0; + } +</style> diff --git a/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte new file mode 100644 index 0000000..0a4b50e --- /dev/null +++ b/src/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte @@ -0,0 +1,50 @@ +<script lang="ts"> + import type { + ProductMediaItem, + MediaType, + } from '@jet-app/app-store/api/models'; + import Artwork from '~/components/Artwork.svelte'; + + export let item: ProductMediaItem; + export let mediaType: MediaType | undefined; +</script> + +{#if item.screenshot} + <article> + <div + class="artwork-container" + class:apple-watch-2018={mediaType === 'appleWatch_2018'} + class:apple-watch-2021={mediaType === 'appleWatch_2021'} + class:apple-watch-2022={mediaType === 'appleWatch_2022'} + class:apple-watch-2024={mediaType === 'appleWatch_2024'} + > + <Artwork artwork={item.screenshot} profile="screenshot-watch" /> + </div> + </article> +{/if} + +<style> + .artwork-container { + mask-position: center; + mask-size: contain; + mask-repeat: no-repeat; + border-radius: 12px; + overflow: hidden; + } + + .apple-watch-2018 { + mask-image: url('/assets/images/masks/apple-watch-2018-mask.svg'); + } + + .apple-watch-2021 { + mask-image: url('/assets/images/masks/apple-watch-2021-mask.svg'); + } + + .apple-watch-2022 { + mask-image: url('/assets/images/masks/apple-watch-2022-mask.svg'); + } + + .apple-watch-2024 { + mask-image: url('/assets/images/masks/apple-watch-2024-mask.svg'); + } +</style> diff --git a/src/components/jet/item/ProductPageLinkItem.svelte b/src/components/jet/item/ProductPageLinkItem.svelte new file mode 100644 index 0000000..be4bb16 --- /dev/null +++ b/src/components/jet/item/ProductPageLinkItem.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import { + type ProductPageLink, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import { isExternalUrlAction } from '~/jet/models/'; + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import ExternalURLAction from '~/components/jet/action/ExternalUrlAction.svelte'; + + export let item: ProductPageLink; + + const clickAction = item.clickAction; + + $: canRenderContainer = + isFlowAction(clickAction) || isExternalUrlAction(clickAction); +</script> + +{#if canRenderContainer} + <div class="product-link-container"> + {#if isFlowAction(clickAction)} + <FlowAction destination={clickAction}> + {item.text} + </FlowAction> + {:else if isExternalUrlAction(clickAction)} + <ExternalURLAction destination={clickAction}> + {item.text} + </ExternalURLAction> + {/if} + </div> +{/if} + +<style> + .product-link-container { + @media (--range-xsmall-down) { + padding: 10px 0; + } + } + + .product-link-container :global(a) { + display: inline-flex; + align-items: center; + color: var(--keyColor); + text-decoration: none; + gap: 6px; + + &:hover { + text-decoration: underline; + } + + @media (--range-xsmall-down) { + font-size: 18px; + gap: 8px; + } + } + + .product-link-container :global(a) :global(.external-link-arrow) { + width: 7px; + height: 7px; + fill: var(--keyColor); + margin-top: 3px; + + @media (--range-xsmall-down) { + width: 10px; + height: 10px; + margin-top: 2px; + } + } +</style> diff --git a/src/components/jet/item/ProductRatingsItem.svelte b/src/components/jet/item/ProductRatingsItem.svelte new file mode 100644 index 0000000..0345993 --- /dev/null +++ b/src/components/jet/item/ProductRatingsItem.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import type { Ratings } from '@jet-app/app-store/api/models'; + + import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte'; + import { getJet } from '~/jet/svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Ratings; + + const i18n = getI18n(); + const jet = getJet(); + const numberOfRatings = jet.localization.formattedCount( + item.totalNumberOfRatings, + ); +</script> + +<article> + {#if item.totalNumberOfRatings === 0} + {item.status} + {:else} + <RatingComponent + averageRating={jet.localization.decimal(item.ratingAverage, 1)} + ratingCount={item.totalNumberOfRatings} + ratingCountText={$i18n.t('ASE.Web.AppStore.Ratings.CountText', { + numberOfRatings: numberOfRatings, + })} + totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')} + ratingCountsList={item.ratingCounts} + /> + {/if} +</article> + +<style> + article { + --ratingBarColor: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte new file mode 100644 index 0000000..2bb6a06 --- /dev/null +++ b/src/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte @@ -0,0 +1,99 @@ +<script lang="ts" context="module"> + import type { + EditorsChoice, + ProductReview, + } from '@jet-app/app-store/api/models'; + + interface EditorsChoiceReview extends ProductReview { + sourceType: 'editorsChoice'; + review: EditorsChoice; + } + + export function isEditorsChoiceReviewItem( + productReview: ProductReview, + ): productReview is EditorsChoiceReview { + return productReview.sourceType === 'editorsChoice'; + } +</script> + +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte'; + import EditorsChoiceBadge from '~/components/EditorsChoiceBadge.svelte'; + import { getJet } from '~/jet'; + import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics'; + + export let item: EditorsChoiceReview; + export let isDetailView: boolean = false; + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const translateFn = (key: string) => $i18n.t(key); + const i18n = getI18n(); + const jet = getJet(); + + const handleCloseModal = () => modalComponent?.close(); + const handleOpenModal = () => { + modalComponent?.showModal(); + jet.recordCustomMetricsEvent({ + eventType: 'dialog', + dialogId: 'more', + targetId: CUSTOMER_REVIEW_MODAL_ID, + dialogType: 'button', + }); + }; +</script> + +<article class:is-detail-view={isDetailView}> + <EditorsChoiceBadge + --font={isDetailView + ? 'var(--large-title-emphasized)' + : 'var(--title-1-emphasized)'} + /> + + {#if isDetailView} + <p>{item.review.notes}</p> + {:else} + <Truncate + {translateFn} + lines={4} + text={item.review.notes} + title={$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')} + isPortalModal={true} + on:openModal={handleOpenModal} + /> + {/if} +</article> + +{#if !isDetailView} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleCloseModal} + title={null} + subtitle={null} + targetId={CUSTOMER_REVIEW_MODAL_ID} + > + <svelte:fragment slot="content"> + <svelte:self {item} isDetailView={true} /> + </svelte:fragment> + </ContentModal> + </Modal> +{/if} + +<style> + article:not(.is-detail-view) { + height: 186px; + padding: 20px; + background-color: var(--systemQuinary); + border-radius: var(--global-border-radius-xlarge); + } + + article :global(.more) { + --moreTextColorOverride: var(--keyColor); + --moreFontOverride: var(--body); + text-transform: lowercase; + } +</style> diff --git a/src/components/jet/item/ProductReview/UserReviewItem.svelte b/src/components/jet/item/ProductReview/UserReviewItem.svelte new file mode 100644 index 0000000..472dd1f --- /dev/null +++ b/src/components/jet/item/ProductReview/UserReviewItem.svelte @@ -0,0 +1,25 @@ +<script lang="ts" context="module"> + import { + type Review as ReviewModel, + ProductReview, + } from '@jet-app/app-store/api/models'; + + interface UserReview extends ProductReview { + sourceType: 'user'; + review: ReviewModel; + } + + export function isUserReviewItem( + productReview: ProductReview, + ): productReview is UserReview { + return productReview.sourceType === 'user'; + } +</script> + +<script lang="ts"> + import ReviewItem from '~/components/jet/item/ReviewItem.svelte'; + + export let item: UserReview; +</script> + +<ReviewItem item={item.review} /> diff --git a/src/components/jet/item/ReviewItem.svelte b/src/components/jet/item/ReviewItem.svelte new file mode 100644 index 0000000..7f406c8 --- /dev/null +++ b/src/components/jet/item/ReviewItem.svelte @@ -0,0 +1,237 @@ +<script lang="ts"> + import type { Review as ReviewModel } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import Truncate from '@amp/web-app-components/src/components/Truncate/Truncate.svelte'; + import StarRating from '~/components/StarRating.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import { getI18n } from '~/stores/i18n'; + import { getJet } from '~/jet/svelte'; + import { + escapeHtml, + stripUnicodeWhitespace, + } from '~/utils/string-formatting'; + import { CUSTOMER_REVIEW_MODAL_ID } from '~/utils/metrics'; + + export let item: ReviewModel; + export let isDetailView: boolean = false; + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const jet = getJet(); + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + + const handleCloseModal = () => modalComponent?.close(); + const handleOpenModal = () => { + modalComponent?.showModal(); + jet.recordCustomMetricsEvent({ + eventType: 'dialog', + dialogId: 'more', + targetId: CUSTOMER_REVIEW_MODAL_ID, + dialogType: 'button', + }); + }; + + $: ({ id, reviewerName, rating, contents, title, date, response } = item); + $: dateForDisplay = jet.localization.timeAgo(new Date(date)); + $: dateForAttribute = new Date(date).toISOString(); + $: titleId = `review-${id}-title`; + $: maximumLinesForReview = response ? 3 : 5; + $: responseDateForDisplay = + response && jet.localization.timeAgo(new Date(response.date)); + $: responseDateForAttribute = + response && new Date(response.date).toISOString(); + $: reviewContents = stripUnicodeWhitespace(escapeHtml(contents)); + $: responseContents = + response && stripUnicodeWhitespace(escapeHtml(response.contents)); +</script> + +<article class:is-detail-view={isDetailView} aria-labelledby={titleId}> + <div class="header"> + <div class="title-and-rating-container"> + {#if !isDetailView} + <h3 id={titleId} class="title"> + <LineClamp clamp={1}> + {title} + </LineClamp> + </h3> + {/if} + + <StarRating + {rating} + --fill-color="var(--systemOrange)" + --star-size={isDetailView ? '24px' : '12px'} + /> + </div> + + <div class="review-header"> + <time class="date" datetime={dateForAttribute}> + {dateForDisplay} + </time> + + <LineClamp clamp={1}> + <p class="author"> + {reviewerName} + </p> + </LineClamp> + </div> + </div> + + {#if isDetailView} + <p> + {@html sanitizeHtml(reviewContents, { + allowedTags: [''], + keepChildrenWhenRemovingParent: true, + })} + + {#if response} + <div class="developer-response-container"> + <div class="developer-response-header"> + <span class="developer-response-heading"> + {$i18n.t( + 'ASE.Web.AppStore.Review.DeveloperResponse', + )} + </span> + + <time class="date" datetime={responseDateForAttribute}> + {responseDateForDisplay} + </time> + </div> + + {@html sanitizeHtml(responseContents, { + allowedTags: [''], + keepChildrenWhenRemovingParent: true, + })} + </div> + {/if} + </p> + {:else} + <div class="content"> + <Truncate + on:openModal={handleOpenModal} + {title} + lines={maximumLinesForReview} + {translateFn} + text={reviewContents} + isPortalModal={true} + /> + + {#if item.response} + <div class="developer-response-container"> + <span class="developer-response-heading"> + {$i18n.t('ASE.Web.AppStore.Review.DeveloperResponse')} + </span> + <Truncate + on:openModal={handleOpenModal} + {title} + {translateFn} + lines={1} + text={responseContents} + isPortalModal={true} + /> + </div> + {/if} + </div> + {/if} +</article> + +{#if !isDetailView} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleCloseModal} + {title} + subtitle={null} + targetId={CUSTOMER_REVIEW_MODAL_ID} + > + <svelte:fragment slot="content"> + <svelte:self {item} isDetailView={true} /> + </svelte:fragment> + </ContentModal> + </Modal> +{/if} + +<style lang="scss"> + article:not(.is-detail-view) { + height: 186px; + padding: 20px 16px; + background-color: var(--systemQuinary); + border-radius: var(--global-border-radius-xlarge); + + @media (--small) { + padding: 20px; + } + } + + .header { + display: flex; + gap: 8px; + margin-bottom: 18px; + align-items: center; + justify-content: space-between; + + .is-detail-view & { + margin-bottom: 0; + } + } + + .title-and-rating-container { + .is-detail-view & { + display: flex; + } + } + + .title { + color: var(--systemPrimary); + font: var(--body-emphasized); + margin-bottom: 4px; + } + + .date, + .author { + color: var(--systemSecondary); + font: var(--callout); + word-break: normal; + } + + .content { + position: relative; + word-wrap: break-word; /* Break to fit the review block, even when people leave a review with long text without spaces */ + text-align: start; + font: var(--body); + } + + .review-header { + text-align: end; + } + + .developer-response-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; + margin-top: 20px; + } + + .developer-response-heading { + font: var(--body-emphasized); + + .is-detail-view & { + display: block; + font: var(--title-3-emphasized); + } + } + + .developer-response-container { + margin-top: 10px; + } + + article :global(.more) { + --moreTextColorOverride: var(--keyColor); + --moreFontOverride: var(--body); + text-transform: lowercase; + } +</style> diff --git a/src/components/jet/item/SearchLinkItem.svelte b/src/components/jet/item/SearchLinkItem.svelte new file mode 100644 index 0000000..cd60512 --- /dev/null +++ b/src/components/jet/item/SearchLinkItem.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { + isFlowAction, + type SearchLink, + } from '@jet-app/app-store/api/models'; + + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import MagnifyingGlass from '~/sf-symbols/magnifyingglass.svg'; + + export let item: SearchLink; +</script> + +{#if isFlowAction(item.clickAction)} + <div class="link-container"> + <FlowAction destination={item.clickAction}> + <MagnifyingGlass class="icon" /> + {item.title} + </FlowAction> + </div> +{/if} + +<style> + .link-container { + display: contents; + } + + .link-container :global(a) { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 12px; + font: var(--title-2); + border-radius: var(--global-border-radius-large); + background: var(--systemQuinary); + } + + .link-container :global(a:hover) { + text-decoration: none; + } + + .link-container :global(a) :global(.icon) { + overflow: visible; + width: 20px; + fill: currentColor; + } +</style> diff --git a/src/components/jet/item/SearchResult/AppSearchResultItem.svelte b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte new file mode 100644 index 0000000..c36e5fc --- /dev/null +++ b/src/components/jet/item/SearchResult/AppSearchResultItem.svelte @@ -0,0 +1,392 @@ +<script lang="ts" context="module"> + import type { + AppSearchResult, + AppEventSearchResult, + SearchResult, + Trailers, + Screenshots, + FlowAction, + Artwork as ArtworkType, + Video as VideoType, + } from '@jet-app/app-store/api/models'; + + export function isAppSearchResult( + result: SearchResult, + ): result is AppSearchResult { + return result.resultType === 'content'; + } + + export function isAppEventSearchResult( + result: SearchResult, + ): result is AppEventSearchResult { + return result.resultType === 'appEvent'; + } +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import type { + ImageSizes, + Profile, + } from '@amp/web-app-components/src/components/Artwork/types'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + + import type { NamedProfile } from '~/config/components/artwork'; + import { getI18n } from '~/stores/i18n'; + import AppIcon, { + doesAppIconNeedBorder, + } from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import StarRating from '~/components/StarRating.svelte'; + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { isNamedColor } from '~/utils/color'; + import mediaQueries from '~/utils/media-queries'; + import VideoPlayer from '~/components/VideoPlayer.svelte'; + + const i18n = getI18n(); + + export let item: AppSearchResult; + + $: ({ + clickAction, + heading, + isEditorsChoice, + rating, + ratingCount, + screenshots, + subtitle, + title, + trailers, + } = item.lockup); + let video: VideoType | undefined; + let media: (ArtworkType | VideoType)[]; + let mediaAspectRatio: number; + let numberOfMediaToShow: number; + let profile: NamedProfile | Profile; + let mediaSizes: ImageSizes; + let videoPlayerInstance: InstanceType<typeof VideoPlayer> | null = null; + let shouldAutoplayVideo: boolean = false; + + const currentPlatform = + (item.lockup.clickAction as FlowAction).destination?.platform ?? ''; + + function isForCurrentPlatform(media: Trailers | Screenshots) { + return media.mediaPlatform.appPlatform === currentPlatform; + } + + $: { + const selectedTrailer = + trailers?.find(isForCurrentPlatform) ?? trailers?.[0]; + video = selectedTrailer?.videos?.[0]; + + const selectedScreenshot = + screenshots.find(isForCurrentPlatform) ?? screenshots[0]; + + const firstMedia = video + ? video.preview + : selectedScreenshot.artwork[0]; + const hasPortraitMedia = firstMedia.width < firstMedia.height; + const isMobile = $mediaQueries === 'xsmall' && $sidebarIsHidden; + + mediaAspectRatio = firstMedia.width / firstMedia.height; + + if (!hasPortraitMedia) { + numberOfMediaToShow = 1; + mediaSizes = isMobile ? [308] : [648, 417, 417, 656]; + } else if (currentPlatform !== 'iphone') { + numberOfMediaToShow = 2; + mediaSizes = isMobile ? [150] : [238, 203, 203, 320]; + } else { + numberOfMediaToShow = 3; + mediaSizes = isMobile ? [98] : [156, 133, 133, 210]; + } + + profile = getNaturalProfile(firstMedia, mediaSizes); + media = [video, ...selectedScreenshot.artwork] + .filter(Boolean) + .slice(0, numberOfMediaToShow) as (ArtworkType | VideoType)[]; + } + + function handleMouseEnter() { + videoPlayerInstance?.play(); + } + + function handleMouseLeave() { + videoPlayerInstance?.pause(); + } + + onMount(() => { + shouldAutoplayVideo = navigator.maxTouchPoints > 0; + }); +</script> + +<LinkWrapper + action={clickAction} + label={`${$i18n.t('ASE.Web.AppStore.View')} ${clickAction.title}`} +> + <article on:mouseenter={handleMouseEnter} on:mouseleave={handleMouseLeave}> + <div class="top-container"> + {#if item.lockup.icon} + <div class="app-icon-container"> + <AppIcon + icon={item.lockup.icon} + profile="app-icon" + withBorder={doesAppIconNeedBorder(item.lockup.icon)} + /> + </div> + {/if} + + <div class="metadata-container"> + {#if heading} + <LineClamp clamp={1}> + <h4>{heading}</h4> + </LineClamp> + {/if} + + <LineClamp clamp={1}> + <h3>{title}</h3> + </LineClamp> + + <LineClamp clamp={1}> + <p>{subtitle}</p> + </LineClamp> + + {#if isEditorsChoice} + <div class="editors-choice-badge-container"> + <SFSymbol name="laurel.leading" ariaHidden={true} /> + + {$i18n.t('ASE.Web.AppStore.Review.EditorsChoice')} + + <SFSymbol name="laurel.trailing" ariaHidden={true} /> + </div> + {:else if ratingCount} + <span class="rating-container"> + <StarRating + {rating} + --fill-color="var(--systemGray2-onDark_IC)" + /> + {ratingCount} + </span> + {/if} + </div> + + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + </div> + + <div + class="artwork-container {currentPlatform}" + style:--media-aspect-ratio={mediaAspectRatio} + > + {#each media as mediaItem} + {#if 'videoUrl' in mediaItem} + <div class="video-wrapper"> + <Video + {profile} + loop + video={mediaItem} + autoplay={shouldAutoplayVideo} + useControls={false} + autoplayVisibilityThreshold={0.75} + bind:videoPlayerRef={videoPlayerInstance} + /> + </div> + {:else} + <Artwork + {profile} + artwork={mediaItem} + disableAutoCenter={true} + useCropCodeFromArtwork={false} + /> + {/if} + {/each} + </div> + </article> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + article { + display: flex; + align-items: stretch; + flex-direction: column; + padding: 16px; + border-radius: 28px; + box-shadow: var(--shadow-medium); + background: #fff; + transition: box-shadow 210ms ease-out; + width: 100%; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuaternary); + } + } + + article:hover { + box-shadow: 0 5px 28px rgba(0, 0, 0, 0.12); + } + + .top-container { + align-items: center; + width: 100%; + padding-bottom: 16px; + gap: 8px; + } + + .top-container, + .metadata-container { + display: flex; + } + + .metadata-container { + flex-direction: column; + flex-grow: 1; + } + + .rating-container { + display: flex; + align-items: center; + font: var(--subhead-emphasized); + color: var(--systemSecondary); + } + + .rating-container :global(svg) { + @media (prefers-contrast: more) and (prefers-color-scheme: dark) { + --fill-color: #fff; + } + } + + .editors-choice-badge-container { + display: flex; + align-items: center; + gap: 4px; + font: var(--caption-1-emphasized); + color: var(--systemSecondary); + } + + .editors-choice-badge-container :global(svg) { + height: 14px; + overflow: visible; + + @include rtl { + transform: rotateY(180deg); + } + } + + .editors-choice-badge-container :global(svg path) { + fill: var(--systemSecondary); + } + + h3 { + font: var(--headline); + } + + h4 { + color: var(--systemSecondary); + font: var(--footnote-emphasized); + text-transform: uppercase; + } + + p { + font: var(--callout); + color: var(--systemSecondary); + } + + .artwork-container { + --container-aspect-ratio: 1.333; + --artwork-override-object-fit: contain; + --artwork-override-height: auto; + --artwork-override-width: 100%; + --artwork-override-max-height: 100%; + --artwork-override-max-width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + height: calc(100% * var(--container-aspect-ratio)); + aspect-ratio: var(--container-aspect-ratio); + border-radius: var(--global-border-radius-medium); + + &.iphone { + --container-aspect-ratio: 1.444; + } + + &.ipad { + --container-aspect-ratio: 1.54; + } + + &.mac { + --container-aspect-ratio: 1.6; + } + + &.watch { + --container-aspect-ratio: 1.636; + } + + &.tv, + &.vision { + --container-aspect-ratio: 1.77; + } + } + + // Centers a single item in the grid + .artwork-container :global(> :only-child) { + justify-self: center; + } + + // Aligns the first of two items to the center edge + .artwork-container :global(> :nth-child(1):nth-last-child(2)) { + justify-self: flex-end; + } + + // Aligns the second of two items to the center edge + .artwork-container :global(> :nth-child(2):nth-last-child(1)) { + justify-self: flex-start; + } + + .video-wrapper { + display: flex; + overflow: hidden; + max-height: 100%; + width: auto; + aspect-ratio: var(--media-aspect-ratio, 16/9); + border: 1px solid var(--systemQuaternary); + border-radius: 16px; + } + + .artwork-container :global(.artwork-component) { + display: flex; + aspect-ratio: var(--media-aspect-ratio); + border-radius: 16px; + justify-content: center; + align-items: center; + width: auto; + height: auto; + max-width: 100%; + max-height: 100%; + } + + .artwork-container :global(.artwork-component img) { + height: 100%; + } + + .artwork-container :global(.video-container) { + container-type: normal; + } + + .artwork-container :global(video) { + width: 100%; + height: 100%; + object-fit: cover; + } +</style> diff --git a/src/components/jet/item/SmallBreakoutItem.svelte b/src/components/jet/item/SmallBreakoutItem.svelte new file mode 100644 index 0000000..311fbef --- /dev/null +++ b/src/components/jet/item/SmallBreakoutItem.svelte @@ -0,0 +1,187 @@ +<script lang="ts"> + import { + type Artwork as JetArtworkType, + type SmallBreakout, + isFlowAction, + } from '@jet-app/app-store/api/models'; + import { isSome } from '@jet/environment/types/optional'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: SmallBreakout; + + $: ({ backgroundColor, iconArtwork, clickAction: action = null } = item); + + $: backgroundColorForCss = backgroundColor + ? colorAsString(backgroundColor) + : '#000'; +</script> + +<LinkWrapper {action}> + <HoverWrapper> + <div class="container" style:--background-color={backgroundColorForCss}> + {#if iconArtwork} + <div class="artwork-container"> + <AppIcon + icon={iconArtwork} + profile="app-icon-xlarge" + fixedWidth={false} + /> + </div> + {/if} + + <div + class="text-container" + class:with-dark-background={item.details.backgroundStyle === + 'dark'} + > + {#if item.details?.badge} + <LineClamp clamp={1}> + <h4>{item.details.badge}</h4> + </LineClamp> + {/if} + + {#if item.details.title} + <LineClamp clamp={2}> + <h3>{item.details.title}</h3> + </LineClamp> + {/if} + + {#if item.details.description} + <LineClamp clamp={3}> + <p>{item.details.description}</p> + </LineClamp> + {/if} + + {#if isSome(action) && isFlowAction(action)} + <span class="link-container"> + {action.title} + <span aria-hidden="true"> + <SFSymbol name="chevron.forward" /> + </span> + </span> + {/if} + </div> + </div> + </HoverWrapper> +</LinkWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .container { + width: 100%; + max-height: 460px; + aspect-ratio: 16/9; + background-color: var(--background-color); + container-type: inline-size; + container-name: container; + + @media (--range-small-up) { + aspect-ratio: 13/5; + } + } + + .artwork-container { + --rotation: -30deg; + position: absolute; + width: 33%; + max-width: 430px; + inset-inline-end: -10%; + transform: translateY(-8%) rotate(var(--rotation)); + + @include rtl { + --rotation: 30deg; + } + } + + @container container (min-width: 1150px) { + .artwork-container { + transform: translateY(-11%) rotate(var(--rotation)); + } + } + + .artwork-container :global(.artwork-component) { + --angle: -7px; + box-shadow: var(--angle) 5px 12px 0 rgba(0, 0, 0, 0.15); + + @include rtl { + --angle: 7px; + } + } + + .text-container { + display: flex; + flex-direction: column; + justify-content: center; + width: 66%; + height: 100%; + padding: 0 20px; + text-wrap: pretty; + + @media (--range-small-up) { + width: 33%; + } + + @media (--range-large-up) { + width: 33%; + } + } + + .text-container.with-dark-background { + color: var(--systemPrimary-onDark); + } + + .link-container { + display: flex; + gap: 4px; + margin-top: 16px; + font: var(--title-3-emphasized); + + @media (--range-small-up) { + font: var(--title-2-emphasized); + } + } + + .link-container :global(svg) { + width: 10px; + height: 10px; + fill: currentColor; + + @include rtl { + transform: rotate(180deg); + } + } + + h3 { + text-wrap: balance; + font: var(--title-1-emphasized); + + @media (--range-small-up) { + font: var(--large-title-emphasized); + } + } + + h4 { + font: var(--subhead-emphasized); + + @media (--range-small-up) { + font: var(--headline); + } + } + + p { + margin-top: 8px; + + @media (--range-small-up) { + font: var(--title-3); + } + } +</style> diff --git a/src/components/jet/item/SmallLockupItem.svelte b/src/components/jet/item/SmallLockupItem.svelte new file mode 100644 index 0000000..b235652 --- /dev/null +++ b/src/components/jet/item/SmallLockupItem.svelte @@ -0,0 +1,110 @@ +<script lang="ts"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon, { type AppIconProfile } from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: Lockup; + + /** + * Controls the `get-button` variant class that is applied to the "View" button + * + * @default "gray" + */ + export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'gray'; + export let shouldShowLaunchNativeButton: boolean = false; + export let titleLineCount: number = 2; + export let appIconProfile: AppIconProfile = 'app-icon-small'; + + const i18n = getI18n(); +</script> + +<div class="small-lockup-item"> + <LinkWrapper + action={item.clickAction} + label={`${$i18n.t('ASE.Web.AppStore.View')} ${ + item.title ? item.title : null + }`} + > + {#if item.icon} + <div class="app-icon-container"> + <AppIcon icon={item.icon} profile={appIconProfile} /> + </div> + {/if} + + <div class="metadata-container"> + {#if item.heading} + <LineClamp clamp={1}> + <h4 dir="auto">{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={titleLineCount}> + <h3 dir="auto">{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p dir="auto">{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + <div class="button-container" aria-hidden="true"> + {#if shouldShowLaunchNativeButton && $$slots['launch-native-button']} + <slot name="launch-native-button" /> + {:else} + <span class="get-button {buttonVariant}"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + {/if} + </div> + </LinkWrapper> +</div> + +<style> + .small-lockup-item, + .small-lockup-item :global(a) { + display: flex; + align-items: center; + width: 100%; + } + + .app-icon-container { + flex-shrink: 0; + margin-inline-end: 16px; + } + + .metadata-container { + margin-inline-end: 16px; + } + + h3 { + color: var(--title-color); + font: var(--title-3-emphasized); + } + + h4 { + color: var(--eyebrow-color, var(--systemSecondary)); + font: var(--subhead-emphasized); + text-transform: uppercase; + mix-blend-mode: var(--eyebrow-blend-mode); + } + + p { + font: var(--callout); + color: var(--subtitle-color, var(--systemSecondary)); + mix-blend-mode: var(--subtitle-blend-mode); + } + + .button-container { + margin-inline-start: auto; + margin-inline-end: var(--margin-inline-end, 0); + mix-blend-mode: var(--button-blend-mode); + flex-shrink: 0; + } +</style> diff --git a/src/components/jet/item/SmallLockupWithOrdinalItem.svelte b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte new file mode 100644 index 0000000..9fb796c --- /dev/null +++ b/src/components/jet/item/SmallLockupWithOrdinalItem.svelte @@ -0,0 +1,176 @@ +<script lang="ts" context="module"> + import type { Lockup } from '@jet-app/app-store/api/models'; + + interface SmallLockupWithOrdinalItem extends Lockup { + ordinal: string; + } + + export function isSmallLockupWithOrdinalItem( + item: Lockup, + ): item is SmallLockupWithOrdinalItem { + return !!item?.ordinal; + } +</script> + +<script lang="ts"> + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + import mediaQueries from '~/utils/media-queries'; + + export let item: Lockup; + + $: titleLineCount = item.heading || $mediaQueries === 'xsmall' ? 1 : 2; + + const i18n = getI18n(); +</script> + +<LinkWrapper action={item.clickAction}> + <article> + {#if item.ordinal} + <div class="ordinal"> + {item.ordinal} + </div> + {/if} + + {#if item.icon} + <div + class="app-icon-container" + style:--icon-aspect-ratio={item.icon.width / item.icon.height} + > + <AppIcon + icon={item.icon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + {/if} + <div class="metadata-container"> + {#if item.heading} + <LineClamp clamp={1}> + <h4>{item.heading}</h4> + </LineClamp> + {/if} + + {#if item.title} + <LineClamp clamp={titleLineCount}> + <h3 title={item.title}>{item.title}</h3> + </LineClamp> + {/if} + + {#if item.subtitle} + <LineClamp clamp={1}> + <p>{item.subtitle}</p> + </LineClamp> + {/if} + </div> + + <div class="button-container"> + <span class="get-button gray"> + {$i18n.t('ASE.Web.AppStore.View')} + </span> + </div> + </article> +</LinkWrapper> + +<style> + article { + position: relative; + aspect-ratio: 0.9; + height: 100%; + padding: 16px; + gap: 10px; + display: flex; + align-items: center; + flex-direction: column; + justify-content: center; + text-align: center; + border-radius: var(--global-border-radius-xlarge); + background: var(--systemPrimary-onDark); + box-shadow: var(--shadow-small); + container-type: inline-size; + container-name: container; + + @media (prefers-color-scheme: dark) { + background: var(--systemQuaternary); + } + + @media (--sidebar-visible) and (--range-xsmall-only) { + aspect-ratio: 1; + } + + @media (--range-medium-up) { + aspect-ratio: 1; + } + } + + .app-icon-container { + flex-shrink: 0; + margin-top: 4px; + aspect-ratio: var(--icon-aspect-ratio); + height: clamp(40px, 40cqi, 100px); + width: auto; + } + + .metadata-container { + display: flex; + flex-direction: column; + gap: 4px; + } + + h3 { + text-wrap: balance; + font: var(--body-emphasized); + line-height: 1.1; + color: var(--title-color); + } + + h4 { + text-transform: uppercase; + font: var(--subhead-emphasized); + color: var(--systemSecondary); + } + + p { + font: var(--subhead); + color: var(--systemSecondary); + } + + .button-container { + --get-button-font: var(--subhead-bold); + align-content: end; + flex-grow: 1; + } + + .ordinal { + position: absolute; + top: 12px; + inset-inline-start: 12px; + font: var(--title-1-semibold); + color: var(--systemTertiary); + } + + @container container (width >= 180px) { + h3 { + font: var(--title-3-emphasized); + } + } + + @container container (width >= 250px) { + h3 { + font: var(--title-2-emphasized); + margin-bottom: 4px; + } + + p { + font: var(--body); + } + } + + @container container (width >= 200px) { + .button-container { + --get-button-font: unset; + } + } +</style> diff --git a/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte new file mode 100644 index 0000000..ce7784b --- /dev/null +++ b/src/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte @@ -0,0 +1,69 @@ +<script lang="ts" context="module"> + import type { + TodayCard, + TodayCardMediaBrandedSingleApp, + } from '@jet-app/app-store/api/models'; + + export interface SmallStoryCardMediaBrandedSingleApp extends TodayCard { + media: TodayCardMediaBrandedSingleApp; + } + + export function isSmallStoryCardMediaBrandedSingleApp( + item: TodayCard, + ): item is SmallStoryCardMediaBrandedSingleApp { + return !!item.media && item.media.kind === 'brandedSingleApp'; + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: SmallStoryCardMediaBrandedSingleApp; + + $: artwork = item.media.artworks?.[0] || item.media.icon; +</script> + +<article> + <LinkWrapper action={item.clickAction}> + <HoverWrapper element="div"> + <Artwork {artwork} profile="brick" useCropCodeFromArtwork={false} /> + </HoverWrapper> + + <div class="text-container"> + <h4>{item.heading}</h4> + <h3>{item.title}</h3> + <p>{item.inlineDescription}</p> + </div> + </LinkWrapper> +</article> + +<style> + article { + aspect-ratio: 16/9; + } + + .text-container { + gap: 4px; + display: flex; + flex-direction: column; + margin-top: 8px; + } + + h3 { + font: var(--title-3); + } + + h4 { + margin-bottom: 2px; + font: var(--callout-emphasized); + color: var(--systemSecondary); + } + + p { + font: var(--body-tall); + color: var(--systemSecondary); + text-wrap: pretty; + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte new file mode 100644 index 0000000..bcd7333 --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithArtworkItem.svelte @@ -0,0 +1,87 @@ +<script lang="ts" context="module"> + import type { + Artwork as ArtworkModel, + TodayCard, + } from '@jet-app/app-store/api/models'; + + export interface SmallStoryCardWithArtwork extends TodayCard { + artwork: ArtworkModel; + badge: any; + } + + export function isSmallStoryCardWithArtworkItem( + item: TodayCard, + ): item is SmallStoryCardWithArtwork { + return !('media' in item) && 'artwork' in item; + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import { colorAsString } from '~/utils/color'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + + export let item: SmallStoryCardWithArtwork; + + $: artwork = item.heroMedia?.artworks?.[0] || item.artwork; + + $: gradientColor = artwork.backgroundColor + ? colorAsString(artwork.backgroundColor) + : 'rgb(0 0 0 / 62%)'; +</script> + +<article> + <LinkWrapper action={item.clickAction}> + <HoverWrapper element="div"> + <Artwork {artwork} profile="small-story-card-portrait" /> + + <GradientOverlay --color={gradientColor} /> + + <div class="text-container"> + {#if item.badge?.title} + <h4>{item.badge.title}</h4> + {/if} + + {#if item.title} + <h3>{@html sanitizeHtml(item.title)}</h3> + {/if} + </div> + </HoverWrapper> + </LinkWrapper> +</article> + +<style> + article { + aspect-ratio: 3/4; + } + + .text-container { + position: absolute; + display: flex; + flex-direction: column; + justify-content: end; + height: 100%; + margin-top: 8px; + padding: 16px; + color: var(--systemPrimary); + } + + h3 { + z-index: 1; + text-wrap: pretty; + font: var(--body-bold); + color: var(--systemPrimary-onDark); + } + + h4 { + position: relative; + z-index: 1; + margin-bottom: 2px; + font: var(--caption-2-emphasized); + color: var(--systemSecondary-onDark); + mix-blend-mode: plus-lighter; + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte new file mode 100644 index 0000000..5b20e1c --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte @@ -0,0 +1,156 @@ +<script lang="ts" context="module"> + import type { + TodayCard, + TodayCardMediaAppIcon, + } from '@jet-app/app-store/api/models'; + + export interface TodayCardWithMediAppIcon extends TodayCard { + media: TodayCardMediaAppIcon; + } + + export function isSmallStoryCardWithMediaAppIcon( + item: TodayCard, + ): item is TodayCardWithMediAppIcon { + return !!item.media && item.media.kind === 'appIcon'; + } +</script> + +<script lang="ts"> + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import AppIcon from '~/components/AppIcon.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let item: TodayCardWithMediAppIcon; + + $: artwork = item.heroMedia?.artworks[0]; + $: appIcon = item.media.icon; + $: backgroundImage = appIcon + ? buildSrc( + appIcon.template, + { + crop: 'bb', + width: 160, + height: 160, + fileType: 'webp', + }, + {}, + ) + : undefined; + $: backgroundColor = appIcon.backgroundColor + ? colorAsString(appIcon.backgroundColor) + : '#000'; +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + <div + class="container" + style:--background-color={backgroundColor} + style:--background-image={`url(${backgroundImage})`} + > + <div class="protection" /> + + {#if artwork} + <Artwork {artwork} profile="brick" /> + {:else} + <div class="app-icon-container"> + <div class="app-icon-normal"> + <AppIcon + icon={appIcon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + + <div class="app-icon-glow"> + <AppIcon + icon={appIcon} + profile="app-icon-medium" + fixedWidth={false} + /> + </div> + </div> + {/if} + </div> + </HoverWrapper> + + <div class="text-container"> + <h4>{item.heading}</h4> + <h3>{item.title}</h3> + </div> +</LinkWrapper> + +<style lang="scss"> + @use 'amp/stylekit/core/mixins/browser-targets' as *; + + .container { + aspect-ratio: 16 / 9; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background: linear-gradient( + to bottom, + transparent 20%, + rgba(0, 0, 0, 0.33) 100% + ), + var(--background-image), var(--background-color, #000); + background-size: cover; + background-position: center; + + // Safari has issues rendering the overlaid `backdrop-filter` from `.proection` atop the + // background image of `.container`, so in Safari only we are forgoing the use of + // `var(--background-image)` and just using colors. + @include target-safari { + background: linear-gradient( + to bottom, + transparent 20%, + rgba(0, 0, 0, 0.33) 100% + ), + var(--background-color, #000); + } + } + + .protection { + position: absolute; + width: 100%; + height: 100%; + backdrop-filter: blur(80px) saturate(1.5); + } + + .app-icon-container { + position: relative; + width: 80px; + } + + .app-icon-normal { + position: relative; + z-index: 1; + filter: drop-shadow(0 0 13px rgba(0, 0, 0, 0.15)); + } + + .app-icon-glow { + position: absolute; + inset: 0; + width: 100%; + transform: scale(1.4); + filter: blur(25px); + } + + .text-container { + margin-top: 8px; + } + + h3 { + font: var(--title-3); + } + + h4 { + margin-bottom: 2px; + font: var(--callout-emphasized); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithMediaItem.svelte b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte new file mode 100644 index 0000000..4901744 --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithMediaItem.svelte @@ -0,0 +1,104 @@ +<script lang="ts" context="module"> + import { isSome } from '@jet/environment/types/optional'; + import type { + TodayCard, + TodayCardMediaWithArtwork, + } from '@jet-app/app-store/api/models'; + + import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + + export interface SmallStoryCardWithMedia extends TodayCard { + media: TodayCardMediaWithArtwork; + heroMedia: TodayCardMediaWithArtwork; + } + + export function isSmallStoryCardWithMediaItem( + item: TodayCard, + ): item is SmallStoryCardWithMedia { + return isSome(item.media); + } +</script> + +<script lang="ts"> + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import Artwork from '~/components/Artwork.svelte'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let item: SmallStoryCardWithMedia; + + $: artwork = (() => { + if (item.heroMedia) { + return item.heroMedia?.artworks?.[0]; + } + + if (isTodayCardMediaWithArtwork(item.media)) { + return item.media.artworks?.[0]; + } + + return null; + })(); +</script> + +<article> + <LinkWrapper action={item.clickAction}> + <HoverWrapper element="div"> + {#if artwork} + <div class="artwork-container"> + <Artwork + {artwork} + profile={item.heroMedia + ? 'small-story-card' + : 'small-story-card-legacy'} + useCropCodeFromArtwork={!item.heroMedia} + /> + </div> + {/if} + </HoverWrapper> + + <div class="text-container"> + <h4>{item.heading}</h4> + <LineClamp clamp={1}> + <h3>{item.title}</h3> + </LineClamp> + + {#if item.inlineDescription} + <LineClamp clamp={1}> + <p>{item.inlineDescription}</p> + </LineClamp> + {/if} + </div> + </LinkWrapper> +</article> + +<style> + .artwork-container { + width: 100%; + aspect-ratio: 16 / 9; + background-color: var(--color); + border-radius: 8px; + } + + .text-container { + display: flex; + margin-top: 8px; + gap: 4px; + color: var(--systemPrimary); + flex-direction: column; + } + + h3 { + font: var(--title-3); + } + + h4 { + font: var(--callout-emphasized); + color: var(--systemTertiary); + } + + p { + font: var(--body-tall); + color: var(--systemSecondary); + text-wrap: pretty; + } +</style> diff --git a/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte new file mode 100644 index 0000000..038f504 --- /dev/null +++ b/src/components/jet/item/SmallStoryCardWithMediaRiver.svelte @@ -0,0 +1,118 @@ +<script lang="ts" context="module"> + import type { + TodayCard, + TodayCardMediaRiver, + } from '@jet-app/app-store/api/models'; + + export interface TodayCardWithMediaRiver extends TodayCard { + media: TodayCardMediaRiver; + } + + export function isSmallStoryCardWithMediaRiver( + item: TodayCard, + ): item is TodayCardWithMediaRiver { + return !!item.media && item.media.kind === 'river'; + } +</script> + +<script lang="ts"> + import type { Opt } from '@jet/environment/types/optional'; + import HoverWrapper from '~/components/HoverWrapper.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + import { + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + + export let item: TodayCardWithMediaRiver; + + $: icons = item.media.lockups.map((lockup) => lockup.icon); + $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks( + icons, + { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + }, + ); + + let title: Opt<string>; + let eyebrow: Opt<string>; + $: { + eyebrow = item.heading; + title = item.title; + + if (item.inlineDescription) { + eyebrow = item.title; + title = item.inlineDescription; + } + } +</script> + +<LinkWrapper action={item.clickAction}> + <HoverWrapper> + <div class="river-container" style={backgroundGradientCssVars}> + <AppIconRiver {icons} profile="app-icon" /> + </div> + </HoverWrapper> + + <div class="text-container"> + {#if eyebrow} + <h4>{eyebrow}</h4> + {/if} + + {#if title} + <h3>{title}</h3> + {/if} + </div> +</LinkWrapper> + +<style> + .river-container { + --app-icon-river-icon-width: 48px; + display: flex; + flex-direction: column; + justify-content: center; + aspect-ratio: 16 / 9; + width: 100%; + border-radius: 8px; + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) 20%, + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) 40%, + transparent 80% + ), + radial-gradient( + circle at 140% -50%, + var(--top-right, #000) 60%, + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) 50%, + transparent 100% + ); + } + + .river-container :global(.app-icons:last-of-type) { + margin-bottom: 0; + } + + .text-container { + margin-top: 8px; + } + + h3 { + font: var(--title-3); + } + + h4 { + margin-bottom: 2px; + font: var(--callout-emphasized); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/item/TitledParagraphItem.svelte b/src/components/jet/item/TitledParagraphItem.svelte new file mode 100644 index 0000000..ad8e4bc --- /dev/null +++ b/src/components/jet/item/TitledParagraphItem.svelte @@ -0,0 +1,175 @@ +<script lang="ts" context="module"> + import type { + ShelfModel, + TitledParagraph, + } from '@jet-app/app-store/api/models'; + + export function isTitledParagraphItem( + item: ShelfModel | string, + ): item is TitledParagraph { + return typeof item !== 'string' && 'text' in item; + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { getNumericDateFromDateString } from '@amp/web-app-components/src/utils/date'; + import { getJet } from '~/jet/svelte'; + import { getI18n } from '~/stores/i18n'; + + export let item: TitledParagraph; + + const i18n = getI18n(); + const jet = getJet(); + const isDetailView = item.style === 'detail'; + const dateForDisplay = jet.localization.timeAgo( + new Date(item.secondarySubtitle), + ); + const dateForAttribute = getNumericDateFromDateString( + item.secondarySubtitle, + ); + + let isTruncated = true; +</script> + +<article class:detail={isDetailView} class:overview={!isDetailView}> + <div class="container"> + <p> + {#if item.text} + {#if !isTruncated || isDetailView} + {item.text} + {:else} + <LineClamp + clamp={5} + observe + on:resize={({ detail }) => + (isTruncated = detail.truncated)} + > + {@html sanitizeHtml(item.text)} + </LineClamp> + + {#if isTruncated} + <button on:click={() => (isTruncated = false)}> + {$i18n.t('ASE.Web.AppStore.More')} + </button> + {/if} + {/if} + {/if} + </p> + + <div class="metadata"> + <h4>{item.primarySubtitle}</h4> + <time datetime={dateForAttribute}>{dateForDisplay}</time> + </div> + </div> +</article> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + article { + display: flex; + flex-direction: column-reverse; + font: var(--body-tall); + color: var(--systemPrimary); + margin: 0 var(--bodyGutter); + + @media (--range-small-up) { + flex-direction: row; + } + } + + .container { + display: flex; + width: 100%; + } + + p { + position: relative; + display: flex; + flex-direction: column; + white-space: break-spaces; + font: var(--body-tall); + } + + .metadata { + display: flex; + flex-direction: column; + justify-content: flex-start; + margin: 0 0 8px 8px; + text-align: end; + color: var(--systemSecondary); + } + + h4 { + font: var(--body-tall); + } + + button { + --gradient-direction: 270deg; + position: absolute; + bottom: 0; + display: flex; + justify-content: end; + color: var(--keyColor); + inset-inline-end: 0; + padding-inline-start: 20px; + background: linear-gradient( + var(--gradient-direction), + var(--pageBg) 72%, + transparent 100% + ); + + @include rtl { + --gradient-direction: 90deg; + } + } + + time { + color: var(--systemSecondary); + white-space: nowrap; + } + + .detail { + flex-direction: column-reverse; + margin: 0; + padding: 16px 0 0; + border-top: 1px solid var(--systemGray4); + } + + .detail .metadata { + gap: 2px; + } + + .detail h4 { + font: var(--body-emphasized-tall); + color: var(--systemPrimary); + } + + .overview .container { + @media (--range-medium-up) { + width: 66%; + } + } + + .overview .metadata { + flex-grow: 1; + gap: 4px; + } + + .overview p { + @media (--range-small-up) { + width: 66%; + } + + @media (--range-large-up) { + width: 50%; + } + } + + .detail .container { + justify-content: space-between; + } +</style> diff --git a/src/components/jet/item/TrailersLockupItem.svelte b/src/components/jet/item/TrailersLockupItem.svelte new file mode 100644 index 0000000..6b2ee42 --- /dev/null +++ b/src/components/jet/item/TrailersLockupItem.svelte @@ -0,0 +1,51 @@ +<script lang="ts"> + import type { TrailersLockup } from '@jet-app/app-store/api/models'; + import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let item: TrailersLockup; + + $: video = item.trailers.videos[0]; +</script> + +<article> + {#if video} + <div class="video-container"> + <Video + {video} + shouldSuperimposePosterImage + loop={true} + useControls={true} + profile="app-trailer-lockup-video" + /> + </div> + {/if} + + <SmallLockup {item} /> +</article> + +<style> + /* + The video container is explicitly not 16/9 aspect ratio, because a lot trailers have + pillarboxing (black bars on the sides), so expand the height of their container which + causes those black bars to overflow outside the container, thus cropping them. + This follows the iOS pattern. + */ + .video-container { + --app-trailer-lockup-video-aspect-ratio: 16/10; + aspect-ratio: var(--app-trailer-lockup--video-aspect-ratio); + margin-bottom: 16px; + overflow: hidden; + border-radius: var(--global-border-radius-large); + } + + /* + Not all trailers are in a landscape aspect ratio (many iPhone trailers are portrait), + so for those cases we force them to fit inside a landscape container, centered vertically, + by using `object-fit: cover;`. + */ + .video-container :global(video) { + aspect-ratio: var(--app-trailer-lockup-video-aspect-ratio); + object-fit: cover; + } +</style> diff --git a/src/components/jet/marker-shelf/ProductTopLockup.svelte b/src/components/jet/marker-shelf/ProductTopLockup.svelte new file mode 100644 index 0000000..e56e5b0 --- /dev/null +++ b/src/components/jet/marker-shelf/ProductTopLockup.svelte @@ -0,0 +1,463 @@ +<script lang="ts" context="module"> + import type { + AppPlatform, + ShelfBasedProductPage, + } from '@jet-app/app-store/api/models'; + + /** + * The parts of {@linkcode ShelfBasedProductPage} that are required to render + * the `MarkerShelf` component + */ + export type MarkerShelfPageRequirements = Pick< + ShelfBasedProductPage, + | 'badges' + | 'banner' + | 'developerAction' + | 'lockup' + | 'shelfMapping' + | 'titleOfferDisplayProperties' + | 'canonicalURL' + | 'appPlatforms' + >; +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import { platform } from '@amp/web-apps-utils'; + import AppIcon, { + doesAppIconNeedBorder, + } from '~/components/AppIcon.svelte'; + import Banner from '~/components/jet/item/BannerItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ProductPageArcadeBanner from '~/components/ProductPageArcadeBanner.svelte'; + import { getI18n } from '~/stores/i18n'; + import { colorAsString, isNamedColor, isRGBColor } from '~/utils/color'; + import { concatWithMiddot, isString } from '~/utils/string-formatting'; + import { + isPlatformExclusivelySupported, + isPlatformSupported, + PlatformToExclusivityText, + } from '~/utils/app-platforms'; + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import ShareArrowButton, { + isShareSupported, + } from '~/components/ShareArrowButton.svelte'; + import LaunchNativeButton from '~/components/LaunchNativeButton.svelte'; + + export let page: MarkerShelfPageRequirements; + + $: banner = page.banner; + $: lockup = page.lockup; + $: appPlatforms = page.appPlatforms; + $: offerDisplayProperties = lockup.offerDisplayProperties || {}; + $: ({ expectedReleaseDate } = offerDisplayProperties?.subtitles || {}); + + const i18n = getI18n(); + + // TODO: replace with `supportsArcade` from Jet + // rdar://143706610 (Support `supportsArcade` attribute) + $: supportsArcade = offerDisplayProperties.offerType === 'arcadeApp'; + + $: backgroundColor = isRGBColor(lockup.icon?.backgroundColor) + ? colorAsString(lockup.icon.backgroundColor) + : '#fff'; + + $: backgroundImage = lockup.icon + ? buildSrc( + lockup.icon.template, + { + crop: 'bb', + width: 400, + height: 400, + fileType: 'webp', + }, + {}, + ) + : undefined; + + $: attributes = concatWithMiddot( + [ + expectedReleaseDate && $i18n.t('ASE.Web.AppStore.App.ComingSoon'), + expectedReleaseDate && expectedReleaseDate, + // Attributes that are not relevant for Arcade Apps: + ...(!supportsArcade + ? [ + page.titleOfferDisplayProperties?.isFree && + $i18n.t('ASE.Web.AppStore.Free'), + offerDisplayProperties.priceFormatted, + offerDisplayProperties.subtitles?.standard, + lockup.tertiaryTitle, + ] + : []), + ].filter(isString), + $i18n, + ); + + $: exclusivePlatform = ( + Object.keys(PlatformToExclusivityText) as AppPlatform[] + ).find((platform: AppPlatform) => + isPlatformExclusivelySupported(platform, appPlatforms), + ); + $: exclusivityText = exclusivePlatform + ? PlatformToExclusivityText[exclusivePlatform] + : null; + + $: shouldShowLaunchNativeButton = + platform.ismacOS() && + (lockup.isIOSBinaryMacOSCompatible || + isPlatformSupported('mac', appPlatforms)); + + let shouldShowShareButton: boolean = true; + + onMount(() => { + shouldShowShareButton = isShareSupported(); + }); +</script> + +<ShelfWrapper withBottomPadding={false} withPaddingTop={false}> + <div + class="container" + style:--background-color={backgroundColor} + style:--background-image={`url(${backgroundImage})`} + > + <div class="rotate" /> + <div class="blur" /> + + <div class="content-container"> + {#if lockup.icon} + <div + class="app-icon-contianer" + class:without-border={!doesAppIconNeedBorder(lockup.icon)} + aria-hidden="true" + > + <AppIcon + icon={lockup.icon} + profile="app-icon-large" + fixedWidth={false} + /> + + <div class="glow"> + <AppIcon + icon={lockup.icon} + profile="app-icon-large" + fixedWidth={false} + /> + </div> + </div> + {/if} + + <section> + {#if supportsArcade} + <span + class="arcade-logo" + aria-label={$i18n.t( + 'ASE.Web.AppStore.ArcadeLogo.AccessibilityValue', + )} + > + <AppleArcadeLogo /> + </span> + {:else if lockup.editorialTagline} + <h3>{lockup.editorialTagline}</h3> + {/if} + + <h1> + {lockup.title} + </h1> + + <h2 class="subtitle"> + {lockup.subtitle} + </h2> + + {#if exclusivityText} + <p class="attributes"> + {$i18n.t(exclusivityText)} + </p> + {/if} + + {#if attributes.length > 0} + <p class="attributes"> + {attributes} + </p> + {/if} + + {#if page.canonicalURL && (shouldShowLaunchNativeButton || shouldShowShareButton)} + <div class="buttons-container"> + {#if shouldShowLaunchNativeButton} + <span class="launch-native-button-container"> + <LaunchNativeButton url={page.canonicalURL} /> + </span> + {/if} + + {#if shouldShowShareButton} + <!-- + If there is no launch native button, then we show a label for + the share button, which helps to visually fill out the space. + --> + <ShareArrowButton + url={page.canonicalURL} + withLabel={!shouldShowLaunchNativeButton} + /> + {/if} + </div> + {/if} + </section> + </div> + </div> +</ShelfWrapper> + +{#if banner} + <ShelfWrapper withBottomPadding={false} withTopMargin={false}> + <Banner item={banner} /> + </ShelfWrapper> +{/if} + +{#if supportsArcade} + <ShelfWrapper + withBottomPadding={false} + withTopMargin={true} + centered={false} + > + <ProductPageArcadeBanner /> + </ShelfWrapper> +{/if} + +<style> + .container { + --blend-mode: plus-lighter; + position: relative; + display: flex; + overflow: hidden; + align-items: center; + height: 200px; + color: var(--systemPrimary-onDark); + border-bottom: 1px solid var(--systemQuaternary-vibrant); + border-bottom-right-radius: 2px; + border-bottom-left-radius: 2px; + background: linear-gradient( + to bottom, + transparent 20%, + rgba(0, 0, 0, 0.8) 100% + ), + var(--background-image), var(--background-color, #000); + background-size: cover; + background-position: center; + transition: border-bottom-left-radius 210ms ease-out, + border-bottom-right-radius 210ms ease-out; + transform: translate(0); + + @media (--range-small-up) { + height: 286px; + } + + @media (--range-xlarge-up) { + border: 1px solid var(--systemQuaternary-vibrant); + border-top: none; + border-bottom-right-radius: 30px; + border-bottom-left-radius: 30px; + } + } + + .glow { + position: absolute; + z-index: -1; + top: 0; + width: 100%; + transform: scale(1.5); + filter: blur(60px); + } + + .blur { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + backdrop-filter: blur(100px) saturate(1.5); + } + + .rotate { + position: absolute; + z-index: 2; + top: 0; + left: 0; + width: 100%; + filter: brightness(1.3) saturate(0) blur(50px); + mix-blend-mode: overlay; + height: 500%; + background-image: var(--background-image); + background-repeat: repeat; + opacity: 0; + transform-origin: top center; + animation: shift-background 60s infinite linear 10s; + } + + .content-container { + display: flex; + flex-direction: row; + max-width: 840px; + gap: 1em; + margin: 0 var(--bodyGutter); + + @media (--range-small-up) { + gap: 1.5em; + } + + @media (--range-medium-up) { + gap: 2em; + } + } + + .app-icon-contianer { + position: relative; + z-index: 2; + display: flex; + align-items: center; + width: 128px; + flex-shrink: 0; + + @media (--range-small-up) { + width: 194px; + } + } + + .app-icon-contianer:not(.without-border) :global(> .app-icon) { + box-shadow: 0 0 30px rgba(0, 0, 0, 0.33); + border: 2px solid var(--systemQuaternary-onDark); + } + + section { + display: flex; + flex-direction: column; + justify-content: center; + } + + .subtitle, + .attributes { + position: relative; + z-index: 2; + margin-bottom: 4px; + font: var(--body); + color: rgba(245.973, 245.973, 245.973, 0.6); + text-wrap: pretty; + mix-blend-mode: var(--blend-mode); + + @media (--range-small-up) { + margin-bottom: 8px; + font: var(--title-2-emphasized); + } + } + + .attributes { + margin-bottom: 0; + font: var(--body); + } + + .buttons-container { + --share-arrow-size: 27px; + --launch-native-button-arrow-size: 7px; + --get-button-font: var(--footnote-bold); + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; + + @media (--range-small-up) { + --share-arrow-size: unset; + --launch-native-button-arrow-size: unset; + --get-button-font: unset; + } + } + + h1 { + position: relative; + z-index: 2; + display: flex; + align-items: center; + margin-bottom: 2px; + font: var(--title-2-emphasized); + color: white; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + text-wrap: pretty; + + @media (--sidebar-visible) { + font: var(--title-1-emphasized); + } + + @media (--range-small-up) { + font: var(--header-emphasized); + letter-spacing: -0.02em; + } + } + + h3 { + margin-bottom: 0; + position: relative; + z-index: 2; + mix-blend-mode: plus-lighter; + font: var(--body-emphasized); + + @media (--range-small-up) { + font: var(--title-3-emphasized); + } + } + + .arcade-logo { + display: flex; + height: 10px; + margin-bottom: 4px; + position: relative; + z-index: 2; + mix-blend-mode: plus-lighter; + + @media (--range-small-up) { + height: 14px; + } + } + + .launch-native-button-container { + position: relative; + z-index: 2; + } + + @keyframes shift-background { + 0% { + background-position: 50% 50%; + background-size: 100%; + transform: rotate(0deg); + opacity: 0; + } + + 10% { + opacity: 0.5; + } + + 20% { + background-position: 65% 25%; + background-size: 160%; + transform: rotate(45deg); + } + + 45% { + background-position: 90% 60%; + background-size: 250%; + transform: rotate(160deg); + opacity: 0.5; + } + + 70% { + background-position: 70% 40%; + background-size: 200%; + transform: rotate(250deg); + opacity: 0.5; + } + + 100% { + background-position: 50% 50%; + background-size: 100%; + transform: rotate(360deg); + opacity: 0; + } + } +</style> diff --git a/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte new file mode 100644 index 0000000..c1e7b2e --- /dev/null +++ b/src/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte @@ -0,0 +1,36 @@ +<script lang="ts" context="module"> + import type { + AccessibilityParagraph, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface AccessibilityDeveloperLinkShelf extends Shelf { + items: [AccessibilityParagraph]; + } + + export function isAccessibilityDeveloperLinkShelf( + shelf: Shelf, + ): shelf is AccessibilityDeveloperLinkShelf { + let { contentType, items, title } = shelf; + + return ( + contentType === 'accessibilityParagraph' && + !title && + Array.isArray(items) + ); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import AccessibilityParagraphItem from '../item/AccessibilityParagraphItem.svelte'; + import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout'; + + export let shelf: AccessibilityDeveloperLinkShelf; + + $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf)); +</script> + +<ShelfWrapper {shelf} centered {withBottomPadding}> + <AccessibilityParagraphItem item={shelf.items[0]} /> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte new file mode 100644 index 0000000..cb2fed8 --- /dev/null +++ b/src/components/jet/shelf/AccessibilityFeaturesShelf.svelte @@ -0,0 +1,35 @@ +<script lang="ts" context="module"> + import type { + AccessibilityFeatures, + Shelf, + } from '@jet-app/app-store/api/models'; + + export interface AccessibilityFeaturesShelf extends Shelf { + items: AccessibilityFeatures[]; + } + + export function isAccessibilityFeaturesShelf( + shelf: Shelf, + ): shelf is AccessibilityFeaturesShelf { + let { contentType, items } = shelf; + + return contentType === 'accessibilityFeatures' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte'; + import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout'; + + export let shelf: AccessibilityFeaturesShelf; + + $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf)); +</script> + +<ShelfWrapper {shelf} {withBottomPadding}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <AccessibilityFeaturesItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AccessibilityHeaderShelf.svelte b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte new file mode 100644 index 0000000..990c507 --- /dev/null +++ b/src/components/jet/shelf/AccessibilityHeaderShelf.svelte @@ -0,0 +1,182 @@ +<script lang="ts" context="module"> + import { + type Action, + type FlowAction, + type GenericPage, + type AccessibilityParagraph, + type Shelf, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + import { + isAccessibilityFeaturesShelf, + type AccessibilityFeaturesShelf, + } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte'; + + interface AccessibilityParagraphShelf extends Shelf { + items: [AccessibilityParagraph]; + } + + interface AccessibilityHeaderShelf extends AccessibilityParagraphShelf { + items: [AccessibilityParagraph]; + } + + interface AccessibilityDetailPage extends GenericPage { + shelves: (AccessibilityFeaturesShelf | AccessibilityParagraphShelf)[]; + } + + interface AccessibilityDetailPageFlowAction extends FlowAction { + page: 'accessibilityDetails'; + pageData: AccessibilityDetailPage; + } + + export function isAccessibilityHeaderShelf( + shelf: Shelf, + ): shelf is AccessibilityHeaderShelf { + let { contentType, items, title } = shelf; + + return ( + contentType === 'accessibilityParagraph' && + !!title && + Array.isArray(items) + ); + } + + function isAccessibilityParagraphShelf( + shelf: Shelf, + ): shelf is AccessibilityParagraphShelf { + let { contentType, items } = shelf; + + return contentType === 'accessibilityParagraph' && Array.isArray(items); + } + + function isAccessibilityDetailFlowAction( + action: Action, + ): action is AccessibilityDetailPageFlowAction { + return isFlowAction(action) && action.page === 'accessibilityDetails'; + } +</script> + +<script lang="ts"> + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import AccessibilityParagraphItem from '~/components/jet/item/AccessibilityParagraphItem.svelte'; + import AccessibilityFeaturesItem from '~/components/jet/item/AccessibilityFeaturesItem.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getAccessibilityLayoutConfiguration } from '~/context/accessibility-layout'; + + export let shelf: AccessibilityHeaderShelf; + + $: ({ withBottomPadding } = getAccessibilityLayoutConfiguration(shelf)); + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const { seeAllAction } = shelf; + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + const handleModalClose = () => modalComponent?.close(); + const handleOpenModalClick = (e: Event) => { + modalTriggerElement = e.target as HTMLElement; + modalComponent?.showModal(); + }; + + const destination = + seeAllAction && isAccessibilityDetailFlowAction(seeAllAction) + ? seeAllAction + : undefined; + const pageData = destination?.pageData; +</script> + +<ShelfWrapper {shelf} {withBottomPadding}> + <div slot="title" class="title-container"> + {#if shelf.title} + {#if destination} + <button on:click={handleOpenModalClick}> + <ShelfTitle + title={shelf.title} + seeAllAction={destination} + /> + </button> + {:else} + <ShelfTitle title={shelf.title} /> + {/if} + {/if} + + {#if pageData} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleModalClose} + title={pageData.title || null} + subtitle={null} + > + <svelte:fragment slot="content"> + <div class="modal-content-container"> + {#each pageData.shelves as shelf} + <div class="content-section"> + {#if isAccessibilityParagraphShelf(shelf)} + {#each shelf.items as item} + <AccessibilityParagraphItem + {item} + /> + {/each} + {/if} + + {#if isAccessibilityFeaturesShelf(shelf)} + {#each shelf.items as item} + <AccessibilityFeaturesItem + {item} + isDetailView={true} + /> + {/each} + {/if} + </div> + {/each} + </div> + </svelte:fragment> + </ContentModal> + </Modal> + {/if} + </div> + + <div class="header-container"> + <div> + <AccessibilityParagraphItem item={shelf.items[0]} /> + </div> + </div> +</ShelfWrapper> + +<style> + .title-container { + display: flex; + justify-content: space-between; + padding-top: 16px; + padding-inline-end: var(--bodyGutter); + } + + .header-container { + margin: 0 var(--bodyGutter); + } + + .header-container div { + @media (--range-medium-up) { + width: 66%; + } + } + + .modal-content-container { + font: var(--body-tall); + white-space: normal; + } + + .modal-content-container .content-section { + padding-top: 20px; + border-top: 1px solid var(--defaultLine); + } + + .modal-content-container .content-section:not(:first-child) { + margin-top: 20px; + } +</style> diff --git a/src/components/jet/shelf/ActionShelf.svelte b/src/components/jet/shelf/ActionShelf.svelte new file mode 100644 index 0000000..847438f --- /dev/null +++ b/src/components/jet/shelf/ActionShelf.svelte @@ -0,0 +1,80 @@ +<script lang="ts" context="module"> + import type { Shelf, Action } from '@jet-app/app-store/api/models'; + + interface ActionShelf extends Shelf { + items: Action[]; + } + + export function isActionShelf(shelf: Shelf): shelf is ActionShelf { + return shelf.contentType === 'action' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ActionShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="F" let:item> + {@const action = item} + {@const artwork = item.artwork} + {@const title = item.title} + + <div class="container"> + <LinkWrapper {action}> + {#if artwork} + <div class="artwork-container" aria-hidden="true"> + <Artwork + {artwork} + profile={getNaturalProfile(artwork, [24])} + hasTransparentBackground + /> + </div> + {/if} + {title} + </LinkWrapper> + </div> + </ShelfItemLayout> +</ShelfWrapper> + +<style> + .container :global(a) { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + background: var(--pageBG); + border-radius: var(--global-border-radius-medium); + box-shadow: var(--shadow-small); + padding: 16px 10px; + width: 100%; + font: var(--title-3-medium); + transition: background-color 210ms ease-out; + } + + .container :global(a:hover) { + /* stylelint-disable color-function-notation */ + background-color: rgb(from var(--pageBG) r g b/0.1); + /* stylelint-enable color-function-notation */ + + @media (prefers-color-scheme: dark) { + /* stylelint-disable color-function-notation */ + background-color: rgb(from var(--pageBG) r g b/0.85); + /* stylelint-enable color-function-notation */ + } + } + + .artwork-container { + width: 24px; + height: 24px; + } + + .container :global(.external-link-arrow) { + height: 10px; + } +</style> diff --git a/src/components/jet/shelf/AnnotationShelf.svelte b/src/components/jet/shelf/AnnotationShelf.svelte new file mode 100644 index 0000000..e11de72 --- /dev/null +++ b/src/components/jet/shelf/AnnotationShelf.svelte @@ -0,0 +1,49 @@ +<script lang="ts" context="module"> + import type { Shelf, Annotation } from '@jet-app/app-store/api/models'; + + interface AnnotationShelf extends Shelf { + items: Annotation[]; + } + + export function isAnnotationShelf(shelf: Shelf): shelf is AnnotationShelf { + const { contentType, items } = shelf; + + return contentType === 'annotation' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import Grid from '~/components/Grid.svelte'; + import CollapsableContent from '~/components/CollapsableContent.svelte'; + import AnnotationItem from '~/components/jet/item/Annotation/AnnotationItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: AnnotationShelf; +</script> + +<ShelfWrapper {shelf}> + <dl> + <Grid items={shelf.items} gridType="F" let:item> + <dt>{item.title}</dt> + + {#if item.summary} + <CollapsableContent> + <svelte:fragment slot="summary"> + {item.summary} + </svelte:fragment> + + <AnnotationItem {item} /> + </CollapsableContent> + {:else} + <AnnotationItem {item} /> + {/if} + </Grid> + </dl> +</ShelfWrapper> + +<style> + dt { + color: var(--systemSecondary); + margin-bottom: 4px; + } +</style> diff --git a/src/components/jet/shelf/AppEventDetailShelf.svelte b/src/components/jet/shelf/AppEventDetailShelf.svelte new file mode 100644 index 0000000..2ae84eb --- /dev/null +++ b/src/components/jet/shelf/AppEventDetailShelf.svelte @@ -0,0 +1,290 @@ +<script lang="ts" context="module"> + import { + type AppEventDetailShelf, + isAppEventDetailShelf, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + export { isAppEventDetailShelf }; +</script> + +<script lang="ts"> + import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import mediaQueries from '~/utils/media-queries'; + import Artwork from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + import GradientOverlay from '~/components/GradientOverlay.svelte'; + import { colorAsString } from '~/utils/color'; + import AppEventDate from '~/components/AppEventDate.svelte'; + import { platform } from '@amp/web-apps-utils'; + import LaunchNativeButton from '~/components/LaunchNativeButton.svelte'; + + export let shelf: AppEventDetailShelf; + + $: item = shelf.items[0]; + $: ({ appEvent, artwork: productArtwork, video } = item); + $: ({ requirements, lockup } = appEvent); + $: artwork = video ? video.preview : productArtwork; + + $: backgroundImageUrl = artwork + ? buildSrc( + artwork.template, + { + crop: artwork.crop as CropCode, + width: 200, + height: Math.floor(200 / (artwork.width / artwork.height)), + fileType: 'webp', + }, + {}, + ) + : undefined; + + $: backgroundColor = artwork?.backgroundColor + ? colorAsString(artwork.backgroundColor) + : '#000'; + $: hasLightArtwork = appEvent.mediaOverlayStyle === 'light'; + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: clickAction = lockup?.clickAction; + $: urlToLaunchNatively = + clickAction && isFlowAction(clickAction) ? clickAction.pageUrl : null; + $: shouldShowLaunchNativeButton = + platform.ismacOS() && + lockup?.isIOSBinaryMacOSCompatible && + !!urlToLaunchNatively; + + function makeCSSURL(url: string | null | undefined): string { + return url ? `url(${url})` : ''; + } +</script> + +<ShelfWrapper {shelf} withBottomPadding={false} centered={false}> + <div + class="event-detail" + style:--background-image-url={makeCSSURL(backgroundImageUrl)} + style:--background-color={backgroundColor} + > + {#if video} + <div class="video-container"> + <Video + {video} + autoplay + loop + useControls={false} + profile="app-event-detail" + /> + </div> + {:else if artwork} + <div class="artwork-container"> + <Artwork {artwork} profile="app-event-detail" /> + </div> + {/if} + + {#if isXSmallViewport} + <div class="gradient-container"> + <GradientOverlay + --color={backgroundColor} + --height="70%" + shouldDarken={!hasLightArtwork} + /> + </div> + {:else} + <div class="tint-container" /> + {/if} + + <div class="time-container"> + <AppEventDate {appEvent} /> + </div> + + <div + class="text-container" + class:dark={hasLightArtwork && isXSmallViewport} + > + <div class="event-details-container"> + <p class="app-event-kind">{appEvent.kind}</p> + <h1 class="app-event-title">{appEvent.title}</h1> + <p class="app-event-subtitle"> + {appEvent.detail} + </p> + {#if requirements} + <span class="requirements">{requirements}</span> + {/if} + </div> + + {#if lockup} + <div class="lockup-container"> + <SmallLockupItem + {shouldShowLaunchNativeButton} + item={lockup} + buttonVariant="transparent" + appIconProfile="app-icon" + > + <svelte:fragment slot="launch-native-button"> + {#if urlToLaunchNatively} + <LaunchNativeButton url={urlToLaunchNatively} /> + {/if} + </svelte:fragment> + </SmallLockupItem> + </div> + {/if} + </div> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .event-detail { + --event-image-desktop-width: 31.64%; + --border-radius: 16px; + --event-gutter: 16px; + border-radius: var(--border-radius); + display: grid; + grid-template-areas: + 'time' + 'text'; + grid-template-rows: 1fr auto; + aspect-ratio: 9/16; + max-height: 90vh; + overflow: hidden; + position: relative; + + @media (--range-small-up) { + --event-gutter: 20px; + aspect-ratio: 16/9; + background-image: var(--background-image-url); + background-position-x: 50%; + background-position-y: 50%; + background-size: cover; + grid-template-areas: + 'image time' + 'image text'; + grid-template-columns: var(--event-image-desktop-width) auto; + grid-template-rows: auto 1fr; + } + } + + .artwork-container, + .video-container { + z-index: 1; + + /* On "mobile" the artwork should be behind both the time and text */ + grid-row-start: time; + grid-row-end: text; + grid-column: 1; + + @media (--range-small-up) { + /* On large screens, it should be to the right of the text */ + grid-area: image; + } + } + + .video-container { + background: var(--background-color); + color: transparent; + } + + .video-container :global(video) { + width: unset; + position: absolute; + } + + .tint-container { + background: var(--systemTertiary-onLight_IC); + backdrop-filter: saturate(120%) blur(24px); + z-index: 1; + + /* One smaller screens, extend behind just the text */ + grid-area: text; + + /* On larger screens, extend behind time and text */ + grid-row-start: time; + grid-row-end: text; + } + + .time-container { + grid-area: time; + margin-top: var(--event-gutter); + margin-inline-start: var(--event-gutter); + } + + .time-container :global(time) { + color: var(--systemPrimary-onLight); + font: var(--callout-emphasized); + padding: 3px 10px; + background-color: var(--systemSecondary-onDark); + border-radius: var(--global-border-radius-medium); + position: relative; + z-index: 3; + + @media (--range-small-up) { + position: relative; + z-index: 3; + mix-blend-mode: plus-lighter; + } + } + + .text-container { + --blend-mode: plus-lighter; + --text-color: var(--systemPrimary-onDark); + padding: var(--event-gutter); + + /* Placement within parent */ + grid-area: text; + + /* Layout of child elements */ + display: flex; + flex-direction: column; + gap: 16px; + justify-content: space-between; + color: var(--text-color); + } + + .text-container.dark { + --blend-mode: normal; + --text-color: var(--systemPrimary-onLight); + } + + .event-details-container { + display: flex; + flex-direction: column; + gap: 6px; + } + + .app-event-kind { + font: var(--callout-emphasized); + mix-blend-mode: var(--blend-mode); + z-index: 1; + } + + .app-event-title { + font: var(--large-title-emphasized); + text-wrap: pretty; + z-index: 1; + } + + .app-event-subtitle { + font: var(--title-3); + z-index: 1; + } + + .requirements { + position: relative; + z-index: 1; + font: var(--body-emphasized); + } + + .lockup-container { + --title-color: var(--text-color); + --subtitle-color: var(--text-color); + --eyebrow-color: var(--text-color); + --linkColor: var(--text-color); + --button-blend-mode: var(--blend-mode); + border-top: 1px solid var(--systemQuaternary-onDark); + padding-top: 16px; + z-index: 1; + } +</style> diff --git a/src/components/jet/shelf/AppPromotionShelf.svelte b/src/components/jet/shelf/AppPromotionShelf.svelte new file mode 100644 index 0000000..48590cb --- /dev/null +++ b/src/components/jet/shelf/AppPromotionShelf.svelte @@ -0,0 +1,47 @@ +<script lang="ts" context="module"> + import type { + AppPromotion, + AppEvent, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface AppPromotionShelf extends Shelf { + items: AppPromotion[]; + } + + export function isAppPromotionShelf( + shelf: Shelf, + ): shelf is AppPromotionShelf { + const { contentType, items } = shelf; + return contentType === 'appPromotion' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import AppEventItem from '~/components/jet/item/AppEventItem.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import mediaQueries from '~/utils/media-queries'; + + export let shelf: AppPromotionShelf; + + $: appEventItems = shelf.items.filter( + (item): item is AppEvent => item.promotionType === 'appEvent', + ); + $: isArticleContext = shelf.presentationHints?.isArticleContext; + $: gridType = + isArticleContext && $mediaQueries !== 'small' ? 'Spotlight' : 'B'; +</script> + +<ShelfWrapper {shelf} withTopMargin={isArticleContext}> + <ShelfItemLayout + shelf={{ + ...shelf, + items: appEventItems, + }} + {gridType} + let:item + > + <AppEventItem {item} {isArticleContext} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AppShowcaseShelf.svelte b/src/components/jet/shelf/AppShowcaseShelf.svelte new file mode 100644 index 0000000..095acf2 --- /dev/null +++ b/src/components/jet/shelf/AppShowcaseShelf.svelte @@ -0,0 +1,29 @@ +<script lang="ts" context="module"> + import type { AppShowcase, Shelf } from '@jet-app/app-store/api/models'; + + interface AppShowcaseShelf extends Shelf { + contentType: 'appShowcase'; + items: [AppShowcase]; + } + + export function isAppShowcaseShelf( + shelf: Shelf, + ): shelf is AppShowcaseShelf { + return ( + shelf.contentType === 'appShowcase' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SmallLockup from '~/components/jet/item/SmallLockupItem.svelte'; + + export let shelf: AppShowcaseShelf; + + $: item = shelf.items[0]; +</script> + +<ShelfWrapper {shelf} withTopMargin centered> + <SmallLockup item={item.lockup} /> +</ShelfWrapper> diff --git a/src/components/jet/shelf/AppTrailerLockupShelf.svelte b/src/components/jet/shelf/AppTrailerLockupShelf.svelte new file mode 100644 index 0000000..f516074 --- /dev/null +++ b/src/components/jet/shelf/AppTrailerLockupShelf.svelte @@ -0,0 +1,48 @@ +<script lang="ts" context="module"> + import type { + TrailersLockup, + MixedMediaLockup, + Shelf, + } from '@jet-app/app-store/api/models'; + + type AppTrailerLockupItem = TrailersLockup | MixedMediaLockup; + + interface AppTrailerLockupShelf extends Shelf { + contentType: 'appTrailerLockup'; + items: AppTrailerLockupItem[]; + } + + export function isAppTrailerLockupShelf( + shelf: Shelf, + ): shelf is AppTrailerLockupShelf { + return ( + shelf.contentType === 'appTrailerLockup' && + Array.isArray(shelf.items) + ); + } + + function isMixedMediaLockup( + item: AppTrailerLockupItem, + ): item is MixedMediaLockup { + return Array.isArray(item.trailers); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import MixedMediaLockupItem from '~/components/jet/item/MixedMediaLockupItem.svelte'; + import TrailersLockupItem from '~/components/jet/item/TrailersLockupItem.svelte'; + + export let shelf: AppTrailerLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="B" let:item> + {#if isMixedMediaLockup(item)} + <MixedMediaLockupItem {item} /> + {:else} + <TrailersLockupItem {item} /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ArcadeFooterShelf.svelte b/src/components/jet/shelf/ArcadeFooterShelf.svelte new file mode 100644 index 0000000..dc46740 --- /dev/null +++ b/src/components/jet/shelf/ArcadeFooterShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { Shelf, ArcadeFooter } from '@jet-app/app-store/api/models'; + + interface ArcadeFooterShelf extends Shelf { + items: [ArcadeFooter]; + } + + export function isArcadeFooterShelf( + shelf: Shelf, + ): shelf is ArcadeFooterShelf { + const { contentType, items } = shelf; + + return contentType === 'arcadeFooter' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ArcadeFooterItem from '~/components/jet/item/ArcadeFooterItem.svelte'; + import HorizontalShelf from '~/components/jet/shelf/HorizontalShelf.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ArcadeFooterShelf; + + $: gridRows = shelf.rowsPerColumn ?? undefined; + $: items = shelf.items; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <HorizontalShelf {gridRows} gridType="Spotlight" {items} let:item> + <ArcadeFooterItem {item} /> + </HorizontalShelf> +</ShelfWrapper> diff --git a/src/components/jet/shelf/BannerShelf.svelte b/src/components/jet/shelf/BannerShelf.svelte new file mode 100644 index 0000000..84289c9 --- /dev/null +++ b/src/components/jet/shelf/BannerShelf.svelte @@ -0,0 +1,35 @@ +<script lang="ts" context="module"> + import type { Shelf, Banner } from '@jet-app/app-store/api/models'; + + interface BannerShelf extends Shelf { + contentType: 'banner'; + items: Banner[]; + } + + export function isBannerShelf(shelf: Shelf): shelf is BannerShelf { + return shelf.contentType === 'banner' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import BannerItem from '~/components/jet/item/BannerItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: BannerShelf; +</script> + +<ShelfWrapper {shelf}> + <div class="banner-items-container"> + {#each shelf.items as item} + <BannerItem {item} /> + {/each} + </div> +</ShelfWrapper> + +<style> + .banner-items-container { + display: flex; + flex-direction: column; + gap: 8px; + } +</style> diff --git a/src/components/jet/shelf/BrickShelf.svelte b/src/components/jet/shelf/BrickShelf.svelte new file mode 100644 index 0000000..4bd55e5 --- /dev/null +++ b/src/components/jet/shelf/BrickShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface BrickShelf extends Shelf { + items: Brick[]; + } + + export function isBrickShelf(shelf: Shelf): shelf is BrickShelf { + const { contentType, items } = shelf; + return contentType === 'brick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import BrickItem from '~/components/jet/item/BrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: BrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout + {shelf} + gridTypeForShelf="Brick" + gridTypeForGrid="F" + let:item + > + <BrickItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/CategoryBrickShelf.svelte b/src/components/jet/shelf/CategoryBrickShelf.svelte new file mode 100644 index 0000000..22ca86b --- /dev/null +++ b/src/components/jet/shelf/CategoryBrickShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface CategoryBrickShelf extends Shelf { + items: Brick[]; + } + + export function isCategoryBrickShelf( + shelf: Shelf, + ): shelf is CategoryBrickShelf { + const { contentType, items } = shelf; + return contentType === 'categoryBrick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import BrickItem from '~/components/jet/item/BrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: CategoryBrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="C" let:item> + <BrickItem {item} shouldOverlayDescription /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/EditorialCardShelf.svelte b/src/components/jet/shelf/EditorialCardShelf.svelte new file mode 100644 index 0000000..efbd71d --- /dev/null +++ b/src/components/jet/shelf/EditorialCardShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { Shelf, EditorialCard } from '@jet-app/app-store/api/models'; + + interface EditorialCardShelf extends Shelf { + items: EditorialCard[]; + } + + export function isEditorialCardShelf( + shelf: Shelf, + ): shelf is EditorialCardShelf { + const { contentType, items } = shelf; + + return contentType === 'editorialCard' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import HeroCarousel from '~/components/hero/Carousel.svelte'; + import EditorialCardItem from '~/components/jet/item/EditorialCardItem.svelte'; + + export let shelf: EditorialCardShelf; + + $: items = shelf.items; + + function deriveBackgroundArtworkFromItem(item: EditorialCard) { + return item.artwork; + } +</script> + +<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item> + <EditorialCardItem {item} /> +</HeroCarousel> diff --git a/src/components/jet/shelf/EditorialLinkShelf.svelte b/src/components/jet/shelf/EditorialLinkShelf.svelte new file mode 100644 index 0000000..0946462 --- /dev/null +++ b/src/components/jet/shelf/EditorialLinkShelf.svelte @@ -0,0 +1,122 @@ +<script lang="ts" context="module"> + import type { Shelf, EditorialLink } from '@jet-app/app-store/api/models'; + + interface EditorialLinkShelf extends Shelf { + contentType: 'smallStoryCard'; + items: [EditorialLink]; + } + + export function isEditorialLinkShelf( + shelf: Shelf, + ): shelf is EditorialLinkShelf { + const { contentType, items } = shelf; + return contentType === 'editorialLink' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ChevronRightIcon from '~/sf-symbols/chevron.right.svg'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let shelf: EditorialLinkShelf; + $: item = shelf.items[0]; + $: ({ clickAction, descriptionText, summaryText } = item); +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <article> + <LinkWrapper + action={clickAction} + includeExternalLinkArrowIcon={false} + label={descriptionText} + > + <svelte:fragment> + <div> + <span class="title">{descriptionText}</span> + <span class="subtitle">{summaryText}</span> + </div> + + <span class="icon-container" aria-hidden="true"> + <ChevronRightIcon /> + </span> + </svelte:fragment> + </LinkWrapper> + </article> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + article { + display: flex; + align-items: center; + flex-direction: row; + justify-content: space-between; + padding: 16px; + margin: 0 var(--bodyGutter); + border-radius: var(--global-border-radius-medium); + background-color: var(--systemQuinary); + transition: background-color 210ms ease-out; + } + + article:hover { + cursor: pointer; + // a fallback for browsers that don't support relative colors (e.g. the `from` syntax) + background-color: var(--systemQuinary); + // stylelint-disable-next-line color-function-notation + background-color: rgb( + from var(--systemQuinary) r g b / calc(alpha + 0.02) + ); + } + + article:hover .icon-container { + transform: translateX(2px); + + @include rtl { + transform: translateX(-2px) rotate(-180deg); + } + } + + div { + display: flex; + flex-direction: column; + gap: 4px; + } + + .title { + font: var(--body-emphasized); + } + + .subtitle { + color: var(--systemSecondary); + } + + .icon-container { + position: relative; + height: 10px; + aspect-ratio: 0.9; + transition: transform 210ms ease-out; + + @include rtl { + transform: rotate(-180deg); + } + } + + .icon-container :global(path:not([fill='none'])) { + fill: var(--systemPrimary); + } + + article :global(a) { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + &:hover { + text-decoration: none; + } + } +</style> diff --git a/src/components/jet/shelf/FallbackShelf.svelte b/src/components/jet/shelf/FallbackShelf.svelte new file mode 100644 index 0000000..c7e4200 --- /dev/null +++ b/src/components/jet/shelf/FallbackShelf.svelte @@ -0,0 +1,39 @@ +<script lang="ts" context="module"> + import type { Shelf, ShelfModel } from '@jet-app/app-store/api/models'; + + interface FallbackShelf extends Shelf { + items: ShelfModel[]; + } + + export function isFallbackShelf(shelf: Shelf): shelf is FallbackShelf { + return Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: FallbackShelf; + + const isPlaceholder = shelf.contentType === 'placeholder'; +</script> + +<ShelfWrapper withTopBorder> + <ShelfItemLayout {shelf} gridType="C"> + <div class="wip"> + {isPlaceholder + ? `🔄 Placeholder for ${shelf.placeholderContentType}` + : `🚧 ${shelf.contentType}`} + </div> + </ShelfItemLayout> +</ShelfWrapper> + +<style> + .wip { + background: #f8f8f8; + padding: 16px; + border-radius: 8px; + border: 1px solid #ccc; + } +</style> diff --git a/src/components/jet/shelf/FramedArtworkShelf.svelte b/src/components/jet/shelf/FramedArtworkShelf.svelte new file mode 100644 index 0000000..16f7c48 --- /dev/null +++ b/src/components/jet/shelf/FramedArtworkShelf.svelte @@ -0,0 +1,98 @@ +<script lang="ts" context="module"> + import type { FramedArtwork, Shelf } from '@jet-app/app-store/api/models'; + + interface FramedArtworkShelf extends Shelf { + contentType: 'framedArtwork'; + items: [FramedArtwork]; + } + + export function isFramedArtworkShelf( + shelf: Shelf, + ): shelf is FramedArtworkShelf { + return ( + shelf.contentType === 'framedArtwork' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: FramedArtworkShelf; + + $: item = shelf.items[0]; + $: ({ artwork, caption, hasRoundedCorners } = item); + $: profile = getNaturalProfile(artwork, [1275, 1185, 825, 500, 690]); + $: aspectRatio = artwork.width / artwork.height; + $: isPortrait = aspectRatio < 1; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <figure + class="framed-artwork-item" + class:has-rounded-corners={hasRoundedCorners} + class:is-portrait={isPortrait} + > + <div + class="artwork-container" + style:--aspect-ratio={artwork.width / artwork.height} + > + <Artwork {artwork} {profile} forceFullWidth={!isPortrait} /> + </div> + + {#if caption} + <figcaption class="caption"> + {@html sanitizeHtml(caption)} + </figcaption> + {/if} + </figure> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + .framed-artwork-item { + border-radius: var(--framed-artwork-border-radius); + padding: 0 20px; + overflow: hidden; + + @media (--sidebar-visible) { + padding: 0 20px; + } + + @media (--range-small-only) { + padding: 0 var(--bodyGutter); + } + } + + .framed-artwork-item.has-rounded-corners { + --framed-artwork-border-radius: var(--global-border-radius-medium); + } + + .artwork-container { + border-radius: inherit; + } + + .caption { + border-bottom-left-radius: var(--framed-artwork-border-radius); + border-bottom-right-radius: var(--framed-artwork-border-radius); + color: var(--systemSecondary); + padding: 8px var(--article-page-padding) 0; + } + + .framed-artwork-item.is-portrait { + --artwork-override-max-height: 560px; + --artwork-override-max-width: 100%; + --artwork-override-width: auto; + } + + .framed-artwork-item.framed-artwork-item.is-portrait .artwork-container { + display: flex; + justify-content: center; + width: 100%; + max-height: var(--artwork-override-max-height); + aspect-ratio: var(--aspect-ratio); + } +</style> diff --git a/src/components/jet/shelf/FramedVideoShelf.svelte b/src/components/jet/shelf/FramedVideoShelf.svelte new file mode 100644 index 0000000..a685d39 --- /dev/null +++ b/src/components/jet/shelf/FramedVideoShelf.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { FramedVideo, Shelf } from '@jet-app/app-store/api/models'; + + interface FramedVideoShelf extends Shelf { + contentType: 'framedArtwork'; + items: [FramedVideo]; + } + + export function isFramedVideoShelf( + shelf: Shelf, + ): shelf is FramedVideoShelf { + return ( + shelf.contentType === 'framedVideo' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { getNaturalProfile } from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + + export let shelf: FramedVideoShelf; + + $: ({ caption, video } = shelf.items[0]); + $: aspectRatio = video.preview.width / video.preview.height; + $: profile = getNaturalProfile(video.preview, [608, 528, 608, 928, 298]); + $: isPortrait = aspectRatio < 1; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <figure class="framed-artwork-item" class:is-portrait={isPortrait}> + <div class="artwork-container" style:--aspect-ratio={aspectRatio}> + <Video {video} {profile} autoplay /> + </div> + + {#if caption} + <figcaption class="caption"> + {@html sanitizeHtml(caption)} + </figcaption> + {/if} + </figure> +</ShelfWrapper> + +<style> + .framed-artwork-item { + border-radius: var(--global-border-radius-medium); + padding: 0 20px; + overflow: hidden; + + @media (--sidebar-visible) { + padding: 0 20px; + } + + @media (--range-small-only) { + padding: 0 var(--bodyGutter); + } + } + + .artwork-container { + aspect-ratio: var(--aspect-ratio); + overflow: hidden; + line-height: 0; + border-radius: var(--global-border-radius-medium); + background-color: var(--systemQuaternary); + max-height: 560px; + max-width: 100%; + margin: 0 auto; + } + + .caption { + border-bottom-left-radius: var(--global-border-radius-medium); + border-bottom-right-radius: var(--global-border-radius-medium); + color: var(--systemSecondary); + padding: 8px var(--article-page-padding) 0; + } +</style> diff --git a/src/components/jet/shelf/HeroCarouselShelf.svelte b/src/components/jet/shelf/HeroCarouselShelf.svelte new file mode 100644 index 0000000..31a0287 --- /dev/null +++ b/src/components/jet/shelf/HeroCarouselShelf.svelte @@ -0,0 +1,38 @@ +<script lang="ts" context="module"> + import type { + Shelf, + HeroCarousel as HeroCarouselModel, + HeroCarouselItem as HeroCarouselItemModel, + } from '@jet-app/app-store/api/models'; + + interface HeroCarouselShelf extends Shelf { + items: [HeroCarouselModel]; + } + + export function isHeroCarouselShelf( + shelf: Shelf, + ): shelf is HeroCarouselShelf { + const { contentType, items } = shelf; + + return contentType === 'heroCarousel' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import HeroCarousel from '~/components/hero/Carousel.svelte'; + import HeroCarouselItem from '~/components/jet/item/HeroCarouselItem.svelte'; + import { isRtl } from '~/utils/locale'; + + export let shelf: HeroCarouselShelf; + + $: ({ items: ltrItems, rtlItems } = shelf.items[0]); + $: items = isRtl() && rtlItems.length ? rtlItems : ltrItems; + + function deriveBackgroundArtworkFromItem(item: HeroCarouselItemModel) { + return item.artwork || item.video?.preview; + } +</script> + +<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item> + <HeroCarouselItem {item} /> +</HeroCarousel> diff --git a/src/components/jet/shelf/HorizontalRuleShelf.svelte b/src/components/jet/shelf/HorizontalRuleShelf.svelte new file mode 100644 index 0000000..3313ff2 --- /dev/null +++ b/src/components/jet/shelf/HorizontalRuleShelf.svelte @@ -0,0 +1,54 @@ +<script lang="ts" context="module"> + import type { + HorizontalRule, + HorizontalRuleStyle, + Shelf, + } from '@jet-app/app-store/api/models'; + + export interface HorizontalRuleShelf extends Shelf { + contentType: 'horizontalRule'; + items: [HorizontalRule]; + } + + export function isHorizontalRuleShelf( + shelf: Shelf, + ): shelf is HorizontalRuleShelf { + return ( + shelf.contentType === 'horizontalRule' && Array.isArray(shelf.items) + ); + } + + function horizontalRuleStyleToBorderStyle( + style: HorizontalRuleStyle, + ): string { + return style.toLowerCase(); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { colorAsString } from '~/utils/color'; + + export let shelf: HorizontalRuleShelf; + + $: item = shelf.items[0]; + $: borderStyle = horizontalRuleStyleToBorderStyle(item.style); +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <hr + style:color={colorAsString(item.color)} + style:border-style={borderStyle} + /> +</ShelfWrapper> + +<style> + hr { + display: block; + height: 1px; + border-width: 1px 0 0; + border-color: currentColor; + margin: 1em var(--bodyGutter); + padding: 0; + } +</style> diff --git a/src/components/jet/shelf/HorizontalShelf.svelte b/src/components/jet/shelf/HorizontalShelf.svelte new file mode 100644 index 0000000..1addb31 --- /dev/null +++ b/src/components/jet/shelf/HorizontalShelf.svelte @@ -0,0 +1,53 @@ +<script lang="ts"> + import type { Opt } from '@jet/environment'; + import Shelf from '@amp/web-app-components/src/components/Shelf/Shelf.svelte'; + import type { + ArrowOffset, + GridType, + } from '@amp/web-app-components/src/components/Shelf/types'; + import { getI18n } from '~/stores/i18n'; + + type T = $$Generic; + + export let items: T[]; + export let gridType: GridType; + export let gridRows: number = 1; + export let arrowOffset: Opt<ArrowOffset> = null; + + const i18n = getI18n(); + // This makes the let:item of type T, because it doesn't know type when it comes back from the Shelf component. + function castGenericItem(x: T): T { + return x; + } +</script> + +<div class="horizontal-shelf" data-test-id="horizontal-shelf"> + <Shelf translateFn={$i18n.t} {items} {gridType} {gridRows} {arrowOffset}> + <svelte:fragment slot="item" let:item let:index let:numberOfItems> + <slot item={castGenericItem(item)} {index} {numberOfItems} /> + </svelte:fragment> + </Shelf> +</div> + +<style> + .horizontal-shelf :global(.shelf-grid) { + --shelfGridPaddingInline: var(--bodyGutter); + --shelfGridGutterWidth: var(--bodyGutter); + } + + .horizontal-shelf :global(.shelf-grid__list) { + @media (--range-xsmall-only) { + scroll-padding-inline-start: var( + --shelfScrollPaddingInline, + var(--bodyGutter) + ); + } + } + + .horizontal-shelf + :global(.shelf-grid__list--grid-type-Spotlight .shelf-grid__list-item) { + @media (--range-xsmall-only) { + --standard-lockup-shadow-offset: var(--shelfScrollPaddingInline, 0); + } + } +</style> diff --git a/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte new file mode 100644 index 0000000..bf2e75e --- /dev/null +++ b/src/components/jet/shelf/InAppPurchaseLockupShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { + InAppPurchaseLockup, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface InAppPurchaseLockupShelf extends Shelf { + items: InAppPurchaseLockup[]; + } + + export function isInAppPurchaseLockupShelf( + shelf: Shelf, + ): shelf is InAppPurchaseLockupShelf { + const { contentType, items } = shelf; + return contentType === 'inAppPurchaseLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import InAppPurchaseLockupComponent from '~/components/jet/item/InAppPurchaseLockup.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: InAppPurchaseLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="InAppPurchaseLockup" let:item> + <InAppPurchaseLockupComponent {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeBrickShelf.svelte b/src/components/jet/shelf/LargeBrickShelf.svelte new file mode 100644 index 0000000..eea1044 --- /dev/null +++ b/src/components/jet/shelf/LargeBrickShelf.svelte @@ -0,0 +1,26 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface LargeBrickShelf extends Shelf { + items: Brick[]; + } + + export function isLargeBrickShelf(shelf: Shelf): shelf is LargeBrickShelf { + const { contentType, items } = shelf; + return contentType === 'largeBrick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import LargeBrickItem from '~/components/jet/item/LargeBrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeBrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="LargeBrick" let:item> + <LargeBrickItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte new file mode 100644 index 0000000..a0dfe9c --- /dev/null +++ b/src/components/jet/shelf/LargeHeroBreakoutShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { + LargeHeroBreakout, + Shelf, + } from '@jet-app/app-store/api/models'; + + interface LargeHeroBreakoutShelf extends Shelf { + items: LargeHeroBreakout[]; + } + + export function isLargeHeroBreakoutShelf( + shelf: Shelf, + ): shelf is LargeHeroBreakoutShelf { + const { contentType, items } = shelf; + return contentType === 'largeHeroBreakout' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import LargeHeroBreakoutItem from '~/components/jet/item/LargeHeroBreakoutItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeHeroBreakoutShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="Spotlight" let:item> + <LargeHeroBreakoutItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeImageLockupShelf.svelte b/src/components/jet/shelf/LargeImageLockupShelf.svelte new file mode 100644 index 0000000..fd192fb --- /dev/null +++ b/src/components/jet/shelf/LargeImageLockupShelf.svelte @@ -0,0 +1,30 @@ +<script lang="ts" context="module"> + import type { Shelf, ImageLockup } from '@jet-app/app-store/api/models'; + + interface LargeImageLockupShelf extends Shelf { + items: ImageLockup[]; + } + + export function isLargeImageLockupShelf( + shelf: Shelf, + ): shelf is LargeImageLockupShelf { + return ( + shelf.contentType === 'largeImageLockup' && + Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import LargeImageLockupItem from '~/components/jet/item/LargeImageLockupItem.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeImageLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="A" let:item> + <LargeImageLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeLockupShelf.svelte b/src/components/jet/shelf/LargeLockupShelf.svelte new file mode 100644 index 0000000..dedd1fe --- /dev/null +++ b/src/components/jet/shelf/LargeLockupShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { Lockup, Shelf } from '@jet-app/app-store/api/models'; + + interface LargeLockupShelf extends Shelf { + items: Lockup[]; + } + + export function isLargeLockupShelf( + shelf: Shelf, + ): shelf is LargeLockupShelf { + const { contentType, items } = shelf; + return contentType === 'largeLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import LargeLockupItem from '~/components/jet/item/LargeLockupItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: LargeLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="LargeLockup" let:item> + <LargeLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/LargeStoryCardShelf.svelte b/src/components/jet/shelf/LargeStoryCardShelf.svelte new file mode 100644 index 0000000..c1a1e57 --- /dev/null +++ b/src/components/jet/shelf/LargeStoryCardShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { Shelf, TodayCard } from '@jet-app/app-store/api/models'; + + interface LargeStoryCardShelf extends Shelf { + items: TodayCard[]; + } + + export function isLargeStoryCardShelf( + shelf: Shelf, + ): shelf is LargeStoryCardShelf { + return ( + shelf.contentType === 'largeStoryCard' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import HeroCarousel from '~/components/hero/Carousel.svelte'; + import LargeStoryCardItem from '~/components/jet/item/LargeStoryCardItem.svelte'; + + export let shelf: LargeStoryCardShelf; + + $: items = shelf.items; + + function deriveBackgroundArtworkFromItem(item: TodayCard) { + return item.heroMedia?.artworks[0]; + } +</script> + +<HeroCarousel {shelf} {items} {deriveBackgroundArtworkFromItem} let:item> + <LargeStoryCardItem {item} /> +</HeroCarousel> diff --git a/src/components/jet/shelf/LinkableTextShelf.svelte b/src/components/jet/shelf/LinkableTextShelf.svelte new file mode 100644 index 0000000..dcfde36 --- /dev/null +++ b/src/components/jet/shelf/LinkableTextShelf.svelte @@ -0,0 +1,43 @@ +<script lang="ts" context="module"> + import type { Shelf, LinkableText } from '@jet-app/app-store/api/models'; + + interface LinkableTextShelf extends Shelf { + contentType: 'linkableText'; + items: [LinkableText]; + } + + export function isLinkableTextShelf( + shelf: Shelf, + ): shelf is LinkableTextShelf { + return ( + shelf.contentType === 'linkableText' && Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + + export let shelf: LinkableTextShelf; +</script> + +<ShelfWrapper centered withPaddingTop={true}> + <div class="banner"> + <LinkableTextItem item={shelf.items[0]} /> + </div> +</ShelfWrapper> + +<style> + .banner { + background: rgba(var(--keyColor-rgb), 0.07); + padding: 8px 16px; + text-align: center; + border-radius: var(--global-border-radius-small); + } + + .banner :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/shelf/MarkerShelf.svelte b/src/components/jet/shelf/MarkerShelf.svelte new file mode 100644 index 0000000..c719235 --- /dev/null +++ b/src/components/jet/shelf/MarkerShelf.svelte @@ -0,0 +1,36 @@ +<script lang="ts" context="module"> + import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models'; + import type { + Lockup, + Shelf, + ShelfMarker, + } from '@jet-app/app-store/api/models'; + + export interface MarkerShelf extends Shelf { + contentType: 'marker'; + marker: ShelfMarker; + items: Lockup[]; + } + + export function isMarkerShelf(shelf: Shelf): shelf is MarkerShelf { + const { contentType, marker, items } = shelf; + + return ( + contentType === 'marker' && + typeof marker === 'string' && + Array.isArray(items) + ); + } +</script> + +<script lang="ts"> + import ProductTopLockup from '~/components/jet/marker-shelf/ProductTopLockup.svelte'; + + export let shelf: MarkerShelf; + + export let page: ShelfBasedProductPage; +</script> + +{#if shelf.marker === 'productTopLockup'} + <ProductTopLockup {page} /> +{/if} diff --git a/src/components/jet/shelf/MediumImageLockupShelf.svelte b/src/components/jet/shelf/MediumImageLockupShelf.svelte new file mode 100644 index 0000000..f7b1316 --- /dev/null +++ b/src/components/jet/shelf/MediumImageLockupShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { ImageLockup, Shelf } from '@jet-app/app-store/api/models'; + + interface MediumImageLockupShelf extends Shelf { + items: ImageLockup[]; + } + + export function isMediumImageLockupShelf( + shelf: Shelf, + ): shelf is MediumImageLockupShelf { + const { contentType, items } = shelf; + return contentType === 'mediumImageLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import MediumImageLockupItem from '~/components/jet/item/MediumImageLockupItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: MediumImageLockupShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <MediumImageLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/MediumLockupShelf.svelte b/src/components/jet/shelf/MediumLockupShelf.svelte new file mode 100644 index 0000000..186acb2 --- /dev/null +++ b/src/components/jet/shelf/MediumLockupShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Lockup, Shelf } from '@jet-app/app-store/api/models'; + + interface MediumLockupShelf extends Shelf { + items: Lockup[]; + } + + export function isMediumLockupShelf( + shelf: Shelf, + ): shelf is MediumLockupShelf { + const { contentType, items } = shelf; + return contentType === 'mediumLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import MediumLockupItem from '~/components/jet/item/MediumLockupItem.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: MediumLockupShelf; + + $: isArticleContext = shelf.presentationHints?.isArticleContext; + $: gridType = isArticleContext ? 'Spotlight' : 'MediumLockup'; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} {gridType} rowsPerColumnOverride={2} let:item> + <MediumLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/MediumStoryCardShelf.svelte b/src/components/jet/shelf/MediumStoryCardShelf.svelte new file mode 100644 index 0000000..35c3ec3 --- /dev/null +++ b/src/components/jet/shelf/MediumStoryCardShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Shelf } from '@jet-app/app-store/api/models'; + + import MediumStoryCardItem, { + type Item as MediumStoryCardItemModel, + } from '~/components/jet/item/MediumStoryCardItem.svelte'; + + interface MediumStoryCardShelf extends Shelf { + items: MediumStoryCardItemModel[]; + } + + export function isMediumStoryCardShelf( + shelf: Shelf, + ): shelf is MediumStoryCardShelf { + const { contentType, items } = shelf; + return contentType === 'mediumStoryCard' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: MediumStoryCardShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <MediumStoryCardItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/PageHeaderShelf.svelte b/src/components/jet/shelf/PageHeaderShelf.svelte new file mode 100644 index 0000000..59c99b2 --- /dev/null +++ b/src/components/jet/shelf/PageHeaderShelf.svelte @@ -0,0 +1,34 @@ +<script lang="ts" context="module"> + import type { PageHeader, Shelf } from '@jet-app/app-store/api/models'; + + interface PageHeaderShelf extends Shelf { + items: [PageHeader]; + } + + export function isPageHeaderShelf(shelf: Shelf): shelf is PageHeaderShelf { + const { contentType, items } = shelf; + return contentType === 'pageHeader' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + + export let shelf: PageHeaderShelf; + + $: [item] = shelf.items; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div class="shelf-title-wrapper" slot="title"> + <ShelfTitle title={item.title} subtitle={item.subtitle} /> + </div> +</ShelfWrapper> + +<style> + .shelf-title-wrapper { + --shelf-title-font: var(--title-1-emphasized); + display: contents; + } +</style> diff --git a/src/components/jet/shelf/ParagraphShelf.svelte b/src/components/jet/shelf/ParagraphShelf.svelte new file mode 100644 index 0000000..777338e --- /dev/null +++ b/src/components/jet/shelf/ParagraphShelf.svelte @@ -0,0 +1,52 @@ +<script lang="ts" context="module"> + import type { Paragraph, Shelf } from '@jet-app/app-store/api/models'; + + interface ParagraphShelf extends Shelf { + contentType: 'paragraph'; + items: Paragraph[]; + } + + export function isParagraphShelf(shelf: Shelf): shelf is ParagraphShelf { + return shelf.contentType === 'paragraph' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ParagraphShelfItem from '~/components/jet/item/ParagraphShelfItem.svelte'; + + export let shelf: ParagraphShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div slot="title" class="title-container"> + {#if shelf.title} + <h2>{shelf.title}</h2> + {/if} + </div> + + <div class="content-container"> + {#each shelf.items as item} + <ParagraphShelfItem {item} /> + {/each} + </div> +</ShelfWrapper> + +<style> + h2 { + color: var(--systemPrimary); + font: var(--title-2-emphasized); + text-wrap: pretty; + margin: 16px 0; + } + + .title-container, + .content-container { + margin: 0 var(--bodyGutter); + } + + /* Whenever this shelf is nested in a modal, we don't want to add extra margin since the modal provides its own */ + :global(.modal-content) .content-container { + margin: unset; + } +</style> diff --git a/src/components/jet/shelf/PosterLockupShelf.svelte b/src/components/jet/shelf/PosterLockupShelf.svelte new file mode 100644 index 0000000..101c1d6 --- /dev/null +++ b/src/components/jet/shelf/PosterLockupShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { Shelf, PosterLockup } from '@jet-app/app-store/api/models'; + + interface PosterLockupShelf extends Shelf { + items: PosterLockup[]; + } + + export function isPosterLockupShelf( + shelf: Shelf, + ): shelf is PosterLockupShelf { + const { contentType, items } = shelf; + return contentType === 'posterLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import mediaQueries from '~/utils/media-queries'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import PosterLockupItem from '~/components/jet/item/PosterLockupItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: PosterLockupShelf; + + $: gridType = $mediaQueries === 'xsmall' ? 'Spotlight' : 'PosterLockup'; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} {gridType} let:item> + <PosterLockupItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/PrivacyFooterShelf.svelte b/src/components/jet/shelf/PrivacyFooterShelf.svelte new file mode 100644 index 0000000..dccade6 --- /dev/null +++ b/src/components/jet/shelf/PrivacyFooterShelf.svelte @@ -0,0 +1,40 @@ +<script lang="ts" context="module"> + import type { PrivacyFooter, Shelf } from '@jet-app/app-store/api/models'; + + interface PrivacyFooterShelf extends Shelf { + items: [PrivacyFooter]; + } + + export function isPrivacyFooterShelf( + shelf: Shelf, + ): shelf is PrivacyFooterShelf { + let { contentType, items } = shelf; + + return contentType === 'privacyFooter' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import LinkableTextItem from '~/components/jet/item/LinkableTextItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: PrivacyFooterShelf; + + $: bodyText = shelf.items[0].bodyText; +</script> + +<ShelfWrapper {shelf} centered> + <p> + <LinkableTextItem item={bodyText} /> + </p> +</ShelfWrapper> + +<style> + p { + font: var(--body-tall); + } + + p :global(a) { + color: var(--keyColor); + } +</style> diff --git a/src/components/jet/shelf/PrivacyHeaderShelf.svelte b/src/components/jet/shelf/PrivacyHeaderShelf.svelte new file mode 100644 index 0000000..5ace666 --- /dev/null +++ b/src/components/jet/shelf/PrivacyHeaderShelf.svelte @@ -0,0 +1,145 @@ +<script lang="ts" context="module"> + import { + type Action, + type FlowAction, + type GenericPage, + type PrivacyHeader, + type Shelf, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + import { + isPrivacyTypeShelf, + type PrivacyTypeShelf, + } from '~/components/jet/shelf/PrivacyTypeShelf.svelte'; + + interface PrivacyHeaderShelf extends Shelf { + items: [PrivacyHeader]; + } + + interface PrivacyDetailPage extends GenericPage { + shelves: (PrivacyTypeShelf | PrivacyHeaderShelf)[]; + } + + interface PrivacyDetailPageFlowAction extends FlowAction { + page: 'privacyDetail'; + pageData: PrivacyDetailPage; + } + + export function isPrivacyHeaderShelf( + shelf: Shelf, + ): shelf is PrivacyHeaderShelf { + let { contentType, items } = shelf; + return contentType === 'privacyHeader' && Array.isArray(items); + } + + function isPrivacyDetailFlowAction( + action: Action, + ): action is PrivacyDetailPageFlowAction { + return isFlowAction(action) && action.page === 'privacyDetail'; + } +</script> + +<script lang="ts"> + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import PrivacyHeaderItem from '~/components/jet/item/PrivacyHeaderItem.svelte'; + import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte'; + import { getI18n } from '~/stores/i18n'; + import { APP_PRIVACY_MODAL_ID } from '~/utils/metrics'; + + export let shelf: PrivacyHeaderShelf; + + let modalComponent: Modal | undefined; + let modalTriggerElement: HTMLElement | null = null; + + const { seeAllAction } = shelf; + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + const handleModalClose = () => modalComponent?.close(); + const handleOpenModalClick = (e: Event) => { + modalTriggerElement = e.target as HTMLElement; + modalComponent?.showModal(); + }; + + const destination = + seeAllAction && isPrivacyDetailFlowAction(seeAllAction) + ? seeAllAction + : undefined; + const pageData = destination?.pageData; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div slot="title" class="title-container"> + {#if shelf.title} + <button on:click={handleOpenModalClick}> + <ShelfTitle title={shelf.title} seeAllAction={destination} /> + </button> + {/if} + + {#if pageData} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + {translateFn} + on:close={handleModalClose} + title={pageData.title || null} + subtitle={null} + targetId={APP_PRIVACY_MODAL_ID} + > + <svelte:fragment slot="content"> + <ul class="modal-content-container"> + {#each pageData.shelves as shelf} + {#if isPrivacyHeaderShelf(shelf)} + {#each shelf.items as item} + <PrivacyHeaderItem {item} /> + {/each} + {/if} + + {#if isPrivacyTypeShelf(shelf)} + {#each shelf.items as item} + <PrivacyTypeItem + {item} + isDetailView={true} + /> + {/each} + {/if} + {/each} + </ul> + </svelte:fragment> + </ContentModal> + </Modal> + {/if} + </div> + + <div class="header-container"> + <div> + <PrivacyHeaderItem item={shelf.items[0]} /> + </div> + </div> +</ShelfWrapper> + +<style> + .title-container { + display: flex; + justify-content: space-between; + padding-top: 16px; + padding-inline-end: var(--bodyGutter); + } + + .header-container { + margin: 0 var(--bodyGutter); + } + + .header-container div { + @media (--range-medium-up) { + width: 66%; + } + } + + .modal-content-container { + font: var(--body-tall); + white-space: normal; + } +</style> diff --git a/src/components/jet/shelf/PrivacyTypeShelf.svelte b/src/components/jet/shelf/PrivacyTypeShelf.svelte new file mode 100644 index 0000000..3817251 --- /dev/null +++ b/src/components/jet/shelf/PrivacyTypeShelf.svelte @@ -0,0 +1,29 @@ +<script lang="ts" context="module"> + import type { PrivacyType, Shelf } from '@jet-app/app-store/api/models'; + + export interface PrivacyTypeShelf extends Shelf { + items: PrivacyType[]; + } + + export function isPrivacyTypeShelf( + shelf: Shelf, + ): shelf is PrivacyTypeShelf { + let { contentType, items } = shelf; + + return contentType === 'privacyType' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import PrivacyTypeItem from '~/components/jet/item/PrivacyTypeItem.svelte'; + + export let shelf: PrivacyTypeShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <ShelfItemLayout {shelf} gridType="B" let:item> + <PrivacyTypeItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ProductBadgeShelf.svelte b/src/components/jet/shelf/ProductBadgeShelf.svelte new file mode 100644 index 0000000..cded0b7 --- /dev/null +++ b/src/components/jet/shelf/ProductBadgeShelf.svelte @@ -0,0 +1,59 @@ +<script lang="ts" context="module"> + import type { Badge, Shelf } from '@jet-app/app-store/api/models'; + + interface ProductBadgeShelf extends Shelf { + items: Badge[]; + } + + export function isProductBadgeShelf( + shelf: Shelf, + ): shelf is ProductBadgeShelf { + const { contentType, items } = shelf || {}; + return contentType === 'productBadge' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductBadgeItem from '~/components/jet/item/ProductBadgeItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductBadgeShelf; + + $: shelf.items = shelf.items.filter( + (item) => item.type !== 'friendsPlaying', + ); +</script> + +<ShelfWrapper {shelf} withBottomPadding={false} withTopMargin={true}> + <div class="inforibbon-shelf-wrapper"> + <ShelfItemLayout {shelf} gridType="ProductBadge" let:item> + <ProductBadgeItem {item} /> + </ShelfItemLayout> + </div> +</ShelfWrapper> + +<style lang="scss"> + .inforibbon-shelf-wrapper { + padding-bottom: 16px; + } + + .inforibbon-shelf-wrapper :global(ul) { + display: grid; + + /* + Here we are overriding the grid template styles from `ShelfItemLayout -> Grid`, + to make it so the badge row always takes up the full-width of the browser until + when not in the XS/mobile view. + */ + @media (--range-small-up) { + display: flex; + justify-content: space-between; + } + } + + // prevent collapse of focus outlines + .inforibbon-shelf-wrapper :global(a) { + display: block; + } +</style> diff --git a/src/components/jet/shelf/ProductCapabilityShelf.svelte b/src/components/jet/shelf/ProductCapabilityShelf.svelte new file mode 100644 index 0000000..6a4307a --- /dev/null +++ b/src/components/jet/shelf/ProductCapabilityShelf.svelte @@ -0,0 +1,31 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ProductCapability, + } from '@jet-app/app-store/api/models'; + + interface ProductCapabilityShelf extends Shelf { + items: ProductCapability[]; + } + + export function isProductCapabilityShelf( + shelf: Shelf, + ): shelf is ProductCapabilityShelf { + const { contentType, items } = shelf; + return contentType === 'productCapability' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductCapabilityItem from '../item/ProductCapabilityItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductCapabilityShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="SearchLink" let:item> + <ProductCapabilityItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ProductDescriptionShelf.svelte b/src/components/jet/shelf/ProductDescriptionShelf.svelte new file mode 100644 index 0000000..7cddcee --- /dev/null +++ b/src/components/jet/shelf/ProductDescriptionShelf.svelte @@ -0,0 +1,95 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ProductDescription, + } from '@jet-app/app-store/api/models'; + + interface ProductDescriptionShelf extends Shelf { + items: [ProductDescription]; + } + + export function isProductDescriptionShelf( + shelf: Shelf, + ): shelf is ProductDescriptionShelf { + const { contentType, items } = shelf; + + return contentType === 'productDescription' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let shelf: ProductDescriptionShelf; + + const i18n = getI18n(); + const description = shelf.items[0]?.paragraph.text; + const handleMoreClick = () => (isOpen = true); + let isOpen = false; + + function handleLineClampResize(event: CustomEvent) { + if (!event.detail.truncated) { + isOpen = true; + } + } +</script> + +<ShelfWrapper centered> + <article> + <p> + {#if isOpen} + {@html sanitizeHtml(description)} + {:else} + <LineClamp observe clamp={5} on:resize={handleLineClampResize}> + {@html sanitizeHtml(description)} + </LineClamp> + {/if} + + {#if !isOpen} + <button on:click={handleMoreClick}> + {$i18n.t('ASE.Web.AppStore.More')} + </button> + {/if} + </p> + </article> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + p { + white-space: break-spaces; + font: var(--body-tall); + position: relative; + display: flex; + flex-direction: column; + + @media (--range-medium-up) { + width: 66%; + } + } + + button { + --gradient-direction: 270deg; + display: flex; + justify-content: end; + position: absolute; + bottom: 0; + inset-inline-end: 0; + padding-inline-start: 20px; + color: var(--keyColor); + background: linear-gradient( + var(--gradient-direction), + var(--pageBg) 72%, + transparent 100% + ); + + @include rtl { + --gradient-direction: 90deg; + } + } +</style> diff --git a/src/components/jet/shelf/ProductMediaShelf.svelte b/src/components/jet/shelf/ProductMediaShelf.svelte new file mode 100644 index 0000000..f57fee7 --- /dev/null +++ b/src/components/jet/shelf/ProductMediaShelf.svelte @@ -0,0 +1,269 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ProductMedia, + AppPlatform, + MediaType, + MediaPlatform, + } from '@jet-app/app-store/api/models'; + + interface ProductMediaShelf extends Shelf, ProductMedia { + items: ProductMedia['items']; + expandedMedia?: ProductMediaShelf[]; + } + + export function isProductMediaShelf( + shelf: Shelf, + ): shelf is ProductMediaShelf { + const { contentType, items } = shelf; + return contentType === 'productMediaItem' && Array.isArray(items); + } + + const platformToIconNameMap: Record<AppPlatform, string> = { + phone: 'iphone.gen2', + pad: 'ipad.gen2', + tv: 'tv', + watch: 'applewatch', + mac: 'macbook.gen2', + messages: 'message', + vision: 'visionpro', + }; + + const platformToDescriptionMap: Record<AppPlatform, string> = { + phone: 'AppStore.AppPlatform.Phone', + pad: 'AppStore.AppPlatform.Pad', + tv: 'AppStore.AppPlatform.TV', + watch: 'AppStore.AppPlatform.Watch', + mac: 'AppStore.AppPlatform.Mac', + messages: 'AppStore.AppPlatform.Messages', + vision: 'AppStore.AppPlatform.Vision', + }; +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductMediaVisionItem from '~/components/jet/item/ProductMedia/ProductMediaVisionItem.svelte'; + import ProductMediaPhoneItem from '~/components/jet/item/ProductMedia/ProductMediaPhoneItem.svelte'; + import ProductMediaMacItem from '~/components/jet/item/ProductMedia/ProductMediaMacItem.svelte'; + import ProductMediaPadItem from '~/components/jet/item/ProductMedia/ProductMediaPadItem.svelte'; + import ProductMediaWatchItem from '~/components/jet/item/ProductMedia/ProductMediaWatchItem.svelte'; + import ProductMediaTVItem from '~/components/jet/item/ProductMedia/ProductMediaTVItem.svelte'; + import ChevronDown from '~/sf-symbols/chevron.down.svg'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { getI18n } from '~/stores/i18n'; + import { slide } from 'svelte/transition'; + import { getJet } from '~/jet'; + + export let shelf: ProductMediaShelf; + export let isExpandedMedia: boolean = false; + + const i18n = getI18n(); + const jet = getJet(); + + let appPlatform: AppPlatform | undefined; + let allPlatforms: MediaPlatform[] | undefined; + let mediaType: MediaType | undefined; + let hasPortraitMedia: boolean = false; + let shouldDisplayExpandedMedia: boolean = false; + + $: { + if (shelf.contentsMetadata.type === 'productMedia') { + ({ hasPortraitMedia, allPlatforms } = shelf.contentsMetadata); + ({ appPlatform, mediaType } = shelf.contentsMetadata.platform); + } + } + + $: allPlatformsDescription = allPlatforms + ?.map(({ appPlatform }) => + $i18n.t(platformToDescriptionMap[appPlatform]), + ) + ?.join($i18n.t('AppStore.AppPlatform.Component.Separator')); + + $: shouldShowPlatform = + isExpandedMedia || + shouldDisplayExpandedMedia || + allPlatforms?.length === 1; + + const displayExpandedMedia = () => { + shouldDisplayExpandedMedia = true; + jet.recordCustomMetricsEvent({ + eventType: 'click', + actionDetails: { type: 'platformSelect' }, + targetType: 'button', + targetId: 'productMediaShelf', + }); + }; +</script> + +<ShelfWrapper {shelf} withBottomPadding={!shelf.expandedMedia}> + {#if appPlatform === 'vision'} + <ShelfItemLayout {shelf} gridType="ScreenshotVision" let:item> + <ProductMediaVisionItem {item} /> + </ShelfItemLayout> + {:else if appPlatform === 'phone' || appPlatform === 'messages'} + <ShelfItemLayout + {shelf} + gridType={hasPortraitMedia ? 'ScreenshotPhone' : 'ScreenshotLarge'} + let:item + > + <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} /> + </ShelfItemLayout> + {:else if appPlatform === 'pad'} + <ShelfItemLayout + {shelf} + gridType={hasPortraitMedia ? 'ScreenshotPad' : 'ScreenshotLarge'} + let:item + > + <ProductMediaPadItem {item} {hasPortraitMedia} {mediaType} /> + </ShelfItemLayout> + {:else if appPlatform === 'mac'} + <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item> + <ProductMediaMacItem {item} /> + </ShelfItemLayout> + {:else if appPlatform === 'tv'} + <ShelfItemLayout {shelf} gridType="ScreenshotLarge" let:item> + <ProductMediaTVItem {item} /> + </ShelfItemLayout> + {:else if appPlatform === 'watch'} + <ShelfItemLayout {shelf} gridType="ScreenshotPhone" let:item> + <ProductMediaWatchItem {item} {mediaType} /> + </ShelfItemLayout> + {:else} + <ShelfItemLayout {shelf} gridType="A" let:item> + <ProductMediaPhoneItem {item} {hasPortraitMedia} {mediaType} /> + </ShelfItemLayout> + {/if} + + {#if appPlatform && shouldShowPlatform} + <div class="platform-description"> + <div class="icon" aria-hidden="true"> + <SFSymbol name={platformToIconNameMap[appPlatform]} /> + </div> + <div class="platform-label"> + {$i18n.t(platformToDescriptionMap[appPlatform])} + </div> + </div> + {/if} +</ShelfWrapper> + +{#if shelf.expandedMedia && allPlatforms && allPlatforms.length > 1} + <div class="expanded-media"> + {#if !shouldDisplayExpandedMedia} + <button + class="expanded-media-header" + on:click={displayExpandedMedia} + > + <div class="all-platforms"> + <div class="all-platforms-icons"> + {#each allPlatforms as platform} + <div class="icon" aria-hidden="true"> + <SFSymbol + name={platformToIconNameMap[ + platform.appPlatform + ]} + /> + </div> + {/each} + </div> + <div class="all-platforms-names"> + {allPlatformsDescription} + </div> + </div> + <div class="chevron-container icon" aria-hidden="true"> + <ChevronDown /> + </div> + </button> + {/if} + {#if shouldDisplayExpandedMedia} + <div class="expanded-media-content" transition:slide> + {#each shelf.expandedMedia as expandedMediaShelf} + <svelte:self + shelf={expandedMediaShelf} + isExpandedMedia={true} + /> + {/each} + </div> + {/if} + </div> +{/if} + +{#if !isExpandedMedia} + <div class="divider" /> +{/if} + +<style> + .expanded-media { + margin: 15px 0; + } + + .expanded-media-header { + width: 100%; + padding-inline: var(--bodyGutter); + display: inline-flex; + align-items: center; + justify-content: space-between; + } + + .platform-description { + display: inline-flex; + align-items: center; + font: var(--body-reduced-semibold); + color: var(--systemSecondary); + margin-top: 15px; + gap: 10px; + margin-inline: var(--bodyGutter); + } + + .all-platforms { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 10px; + color: var(--systemSecondary); + } + + .all-platforms-icons { + display: inline-flex; + gap: 10px; + } + + .all-platforms-names { + font: var(--body-reduced-semibold); + } + + .icon :global(svg) { + overflow: visible; + height: 16px; + max-width: 25px; + fill: var(--systemSecondary); + position: relative; + display: flex; + } + + .divider { + margin: 10px var(--bodyGutter); + border-bottom: 1px solid var(--systemGray4); + } + + .chevron-container { + top: 2px; + } + + .expanded-media-content :global(.shelf:last-of-type) { + padding-bottom: 0; + } + + .expanded-media-header .all-platforms, + .expanded-media-header .chevron-container :global(svg) { + transition-duration: 210ms; + transition-timing-function: ease-out; + transition-property: color, fill; + } + + .expanded-media-header:hover .all-platforms, + .expanded-media-header:hover .chevron-container :global(svg) { + color: var(--systemPrimary); + fill: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/shelf/ProductPageLinkShelf.svelte b/src/components/jet/shelf/ProductPageLinkShelf.svelte new file mode 100644 index 0000000..7b41e80 --- /dev/null +++ b/src/components/jet/shelf/ProductPageLinkShelf.svelte @@ -0,0 +1,59 @@ +<script lang="ts" context="module"> + import type { Shelf, ProductPageLink } from '@jet-app/app-store/api/models'; + + interface ProductPageLinkShelf extends Shelf { + items: ProductPageLink[]; + } + + export function isProductPageLinkShelf( + shelf: Shelf, + ): shelf is ProductPageLinkShelf { + const { contentType, items } = shelf; + return contentType === 'productPageLink' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ProductPageLinkItem from '~/components/jet/item/ProductPageLinkItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductPageLinkShelf; +</script> + +<ShelfWrapper {shelf}> + <div class="product-page-link-shelf"> + {#each shelf.items as item} + <li class="product-page-link-item"> + <ProductPageLinkItem {item} /> + </li> + {/each} + </div> +</ShelfWrapper> + +<style lang="scss"> + $product-page-link-border: 1px solid var(--systemGray4); + + .product-page-link-shelf { + display: flex; + justify-content: center; + align-items: center; + column-gap: 20px; + + @media (--range-xsmall-down) { + flex-direction: column; + align-items: flex-start; + } + } + + @media (--range-xsmall-down) { + .product-page-link-item:first-child { + border-top: $product-page-link-border; + } + + .product-page-link-item { + width: 100%; + border-bottom: $product-page-link-border; + padding: 0 var(--bodyGutter); + } + } +</style> diff --git a/src/components/jet/shelf/ProductRatingsShelf.svelte b/src/components/jet/shelf/ProductRatingsShelf.svelte new file mode 100644 index 0000000..8f09ab5 --- /dev/null +++ b/src/components/jet/shelf/ProductRatingsShelf.svelte @@ -0,0 +1,29 @@ +<script lang="ts" context="module"> + import { type Ratings, type Shelf } from '@jet-app/app-store/api/models'; + + interface ProductRatingsShelf extends Shelf { + items: Ratings[]; + } + + export function isProductRatingsShelf( + shelf: Shelf, + ): shelf is ProductRatingsShelf { + let { contentType, items } = shelf; + + return contentType === 'productRatings' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ProductRatingsItem from '~/components/jet/item/ProductRatingsItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: ProductRatingsShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <ShelfItemLayout {shelf} gridType="A" let:item> + <ProductRatingsItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/ProductReviewShelf.svelte b/src/components/jet/shelf/ProductReviewShelf.svelte new file mode 100644 index 0000000..6bc4ecb --- /dev/null +++ b/src/components/jet/shelf/ProductReviewShelf.svelte @@ -0,0 +1,38 @@ +<script lang="ts" context="module"> + import type { ProductReview, Shelf } from '@jet-app/app-store/api/models'; + + interface ProductReviewShelf extends Shelf { + items: ProductReview[]; + } + + export function isProductReviewShelf( + shelf: Shelf, + ): shelf is ProductReviewShelf { + let { contentType, items } = shelf; + + return contentType === 'productReview' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import EditorsChoiceReviewItem, { + isEditorsChoiceReviewItem, + } from '~/components/jet/item/ProductReview/EditorsChoiceReviewItem.svelte'; + import UserReviewItem, { + isUserReviewItem, + } from '~/components/jet/item/ProductReview/UserReviewItem.svelte'; + + export let shelf: ProductReviewShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="A" let:item> + {#if isUserReviewItem(item)} + <UserReviewItem {item} /> + {:else if isEditorsChoiceReviewItem(item)} + <EditorsChoiceReviewItem {item} /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/QuoteShelf.svelte b/src/components/jet/shelf/QuoteShelf.svelte new file mode 100644 index 0000000..3a14f4f --- /dev/null +++ b/src/components/jet/shelf/QuoteShelf.svelte @@ -0,0 +1,80 @@ +<script lang="ts" context="module"> + import type { Quote, Shelf } from '@jet-app/app-store/api/models'; + + interface QuoteShelf extends Shelf { + contentType: 'quote'; + items: [Quote]; + } + + export function isQuoteShelf(shelf: Shelf): shelf is QuoteShelf { + return shelf.contentType === 'quote' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: QuoteShelf; + + $: item = shelf.items[0]; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false}> + <div class="outer"> + <div class="inner"> + <blockquote> + {item.text} + </blockquote> + <span>{item.credit}</span> + </div> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/locale' as *; + + .outer { + display: flex; + margin-bottom: 24px; + padding: 0 var(--bodyGutter); + gap: 6px; + } + + .outer::before { + content: '❝'; + font-size: 40px; + line-height: 2.2rem; + color: var(--systemSecondary); + + @include rtl { + content: '❞'; + } + } + + .inner { + display: flex; + flex-direction: column; + gap: 6px; + } + + blockquote { + font: var(--large-title-emphasized); + text-wrap: pretty; + } + + blockquote::after { + content: '❞'; + color: var(--systemSecondary); + + @include rtl { + content: '❝'; + } + } + + span { + font: var(--title-3); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/jet/shelf/ReviewsContainerShelf.svelte b/src/components/jet/shelf/ReviewsContainerShelf.svelte new file mode 100644 index 0000000..a55fe40 --- /dev/null +++ b/src/components/jet/shelf/ReviewsContainerShelf.svelte @@ -0,0 +1,84 @@ +<script lang="ts" context="module"> + import type { + Shelf, + ReviewsContainer, + } from '@jet-app/app-store/api/models'; + + export interface ReviewsContainerShelf extends Shelf { + items: [ReviewsContainer]; + } + + export function isReviewsContainerShelf( + shelf: Shelf, + ): shelf is ReviewsContainerShelf { + return ( + shelf.contentType === 'reviewsContainer' && + Array.isArray(shelf.items) + ); + } +</script> + +<script lang="ts"> + import RatingComponent from '@amp/web-app-components/src/components/Rating/Rating.svelte'; + + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import Grid from '~/components/Grid.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getJet } from '~/jet/svelte'; + + const i18n = getI18n(); + const jet = getJet(); + + export let shelf: ReviewsContainerShelf; + + $: reviewsContainer = shelf.items[0]; + $: ({ productAction, ratings } = reviewsContainer); + + $: numberOfRatings = jet.localization.formattedCount( + ratings.totalNumberOfRatings, + ); +</script> + +<ShelfWrapper {shelf}> + <header slot="title"> + {#if productAction} + <div class="product-action"> + <LinkWrapper action={productAction}> + {productAction.title} + </LinkWrapper> + </div> + {/if} + + <ShelfTitle title={shelf.title ?? ''} /> + + <Grid gridType="A" items={[1]}> + <div class="rating"> + <RatingComponent + averageRating={ratings.ratingAverage} + ratingCount={ratings.totalNumberOfRatings} + ratingCountText={$i18n.t( + 'ASE.Web.AppStore.Ratings.CountText', + { + numberOfRatings, + }, + )} + ratingCountsList={ratings.ratingCounts} + totalText={$i18n.t('ASE.Web.AppStore.Ratings.TotalText')} + /> + </div> + </Grid> + </header> +</ShelfWrapper> + +<style> + .product-action { + --linkColor: var(--keyColor); + margin: 0 var(--bodyGutter) 6px; + } + + .rating { + --ratingBarColor: var(--systemPrimary); + } +</style> diff --git a/src/components/jet/shelf/ReviewsShelf.svelte b/src/components/jet/shelf/ReviewsShelf.svelte new file mode 100644 index 0000000..8304444 --- /dev/null +++ b/src/components/jet/shelf/ReviewsShelf.svelte @@ -0,0 +1,28 @@ +<script lang="ts" context="module"> + import type { + Shelf, + Review as ReviewModel, + } from '@jet-app/app-store/api/models'; + + export interface ReviewsShelf extends Shelf { + items: ReviewModel[]; + } + + export function isReviewsShelf(shelf: Shelf): shelf is ReviewsShelf { + return shelf.contentType === 'reviews' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import ReviewItem from '~/components/jet/item/ReviewItem.svelte'; + + export let shelf: ReviewsShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="A" let:item> + <ReviewItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/RibbonBarShelf.svelte b/src/components/jet/shelf/RibbonBarShelf.svelte new file mode 100644 index 0000000..44a8ae9 --- /dev/null +++ b/src/components/jet/shelf/RibbonBarShelf.svelte @@ -0,0 +1,135 @@ +<script lang="ts" context="module"> + import type { Shelf, RibbonBarItem } from '@jet-app/app-store/api/models'; + + interface RibbonBarShelf extends Shelf { + items: RibbonBarItem[]; + } + + export function isRibbonBarShelf(shelf: Shelf): shelf is RibbonBarShelf { + return shelf.contentType === 'ribbonBar' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + + export let shelf: RibbonBarShelf; +</script> + +<ShelfWrapper {shelf} withBottomPadding={false} withPaddingTop={false}> + <div class="scroll"> + <ul> + {#each shelf.items as ribbonBarItem} + {@const action = ribbonBarItem.clickAction} + {@const artwork = ribbonBarItem.artwork} + {@const title = ribbonBarItem.title} + <li> + <LinkWrapper {action}> + {#if artwork} + <div + class="artwork-container" + style:--aspect-ratio={artwork.width / + artwork.height} + > + {#if isSystemImageArtwork(artwork)} + <SystemImage {artwork} /> + {:else} + <Artwork + {artwork} + profile={getNaturalProfile(artwork, [ + 17, + ])} + hasTransparentBackground + /> + {/if} + </div> + {/if} + {title} + </LinkWrapper> + </li> + {/each} + </ul> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use 'ac-sasskit/core/helpers' as *; + @use 'ac-sasskit/core/locale' as *; + + .scroll { + --gradient-direction: 90deg; + overflow-x: auto; + scrollbar-width: none; + padding-inline-start: var(--bodyGutter); + margin-inline-end: var(--bodyGutter); + // A small gradient that fades out the ribbon, to indicate that there is more + mask-image: linear-gradient( + var(--gradient-direction), + black calc(100% - 8px), + transparent 100% + ); + + @include rtl { + --gradient-direction: -90deg; + } + } + + ul { + font: var(--body-emphasized); + display: flex; + gap: 4px; + padding-bottom: 16px; + padding-top: 13px; + } + + li { + display: flex; + margin-inline-end: 8px; + flex-shrink: 0; + } + + li:last-of-type { + padding-inline-end: 8px; + } + + li :global(a) { + position: relative; + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + background: var(--pageBG); + border-radius: var(--global-border-radius-small); + padding: 6px 10px; + + &::after { + content: ''; + display: block; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + border-radius: var(--global-border-radius-small); + box-shadow: var(--shadow-small); + z-index: calc(var(--z-default) - 1); + } + + @media (prefers-color-scheme: dark) { + background: var(--systemGray5-default_IC); + } + } + + .artwork-container { + --artwork-override-height: 17px; + flex-shrink: 0; + aspect-ratio: var(--aspect-ratio); + height: 17px; + } +</style> diff --git a/src/components/jet/shelf/SearchLinkShelf.svelte b/src/components/jet/shelf/SearchLinkShelf.svelte new file mode 100644 index 0000000..6b29780 --- /dev/null +++ b/src/components/jet/shelf/SearchLinkShelf.svelte @@ -0,0 +1,26 @@ +<script lang="ts" context="module"> + import type { Shelf, SearchLink } from '@jet-app/app-store/api/models'; + + interface SearchLinkShelf extends Shelf { + items: SearchLink[]; + } + + export function isSearchLinkShelf(shelf: Shelf): shelf is SearchLinkShelf { + const { contentType, items } = shelf; + return contentType === 'searchLink' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import SearchLinkItem from '~/components/jet/item/SearchLinkItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SearchLinkShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="SearchLink" let:item> + <SearchLinkItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SearchResultShelf.svelte b/src/components/jet/shelf/SearchResultShelf.svelte new file mode 100644 index 0000000..9c15d3e --- /dev/null +++ b/src/components/jet/shelf/SearchResultShelf.svelte @@ -0,0 +1,49 @@ +<script lang="ts" context="module"> + import type { + AppSearchResult, + Shelf, + SearchResult, + AppEventSearchResult, + } from '@jet-app/app-store/api/models'; + + import AppSearchResultItem, { + isAppSearchResult, + isAppEventSearchResult, + } from '~/components/jet/item/SearchResult/AppSearchResultItem.svelte'; + + /** + * All sub-classes of {@linkcode SearchResult} that this component can handle rendering + */ + type RenderableSearchResult = AppSearchResult | AppEventSearchResult; + + interface SearchResultShelf extends Shelf { + items: SearchResult[]; + } + + export function isSearchResultShelf( + shelf: Shelf, + ): shelf is SearchResultShelf { + return ( + shelf.contentType === 'searchResult' && Array.isArray(shelf.items) + ); + } + + export function isRenderableInSearchResultsShelf( + item: SearchResult, + ): item is RenderableSearchResult { + return isAppSearchResult(item) || isAppEventSearchResult(item); + } +</script> + +<script lang="ts"> + import Grid from '~/components/Grid.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SearchResultShelf; +</script> + +<ShelfWrapper {shelf}> + <Grid gridType="SearchResult" items={shelf.items} let:item> + <AppSearchResultItem {item} /> + </Grid> +</ShelfWrapper> diff --git a/src/components/jet/shelf/Shelf.svelte b/src/components/jet/shelf/Shelf.svelte new file mode 100644 index 0000000..6cbb0f6 --- /dev/null +++ b/src/components/jet/shelf/Shelf.svelte @@ -0,0 +1,320 @@ +<script lang="ts"> + import type { Shelf } from '@jet-app/app-store/api/models'; + + // Components for specific types of `Shelf` + import AccessibilityHeaderShelf, { + isAccessibilityHeaderShelf, + } from '~/components/jet/shelf/AccessibilityHeaderShelf.svelte'; + import AccessibilityFeaturesShelf, { + isAccessibilityFeaturesShelf, + } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte'; + import AccessibilityDeveloperLinkShelf, { + isAccessibilityDeveloperLinkShelf, + } from './AccessibilityDeveloperLinkShelf.svelte'; + import ActionShelf, { + isActionShelf, + } from '~/components/jet/shelf/ActionShelf.svelte'; + import AnnotationShelf, { + isAnnotationShelf, + } from '~/components/jet/shelf/AnnotationShelf.svelte'; + import AppEventDetailShelf, { + isAppEventDetailShelf, + } from '~/components/jet/shelf/AppEventDetailShelf.svelte'; + import AppPromotionShelf, { + isAppPromotionShelf, + } from '~/components/jet/shelf/AppPromotionShelf.svelte'; + import AppShowcaseShelf, { + isAppShowcaseShelf, + } from '~/components/jet/shelf/AppShowcaseShelf.svelte'; + import AppTrailerLockupShelf, { + isAppTrailerLockupShelf, + } from '~/components/jet/shelf/AppTrailerLockupShelf.svelte'; + import ArcadeFooterShelf, { + isArcadeFooterShelf, + } from '~/components/jet/shelf/ArcadeFooterShelf.svelte'; + import { isBannerShelf } from '~/components/jet/shelf/BannerShelf.svelte'; + import BrickShelf, { + isBrickShelf, + } from '~/components/jet/shelf/BrickShelf.svelte'; + import CategoryBrickShelf, { + isCategoryBrickShelf, + } from '~/components/jet/shelf/CategoryBrickShelf.svelte'; + import EditorialCardShelf, { + isEditorialCardShelf, + } from '~/components/jet/shelf/EditorialCardShelf.svelte'; + import EditorialLinkShelf, { + isEditorialLinkShelf, + } from '~/components/jet/shelf/EditorialLinkShelf.svelte'; + import FramedArtworkShelf, { + isFramedArtworkShelf, + } from '~/components/jet/shelf/FramedArtworkShelf.svelte'; + import FramedVideoShelf, { + isFramedVideoShelf, + } from '~/components/jet/shelf/FramedVideoShelf.svelte'; + import HeroCarouselShelf, { + isHeroCarouselShelf, + } from '~/components/jet/shelf/HeroCarouselShelf.svelte'; + import HorizontalRuleShelf, { + isHorizontalRuleShelf, + } from '~/components/jet/shelf/HorizontalRuleShelf.svelte'; + import InAppPurchaseLockupShelf, { + isInAppPurchaseLockupShelf, + } from '~/components/jet/shelf/InAppPurchaseLockupShelf.svelte'; + import LargeHeroBreakoutShelf, { + isLargeHeroBreakoutShelf, + } from '~/components/jet/shelf/LargeHeroBreakoutShelf.svelte'; + import LargeBrickShelf, { + isLargeBrickShelf, + } from '~/components/jet/shelf/LargeBrickShelf.svelte'; + import LargeImageLockupShelf, { + isLargeImageLockupShelf, + } from '~/components/jet/shelf/LargeImageLockupShelf.svelte'; + import LargeLockupShelf, { + isLargeLockupShelf, + } from '~/components/jet/shelf/LargeLockupShelf.svelte'; + import LargeStoryCardShelf, { + isLargeStoryCardShelf, + } from '~/components/jet/shelf/LargeStoryCardShelf.svelte'; + import LinkableTextShelf, { + isLinkableTextShelf, + } from '~/components/jet/shelf/LinkableTextShelf.svelte'; + import { + isMarkerShelf, + type MarkerShelf as MarkerShelfModel, + } from '~/components/jet/shelf/MarkerShelf.svelte'; + import MediumImageLockupShelf, { + isMediumImageLockupShelf, + } from '~/components/jet/shelf/MediumImageLockupShelf.svelte'; + import MediumLockupShelf, { + isMediumLockupShelf, + } from '~/components/jet/shelf/MediumLockupShelf.svelte'; + import MediumStoryCardShelf, { + isMediumStoryCardShelf, + } from '~/components/jet/shelf/MediumStoryCardShelf.svelte'; + import ProductBadgeShelf, { + isProductBadgeShelf, + } from '~/components/jet/shelf/ProductBadgeShelf.svelte'; + import PageHeaderShelf, { + isPageHeaderShelf, + } from '~/components/jet/shelf/PageHeaderShelf.svelte'; + import ParagraphShelf, { + isParagraphShelf, + } from '~/components/jet/shelf/ParagraphShelf.svelte'; + import PosterLockupShelf, { + isPosterLockupShelf, + } from '~/components/jet/shelf/PosterLockupShelf.svelte'; + import ProductMediaShelf, { + isProductMediaShelf, + } from '~/components/jet/shelf/ProductMediaShelf.svelte'; + import ProductDescriptionShelf, { + isProductDescriptionShelf, + } from '~/components/jet/shelf/ProductDescriptionShelf.svelte'; + import ProductRatingsShelf, { + isProductRatingsShelf, + } from '~/components/jet/shelf/ProductRatingsShelf.svelte'; + import ProductReviewShelf, { + isProductReviewShelf, + } from '~/components/jet/shelf/ProductReviewShelf.svelte'; + import RibbonBarShelf, { + isRibbonBarShelf, + } from '~/components/jet/shelf/RibbonBarShelf.svelte'; + import SearchLinkShelf, { + isSearchLinkShelf, + } from '~/components/jet/shelf/SearchLinkShelf.svelte'; + import SearchResultShelf, { + isSearchResultShelf, + } from '~/components/jet/shelf/SearchResultShelf.svelte'; + import SmallBreakoutShelf, { + isSmallBreakoutShelf, + } from '~/components/jet/shelf/SmallBreakoutShelf.svelte'; + import SmallBrickShelf, { + isSmallBrickShelf, + } from '~/components/jet/shelf/SmallBrickShelf.svelte'; + import SmallLockupShelf, { + isSmallLockupShelf, + } from '~/components/jet/shelf/SmallLockupShelf.svelte'; + import SmallStoryCardShelf, { + isSmallStoryCardShelf, + } from '~/components/jet/shelf/SmallStoryCardShelf.svelte'; + import PrivacyHeaderShelf, { + isPrivacyHeaderShelf, + } from '~/components/jet/shelf/PrivacyHeaderShelf.svelte'; + import PrivacyFooterShelf, { + isPrivacyFooterShelf, + } from '~/components/jet/shelf/PrivacyFooterShelf.svelte'; + import PrivacyTypeShelf, { + isPrivacyTypeShelf, + } from '~/components/jet/shelf/PrivacyTypeShelf.svelte'; + import ProductCapabilityShelf, { + isProductCapabilityShelf, + } from '~/components/jet/shelf/ProductCapabilityShelf.svelte'; + import ProductPageLinkShelf, { + isProductPageLinkShelf, + } from './ProductPageLinkShelf.svelte'; + import QuoteShelf, { + isQuoteShelf, + } from '~/components/jet/shelf/QuoteShelf.svelte'; + import ReviewsContainerShelf, { + isReviewsContainerShelf, + } from '~/components/jet/shelf/ReviewsContainerShelf.svelte'; + import ReviewsShelf, { + isReviewsShelf, + } from '~/components/jet/shelf/ReviewsShelf.svelte'; + import TitledParagraphShelf, { + isTitledParagraphShelf, + } from '~/components/jet/shelf/TitledParagraphShelf.svelte'; + import TodayCardShelf, { + isTodayCardShelf, + } from '~/components/jet/shelf/TodayCardShelf.svelte'; + import UberShelf, { + isUberShelf, + } from '~/components/jet/shelf//UberShelf.svelte'; + import FallbackShelf, { + isFallbackShelf, + } from '~/components/jet/shelf/FallbackShelf.svelte'; + + interface $$Slots { + /** + * If the `shelf` is recognized to be a {@linkcode MarkerShelfModel}, this + * slot is rendered with the `shelf` as data rather than rendering the + * shelf directly. + * + * This is done because "marker" shelves need the whole "page" definition to + * be rendered, which is not available at this level of the UI. Rather than + * having to pass that data down to this level, we yield rendering back to + * the "parent" component that can provide that data directly. + */ + 'marker-shelf': { + shelf: MarkerShelfModel; + }; + } + + export let shelf: Shelf; +</script> + +<!-- +@component +Render a generic `Shelf` + +This component is responsible for rendering any kind of `Shelf` that +the App Store is capable of rendering. It primarily does this by trying +to narrow the generic `Shelf` down to a more-specific type and then +rendering a component specifically made for it +--> + +{#if isAccessibilityHeaderShelf(shelf)} + <AccessibilityHeaderShelf {shelf} /> +{:else if isAccessibilityFeaturesShelf(shelf)} + <AccessibilityFeaturesShelf {shelf} /> +{:else if isAccessibilityDeveloperLinkShelf(shelf)} + <AccessibilityDeveloperLinkShelf {shelf} /> +{:else if isActionShelf(shelf)} + <ActionShelf {shelf} /> +{:else if isAnnotationShelf(shelf)} + <AnnotationShelf {shelf} /> +{:else if isAppEventDetailShelf(shelf)} + <AppEventDetailShelf {shelf} /> +{:else if isAppPromotionShelf(shelf)} + <AppPromotionShelf {shelf} /> +{:else if isAppShowcaseShelf(shelf)} + <AppShowcaseShelf {shelf} /> +{:else if isAppTrailerLockupShelf(shelf)} + <AppTrailerLockupShelf {shelf} /> +{:else if isArcadeFooterShelf(shelf)} + <ArcadeFooterShelf {shelf} /> +{:else if isBannerShelf(shelf)} + <!-- a no-op until we determine if we actually want to support these banners --> + <!-- <BannerShelf {shelf} /> --> +{:else if isBrickShelf(shelf)} + <BrickShelf {shelf} /> +{:else if isCategoryBrickShelf(shelf)} + <CategoryBrickShelf {shelf} /> +{:else if isEditorialCardShelf(shelf)} + <EditorialCardShelf {shelf} /> +{:else if isEditorialLinkShelf(shelf)} + <EditorialLinkShelf {shelf} /> +{:else if isFramedArtworkShelf(shelf)} + <FramedArtworkShelf {shelf} /> +{:else if isFramedVideoShelf(shelf)} + <FramedVideoShelf {shelf} /> +{:else if isHeroCarouselShelf(shelf)} + <HeroCarouselShelf {shelf} /> +{:else if isHorizontalRuleShelf(shelf)} + <HorizontalRuleShelf {shelf} /> +{:else if isInAppPurchaseLockupShelf(shelf)} + <InAppPurchaseLockupShelf {shelf} /> +{:else if isLargeHeroBreakoutShelf(shelf)} + <LargeHeroBreakoutShelf {shelf} /> +{:else if isLargeBrickShelf(shelf)} + <LargeBrickShelf {shelf} /> +{:else if isLargeImageLockupShelf(shelf)} + <LargeImageLockupShelf {shelf} /> +{:else if isLargeLockupShelf(shelf)} + <LargeLockupShelf {shelf} /> +{:else if isLargeStoryCardShelf(shelf)} + <LargeStoryCardShelf {shelf} /> +{:else if isLinkableTextShelf(shelf)} + <LinkableTextShelf {shelf} /> +{:else if isProductDescriptionShelf(shelf)} + <ProductDescriptionShelf {shelf} /> +{:else if isMediumImageLockupShelf(shelf)} + <MediumImageLockupShelf {shelf} /> +{:else if isMediumLockupShelf(shelf)} + <MediumLockupShelf {shelf} /> +{:else if isMediumStoryCardShelf(shelf)} + <MediumStoryCardShelf {shelf} /> +{:else if isPosterLockupShelf(shelf)} + <PosterLockupShelf {shelf} /> +{:else if isProductBadgeShelf(shelf)} + <ProductBadgeShelf {shelf} /> +{:else if isPageHeaderShelf(shelf)} + <PageHeaderShelf {shelf} /> +{:else if isParagraphShelf(shelf)} + <ParagraphShelf {shelf} /> +{:else if isPrivacyHeaderShelf(shelf)} + <PrivacyHeaderShelf {shelf} /> +{:else if isPrivacyFooterShelf(shelf)} + <PrivacyFooterShelf {shelf} /> +{:else if isPrivacyTypeShelf(shelf)} + <PrivacyTypeShelf {shelf} /> +{:else if isProductMediaShelf(shelf)} + <ProductMediaShelf {shelf} /> +{:else if isProductRatingsShelf(shelf)} + <ProductRatingsShelf {shelf} /> +{:else if isProductReviewShelf(shelf)} + <ProductReviewShelf {shelf} /> +{:else if isRibbonBarShelf(shelf)} + <RibbonBarShelf {shelf} /> +{:else if isSearchLinkShelf(shelf)} + <SearchLinkShelf {shelf} /> +{:else if isSearchResultShelf(shelf)} + <SearchResultShelf {shelf} /> +{:else if isSmallBreakoutShelf(shelf)} + <SmallBreakoutShelf {shelf} /> +{:else if isSmallBrickShelf(shelf)} + <SmallBrickShelf {shelf} /> +{:else if isSmallStoryCardShelf(shelf)} + <SmallStoryCardShelf {shelf} /> +{:else if isSmallLockupShelf(shelf)} + <SmallLockupShelf {shelf} /> +{:else if isProductCapabilityShelf(shelf)} + <ProductCapabilityShelf {shelf} /> +{:else if isProductPageLinkShelf(shelf)} + <ProductPageLinkShelf {shelf} /> +{:else if isQuoteShelf(shelf)} + <QuoteShelf {shelf} /> +{:else if isReviewsContainerShelf(shelf)} + <ReviewsContainerShelf {shelf} /> +{:else if isReviewsShelf(shelf)} + <ReviewsShelf {shelf} /> +{:else if isTodayCardShelf(shelf)} + <TodayCardShelf {shelf} /> +{:else if isTitledParagraphShelf(shelf)} + <TitledParagraphShelf {shelf} /> +{:else if isUberShelf(shelf)} + <UberShelf {shelf} /> +{:else if isMarkerShelf(shelf)} + <slot name="marker-shelf" {shelf} /> +{:else if isFallbackShelf(shelf)} + <FallbackShelf {shelf} /> +{/if} diff --git a/src/components/jet/shelf/SmallBreakoutShelf.svelte b/src/components/jet/shelf/SmallBreakoutShelf.svelte new file mode 100644 index 0000000..095cf7f --- /dev/null +++ b/src/components/jet/shelf/SmallBreakoutShelf.svelte @@ -0,0 +1,32 @@ +<script lang="ts" context="module"> + import type { + LargeHeroBreakout, + Shelf, + SmallBreakout, + } from '@jet-app/app-store/api/models'; + + interface SmallBreakoutShelf extends Shelf { + items: SmallBreakout[]; + } + + export function isSmallBreakoutShelf( + shelf: Shelf, + ): shelf is SmallBreakoutShelf { + const { contentType, items } = shelf; + return contentType === 'smallBreakout' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import SmallBreakoutItem from '~/components/jet/item/SmallBreakoutItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SmallBreakoutShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="Spotlight" let:item> + <SmallBreakoutItem {item} /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SmallBrickShelf.svelte b/src/components/jet/shelf/SmallBrickShelf.svelte new file mode 100644 index 0000000..34426cf --- /dev/null +++ b/src/components/jet/shelf/SmallBrickShelf.svelte @@ -0,0 +1,26 @@ +<script lang="ts" context="module"> + import type { Brick, Shelf } from '@jet-app/app-store/api/models'; + + interface SmallBrickShelf extends Shelf { + items: Brick[]; + } + + export function isSmallBrickShelf(shelf: Shelf): shelf is SmallBrickShelf { + const { contentType, items } = shelf; + return contentType === 'smallBrick' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import BrickItem from '~/components/jet/item/BrickItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SmallBrickShelf; +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout {shelf} gridType="C" let:item> + <BrickItem {item} shouldOverlayDescription /> + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SmallLockupShelf.svelte b/src/components/jet/shelf/SmallLockupShelf.svelte new file mode 100644 index 0000000..e286671 --- /dev/null +++ b/src/components/jet/shelf/SmallLockupShelf.svelte @@ -0,0 +1,54 @@ +<script lang="ts" context="module"> + import type { Lockup, Shelf } from '@jet-app/app-store/api/models'; + + interface SmallLockupShelf extends Shelf { + items: Lockup[]; + } + + export function isSmallLockupShelf( + shelf: Shelf, + ): shelf is SmallLockupShelf { + const { contentType, items } = shelf; + return contentType === 'smallLockup' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + import SmallLockupWithOrdinalItem, { + isSmallLockupWithOrdinalItem, + } from '~/components/jet/item/SmallLockupWithOrdinalItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: SmallLockupShelf; + + $: ({ isArticleContext = false } = shelf.presentationHints ?? {}); + $: itemHasOrdinal = shelf.items.some((item) => item.ordinal); + $: gridType = (() => { + if (itemHasOrdinal) { + return 'SmallLockupWithOrdinal'; + } + + if (isArticleContext) { + return 'Spotlight'; + } + + return 'SmallLockup'; + })(); +</script> + +<ShelfWrapper {shelf}> + <ShelfItemLayout + {shelf} + {gridType} + rowsPerColumnOverride={gridType === 'SmallLockup' ? 3 : null} + let:item + > + {#if isSmallLockupWithOrdinalItem(item)} + <SmallLockupWithOrdinalItem {item} /> + {:else} + <SmallLockupItem {item} --margin-inline-end="16px" /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/SmallStoryCardShelf.svelte b/src/components/jet/shelf/SmallStoryCardShelf.svelte new file mode 100644 index 0000000..c1a85ad --- /dev/null +++ b/src/components/jet/shelf/SmallStoryCardShelf.svelte @@ -0,0 +1,66 @@ +<script lang="ts" context="module"> + import type { Shelf, TodayCard } from '@jet-app/app-store/api/models'; + + interface SmallStoryCardShelf extends Shelf { + contentType: 'smallStoryCard'; + items: TodayCard[]; + } + + export function isSmallStoryCardShelf( + shelf: Shelf, + ): shelf is SmallStoryCardShelf { + const { contentType, items } = shelf; + return contentType === 'smallStoryCard' && Array.isArray(items); + } +</script> + +<script lang="ts"> + import SmallStoryCardWithMediaItem, { + isSmallStoryCardWithMediaItem, + } from '~/components/jet/item/SmallStoryCardWithMediaItem.svelte'; + import SmallStoryCardWithArtworkItem, { + isSmallStoryCardWithArtworkItem, + } from '~/components/jet/item/SmallStoryCardWithArtworkItem.svelte'; + import SmallStoryCardWithMediaRiver, { + isSmallStoryCardWithMediaRiver, + } from '~/components/jet/item/SmallStoryCardWithMediaRiver.svelte'; + import SmallStoryCardWithMediaAppIcon, { + isSmallStoryCardWithMediaAppIcon, + } from '~/components/jet/item/SmallStoryCardWithMediaAppIcon.svelte'; + import SmallStoryCardMediaBrandedSingleApp, { + isSmallStoryCardMediaBrandedSingleApp, + } from '~/components/jet/item/SmallStoryCardMediaBrandedSingleApp.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfItemLayout from '~/components/ShelfItemLayout.svelte'; + + export let shelf: SmallStoryCardShelf; + + $: ({ isArticleContext = false } = shelf.presentationHints ?? {}); + $: gridType = (() => { + if (isArticleContext) { + return 'SmallStoryCard'; + } + + if (shelf.items.some(isSmallStoryCardWithArtworkItem)) { + return 'D'; + } + + return 'B'; + })(); +</script> + +<ShelfWrapper {shelf} withBottomPadding={!isArticleContext}> + <ShelfItemLayout {shelf} {gridType} let:item> + {#if isSmallStoryCardWithMediaRiver(item)} + <SmallStoryCardWithMediaRiver {item} /> + {:else if isSmallStoryCardWithMediaAppIcon(item)} + <SmallStoryCardWithMediaAppIcon {item} /> + {:else if isSmallStoryCardMediaBrandedSingleApp(item)} + <SmallStoryCardMediaBrandedSingleApp {item} /> + {:else if isSmallStoryCardWithMediaItem(item)} + <SmallStoryCardWithMediaItem {item} /> + {:else if isSmallStoryCardWithArtworkItem(item)} + <SmallStoryCardWithArtworkItem {item} /> + {/if} + </ShelfItemLayout> +</ShelfWrapper> diff --git a/src/components/jet/shelf/TitledParagraphShelf.svelte b/src/components/jet/shelf/TitledParagraphShelf.svelte new file mode 100644 index 0000000..41c1d74 --- /dev/null +++ b/src/components/jet/shelf/TitledParagraphShelf.svelte @@ -0,0 +1,118 @@ +<script lang="ts" context="module"> + import { + type Action, + type FlowAction, + type GenericPage, + type Shelf, + type TitledParagraph, + isFlowAction, + } from '@jet-app/app-store/api/models'; + + interface TitledParagraphShelf extends Shelf { + items: [TitledParagraph]; + } + + interface VersionHistoryPage extends FlowAction { + page: 'versionHistory'; + pageData: GenericPage; + } + + export function isTitledParagraphShelf( + shelf: Shelf, + ): shelf is TitledParagraphShelf { + const { contentType, items } = shelf; + + return contentType === 'titledParagraph' && Array.isArray(items); + } + + function isVersionHistoryFlowAction( + action: Action, + ): action is VersionHistoryPage { + return isFlowAction(action) && action.page === 'versionHistory'; + } +</script> + +<script lang="ts"> + import { createEventDispatcher, type SvelteComponent } from 'svelte'; + import Modal from '@amp/web-app-components/src/components/Modal/Modal.svelte'; + import ContentModal from '~/components/jet/item/ContentModal.svelte'; + import TitledParagraphItem, { + isTitledParagraphItem, + } from '~/components/jet/item/TitledParagraphItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getJetPerform } from '~/jet'; + import { VERSION_HISTORY_MODAL_ID } from '~/utils/metrics'; + + const perform = getJetPerform(); + export let shelf: TitledParagraphShelf; + + let modalComponent: SvelteComponent; + let modalTriggerElement: HTMLElement | null = null; + + const { seeAllAction } = shelf; + const i18n = getI18n(); + const translateFn = (key: string) => $i18n.t(key); + const handleModalClose = () => modalComponent.close(); + const handleOpenModalClick = (e: Event) => { + modalTriggerElement = e.target as HTMLElement; + modalComponent?.showModal(); + perform(destination); + }; + + const destination = + seeAllAction && isVersionHistoryFlowAction(seeAllAction) + ? seeAllAction + : undefined; + + const pageData = destination?.pageData; +</script> + +<ShelfWrapper {shelf}> + <div slot="title" class="title-container"> + {#if shelf.title} + <button on:click={handleOpenModalClick}> + <ShelfTitle title={shelf.title} seeAllAction={destination} /> + </button> + {/if} + + {#if pageData} + <Modal {modalTriggerElement} bind:this={modalComponent}> + <ContentModal + on:close={handleModalClose} + title={pageData.title || null} + subtitle={null} + targetId={VERSION_HISTORY_MODAL_ID} + > + <svelte:fragment slot="content"> + <ul> + {#each pageData.shelves as shelf} + {#each shelf.items || [] as item} + {#if isTitledParagraphItem(item)} + <li> + <TitledParagraphItem {item} /> + </li> + {/if} + {/each} + {/each} + </ul> + </svelte:fragment> + </ContentModal> + </Modal> + {/if} + </div> + + {#each shelf.items as item} + <TitledParagraphItem {item} /> + {/each} +</ShelfWrapper> + +<style> + .title-container { + display: flex; + justify-content: space-between; + padding-top: 16px; + padding-inline-end: var(--bodyGutter); + } +</style> diff --git a/src/components/jet/shelf/TodayCardShelf.svelte b/src/components/jet/shelf/TodayCardShelf.svelte new file mode 100644 index 0000000..e872112 --- /dev/null +++ b/src/components/jet/shelf/TodayCardShelf.svelte @@ -0,0 +1,187 @@ +<script lang="ts" context="module"> + import type { + Shelf, + TodayCard as TodayCardModel, + } from '@jet-app/app-store/api/models'; + + export interface TodayCardShelf extends Shelf { + contentType: 'todayCard'; + + items: TodayCardModel[]; + } + + export function isTodayCardShelf(shelf: Shelf): shelf is TodayCardShelf { + return shelf.contentType === 'todayCard' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import TodayCard from '~/components/jet/today-card/TodayCard.svelte'; + + import { getTodayCardLayoutConfiguration } from '~/context/today-card-layout'; + + export let shelf: TodayCardShelf; + + $: ({ + wrap: { shouldStretchFirstCard: shouldStretchFirstCardWrap }, + nowrap: { shouldStretchFirstCard: shouldStretchFirstCardNoWrap }, + } = getTodayCardLayoutConfiguration(shelf)); +</script> + +<ShelfWrapper {shelf}> + <div> + <div + class="today-card-row" + class:today-card-row__stretch-first-wrap={shouldStretchFirstCardWrap && + shelf.items.length >= 2} + class:today-card-row__stretch-first-nowrap={shouldStretchFirstCardNoWrap && + shelf.items.length >= 2} + class:today-card-row__stretch-last-wrap={!shouldStretchFirstCardWrap && + shelf.items.length >= 2} + class:today-card-row__stretch-last-nowrap={!shouldStretchFirstCardNoWrap && + shelf.items.length >= 2} + class:today-card-row__1-card={shelf.items.length == 1} + class:today-card-row__2-card={shelf.items.length == 2} + class:today-card-row__3-card={shelf.items.length == 3} + class:today-card-row__4-card={shelf.items.length >= 4} + > + {#each shelf.items.slice(0, 4) as card} + <div class="today-card-wrapper"> + <TodayCard {card} /> + </div> + {/each} + </div> + </div> +</ShelfWrapper> + +<style lang="scss"> + @use '@amp/web-shared-styles/sasskit-stylekit/ac-sasskit-config'; + @use '@amp/web-shared-styles/app/core/globalvars' as *; + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + @use 'amp/stylekit/core/mixins/browser-targets' as *; + + @mixin stretch-card($flex-shrink: 1) { + aspect-ratio: unset; + justify-self: stretch; + align-self: stretch; + width: auto; + flex-shrink: $flex-shrink; + flex-grow: 1; + } + + .today-card-row { + --card-default-width: 407px; + --card-default-height: 534px; + --card-row-gap: 16px; + min-width: min(var(--card-default-width), 100vw); + padding: 0 25px; + display: flex; + flex-direction: column; + gap: var(--card-row-gap); + + @media (--range-medium-up) { + padding: 0 40px; + flex-direction: row; + flex-wrap: wrap; + } + } + + .today-card-wrapper { + --artworkShadowInset: 0; + --afterShadowBorderRadius: 0px; + aspect-ratio: 3 / 4; + width: 100%; + flex-shrink: 0; + max-height: 600px; + min-height: 100px; + + > :global(a) { + display: block; + width: 100%; + height: 100%; + } + + @include target-safari { + @media screen and (760px <= width) { + height: 600px; + aspect-ratio: unset; + } + } + + @media (--range-medium-up) { + width: auto; + height: var(--card-default-height); + aspect-ratio: 3 / 4; + } + } + + @media (--range-medium-up) { + .today-card-row__1-card .today-card-wrapper { + @include stretch-card; + } + } + + @media (--range-medium-up) and (--range-large-down) { + .today-card-row__2-card { + &.today-card-row__stretch-first-wrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child { + @include stretch-card; + } + } + + .today-card-row__3-card { + .today-card-wrapper:first-child { + flex-basis: 100%; + + @include stretch-card(0); + } + + &.today-card-row__stretch-first-wrap + .today-card-wrapper:nth-child(2), + &.today-card-row__stretch-last-wrap .today-card-wrapper:last-child { + @include stretch-card; + } + } + } + + @media (--range-medium-up) { + .today-card-row__4-card { + &.today-card-row__stretch-first-wrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-first-wrap .today-card-wrapper:last-child, + &.today-card-row__stretch-last-wrap + .today-card-wrapper:nth-child(2), + &.today-card-row__stretch-last-wrap + .today-card-wrapper:nth-child(3) { + flex-basis: calc( + 100% - var(--card-default-width) - var(--card-row-gap) + ); + + @include stretch-card; + } + } + } + + @media (--range-xlarge-up) { + .today-card-row__2-card { + &.today-card-row__stretch-first-nowrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-last-nowrap + .today-card-wrapper:last-child { + @include stretch-card; + } + } + + .today-card-row__3-card { + &.today-card-row__stretch-first-nowrap + .today-card-wrapper:first-child, + &.today-card-row__stretch-last-nowrap + .today-card-wrapper:last-child { + @include stretch-card; + } + } + } +</style> diff --git a/src/components/jet/shelf/UberShelf.svelte b/src/components/jet/shelf/UberShelf.svelte new file mode 100644 index 0000000..6cdf004 --- /dev/null +++ b/src/components/jet/shelf/UberShelf.svelte @@ -0,0 +1,40 @@ +<script lang="ts" context="module"> + import type { Shelf, Uber } from '@jet-app/app-store/api/models'; + + interface UberShelf extends Shelf { + contentType: 'uber'; + items: [Uber]; + } + + export function isUberShelf(shelf: Shelf): shelf is UberShelf { + return shelf.contentType === 'uber' && Array.isArray(shelf.items); + } +</script> + +<script lang="ts"> + import Artwork from '~/components/Artwork.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + + export let shelf: UberShelf; + + $: uber = shelf.items[0]; + $: artwork = uber.artwork; +</script> + +{#if artwork} + <ShelfWrapper withPaddingTop={false} withBottomPadding={false}> + <div class="artwork-container"> + <Artwork {artwork} profile="uber-shelf" /> + </div> + </ShelfWrapper> +{/if} + +<style> + .artwork-container { + border-bottom: 1px solid var(--systemQuaternary-onDark); + + @media (--range-xlarge-only) { + border: 1px solid var(--systemQuaternary-onDark); + } + } +</style> diff --git a/src/components/jet/today-card/TodayCard.svelte b/src/components/jet/today-card/TodayCard.svelte new file mode 100644 index 0000000..84d760f --- /dev/null +++ b/src/components/jet/today-card/TodayCard.svelte @@ -0,0 +1,401 @@ +<script lang="ts"> + import type { TodayCard } from '@jet-app/app-store/api/models'; + + import Artwork, { + type Profile, + getNaturalProfile, + } from '~/components/Artwork.svelte'; + import LineClamp from '@amp/web-app-components/src/components/LineClamp/LineClamp.svelte'; + import { sanitizeHtml } from '@amp/web-app-components/src/utils/sanitize-html'; + import TodayCardMedia from '~/components/jet/today-card/TodayCardMedia.svelte'; + import TodayCardOverlay from '~/components/jet/today-card/TodayCardOverlay.svelte'; + import { isTodayCardMediaList } from '~/components/jet/today-card/media/TodayCardMediaList.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + import { colorAsString } from '~/utils/color'; + import { bestBackgroundColor } from './background-color-utils'; + + export let card: TodayCard; + + /** + * When set to `true`, this component will not enable the `clickAction` provided by the + * `card` + * + * This can be useful on the "story" page, where the card will link back to the page + * currently being viewed + */ + export let suppressClickAction: boolean = false; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; + + let useProtectionLayer: boolean; + let useBlurryProtectionLayer: boolean; + let useGradientProtectionLayer: boolean; + let useListStyle: boolean; + let accentColor: string; + + $: ({ + heading, + title, + inlineDescription, + titleArtwork, + overlay, + media, + editorialDisplayOptions, + style = 'light', + clickAction, + } = card); + $: action = suppressClickAction ? undefined : clickAction; + + $: { + const isAppEvent = media?.kind === 'appEvent'; + const isList = !!media && isTodayCardMediaList(media); + + useListStyle = isList; + useProtectionLayer = + editorialDisplayOptions?.useTextProtectionColor || + editorialDisplayOptions?.useMaterialBlur || + false; + useBlurryProtectionLayer = useProtectionLayer && !isAppEvent && !isList; + useGradientProtectionLayer = useProtectionLayer && isAppEvent; + accentColor = colorAsString(bestBackgroundColor(card.media)); + } +</script> + +<!-- + We don't wrap the entire card with an action if there is an `overlay`, since the overlay has + it's own link / action (and we don't want nesting `a` tags, of course). +--> +<LinkWrapper action={overlay || useListStyle ? null : action}> + <div + class="today-card" + class:light={style === 'light'} + class:dark={style === 'dark'} + class:white={style === 'white'} + class:list={useListStyle} + class:with-overlay={overlay} + style:--today-card-accent-color={accentColor} + > + {#if media && !useListStyle} + <TodayCardMedia {media} {artworkProfile} /> + {/if} + + <div class="wrapper"> + <div + class="information-layer" + class:with-gradient={useGradientProtectionLayer} + class:with-action={!!action} + > + <LinkWrapper action={useListStyle ? null : action}> + <div class="content-container"> + {#if useBlurryProtectionLayer} + <div class="protection-layer" /> + {/if} + + <div class="title-container"> + {#if heading && !titleArtwork} + <p class="badge"> + <LineClamp clamp={1}> + {heading} + </LineClamp> + </p> + {/if} + + {#if titleArtwork} + <div class="title-artwork-container"> + <Artwork + artwork={titleArtwork} + profile={getNaturalProfile( + titleArtwork, + )} + /> + </div> + {/if} + + {#if title && !titleArtwork} + <h3 class="title"> + <LinkWrapper + action={useListStyle ? action : null} + > + {@html sanitizeHtml(title)} + </LinkWrapper> + </h3> + {/if} + + {#if inlineDescription} + <LineClamp clamp={2}> + <p class="description"> + {@html sanitizeHtml(inlineDescription)} + </p> + </LineClamp> + {/if} + </div> + </div> + </LinkWrapper> + + {#if overlay} + <div + class="overlay" + class:blur-only={!useProtectionLayer} + class:dark={useProtectionLayer && style !== 'dark'} + class:light={useProtectionLayer && style === 'dark'} + > + <TodayCardOverlay + {overlay} + buttonVariant={useProtectionLayer + ? 'transparent' + : 'dark-gray'} + --text-color="var(--today-card-text-color)" + --text-accent-color="var(--today-card-text-accent-color)" + --text-accent-blend-mode="var(--today-card-text-accent-blend-mode)" + /> + </div> + {/if} + </div> + </div> + + {#if media && useListStyle} + <TodayCardMedia {media} {artworkProfile} /> + {/if} + </div> +</LinkWrapper> + +<style lang="scss"> + @property --gradient-color { + syntax: '<color>'; + inherits: true; + initial-value: #000; + } + + .today-card { + --today-card-gutter: 16px; + --today-card-border-radius: var( + --border-radius, + var(--global-border-radius-large) + ); + --protection-layer-bottom-offset: 0px; + --gradient-color: var(--today-card-accent-color); + background-color: var(--today-card-accent-color); + position: relative; + display: flex; + align-items: end; + height: 100%; + overflow: hidden; + color: var(--today-card-text-color); + container-type: size; + container-name: today-card; + border-radius: var(--today-card-border-radius); + box-shadow: var(--shadow-small); + } + + .today-card.with-overlay { + --protection-layer-bottom-offset: 80px; + } + + .today-card.light, + .today-card.dark { + --today-card-text-color: rgb(255, 255, 255); + --today-card-text-accent-color: rgba(255, 255, 255, 0.56); + --today-card-text-accent-blend-mode: plus-lighter; + --today-card-background-tint-color: rgba(0, 0, 0, 0.18); + } + + .today-card.white { + --today-card-text-color: var(--systemPrimary-onLight); + --today-card-text-accent-color: rgba(0, 0, 0, 0.56); + --today-card-background-tint-color: rgba(255, 255, 255, 0.33); + --today-card-text-accent-blend-mode: revert; + } + + .today-card :global(.artwork-component) { + z-index: unset; + } + + .wrapper { + position: absolute; + display: flex; + width: 100%; + height: 100%; + } + + .content-container { + position: relative; + } + + .information-layer { + position: relative; + display: flex; + flex-direction: column; + justify-content: end; + align-self: flex-end; + width: 100%; + height: 100%; + border-radius: var(--today-card-border-radius); + overflow: hidden; + } + + .information-layer > :global(a) { + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: end; + } + + .information-layer.with-gradient { + // A smooth bottom-to-top gradient with an intermediate stop at 60% of the accent color's + // opacity to ease the hard transition. + --gradient-color-end-position: 22%; + --gradient-fade-end-position: 50%; + background: linear-gradient( + 0deg, + var(--gradient-color) var(--gradient-color-end-position), + color-mix(in srgb, var(--gradient-color) 60%, transparent) + calc( + ( + var(--gradient-color-end-position) + + var(--gradient-fade-end-position) + ) / 2 + ), + transparent var(--gradient-fade-end-position) + ); + transition: --accent-color-end 500ms ease-out, --fade-end 350ms ease-out, + --gradient-color 350ms ease-out; + } + + .information-layer.with-gradient.with-action:has(> a:hover) { + // Darkens the color used in the gradient on hover + --gradient-color: color-mix( + in srgb, + var(--today-card-accent-color) 93%, + black + ); + } + + @container today-card (aspect-ratio >= 16/9) { + .information-layer.with-gradient { + --accent-color-end: 30%; + } + } + + .protection-layer { + --brightness: 0.95; + position: absolute; + width: 100%; + // On cards with overlays (app lockups at the bottom), we increase the height of the + // protection layer and shift it downward the same amount, so it is aligned to bottom + // of the overlay. + height: calc(100% + var(--protection-layer-bottom-offset) + 60px); + bottom: calc(-1 * var(--protection-layer-bottom-offset)); + background: var(--today-card-background-tint-color); + backdrop-filter: blur(34px) brightness(var(--brightness)) saturate(1.6) + contrast(1.1); + mask-image: linear-gradient( + to top, + black 30%, + rgba(0, 0, 0, 0.75) 70%, + rgba(0, 0, 0, 0.4) 86%, + transparent 100% + ); + transition: backdrop-filter 210ms ease-in; + } + + .information-layer:has(> a:hover) .protection-layer { + --brightness: 0.88; + } + + .badge { + font: var(--callout-emphasized); + margin-bottom: 4px; + mix-blend-mode: var(--today-card-text-accent-blend-mode); + color: var(--today-card-text-accent-color); + } + + .title-container { + width: auto; + position: relative; + padding: 0 var(--today-card-gutter) var(--today-card-gutter); + } + + @container today-card (orientation: landscape) { + .title-artwork-container { + width: 33%; + min-width: 200px; + max-width: 300px; + padding-bottom: 8px; + } + } + + @container today-card (orientation: portrait) { + .title-artwork-container { + max-width: 75%; + padding-bottom: 8px; + } + } + + .title { + font: var(--header-emphasized); + color: var(--today-card-text-color); + text-wrap: pretty; + } + + .description { + font: var(--body); + padding-top: calc(var(--today-card-gutter) / 2); + mix-blend-mode: var(--today-card-text-accent-blend-mode); + color: var(--today-card-text-accent-color); + text-wrap: pretty; + z-index: 1; + position: relative; + } + + .overlay { + z-index: 1; + position: relative; + padding: var(--today-card-gutter); + } + + .overlay.blur-only { + backdrop-filter: blur(50px); + } + + .overlay.light { + background-image: linear-gradient(rgba(225, 225, 225, 0.15) 0 0); + } + + .overlay.dark { + background-image: linear-gradient(rgba(0, 0, 0, 0.15) 0 0); + } + + .list { + background: var(--systemPrimary-onDark); + padding: var(--today-card-gutter) 0; + width: 100%; + flex-direction: column; + + @media (prefers-color-scheme: dark) { + --title-color: var(--systemPrimary); + background: var(--systemQuaternary); + + .title { + --today-card-text-color: var(--systemPrimary); + } + + .badge { + --today-card-text-accent-color: var(--systemSecondary); + } + } + } + + .list .wrapper { + position: relative; + height: auto; + width: 100%; + } + + .list .information-layer { + padding-top: 0; + } +</style> diff --git a/src/components/jet/today-card/TodayCardMedia.svelte b/src/components/jet/today-card/TodayCardMedia.svelte new file mode 100644 index 0000000..99f444f --- /dev/null +++ b/src/components/jet/today-card/TodayCardMedia.svelte @@ -0,0 +1,49 @@ +<script lang="ts"> + import type { TodayCardMedia } from '@jet-app/app-store/api/models'; + + import type { Profile } from '~/components/Artwork.svelte'; + import TodayCardMediaAppEvent, { + isTodayCardMediaAppEvent, + } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte'; + import TodayCardMediaAppIcon, { + isTodayCardMediAppIcon, + } from '~/components/jet/today-card/media/TodayCardMediaAppIcon.svelte'; + import TodayCardMediaBrandedSingleApp, { + isTodayCardMediaBrandedSingleApp, + } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte'; + import TodayCardMediaList, { + isTodayCardMediaList, + } from '~/components/jet/today-card/media/TodayCardMediaList.svelte'; + import TodayCardMediaRiver, { + isTodayCardMediaRiver, + } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte'; + import TodayCardMediaWithArtwork, { + isTodayCardMediaWithArtwork, + } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo, { + isTodayCardMediaVideo, + } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + + export let media: TodayCardMedia; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; +</script> + +{#if isTodayCardMediaAppEvent(media)} + <TodayCardMediaAppEvent {media} {artworkProfile} /> +{:else if isTodayCardMediAppIcon(media)} + <TodayCardMediaAppIcon {media} /> +{:else if isTodayCardMediaBrandedSingleApp(media)} + <TodayCardMediaBrandedSingleApp {media} {artworkProfile} /> +{:else if isTodayCardMediaList(media)} + <TodayCardMediaList {media} /> +{:else if isTodayCardMediaWithArtwork(media)} + <TodayCardMediaWithArtwork {media} {artworkProfile} /> +{:else if isTodayCardMediaRiver(media)} + <TodayCardMediaRiver {media} /> +{:else if isTodayCardMediaVideo(media)} + <TodayCardMediaVideo {media} {artworkProfile} /> +{/if} diff --git a/src/components/jet/today-card/TodayCardOverlay.svelte b/src/components/jet/today-card/TodayCardOverlay.svelte new file mode 100644 index 0000000..4e3c405 --- /dev/null +++ b/src/components/jet/today-card/TodayCardOverlay.svelte @@ -0,0 +1,48 @@ +<script lang="ts" context="module"> + import type { + TodayCardOverlay, + TodayCardLockupOverlay, + } from '@jet-app/app-store/api/models'; + + export function isLockupOverlay( + overlay: TodayCardOverlay, + ): overlay is TodayCardLockupOverlay { + return overlay.kind === 'lockup'; + } +</script> + +<script lang="ts"> + import TodayCardLockupListOverlay, { + isLockupListOverlay, + } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + + export let overlay: TodayCardOverlay; + export let buttonVariant: 'gray' | 'blue' | 'transparent' = 'transparent'; +</script> + +{#if isLockupOverlay(overlay)} + <div class="small-lockup-item-config"> + <SmallLockupItem + {buttonVariant} + item={overlay.lockup} + titleLineCount={1} + appIconProfile="app-icon" + /> + </div> +{:else if isLockupListOverlay(overlay)} + <TodayCardLockupListOverlay {overlay} /> +{/if} + +<style> + .small-lockup-item-config { + --title-color: var(--text-color, currentColor); + --subtitle-color: var(--text-accent-color, currentColor); + --linkColor: currentColor; + --eyebrow-color: var(--text-accent-color, currentColor); + --button-blend-mode: var(--text-accent-blend-mode); + --subtitle-blend-mode: var(--text-accent-blend-mode); + --eyebrow-blend-mode: var(--text-accent-blend-mode); + display: contents; + } +</style> diff --git a/src/components/jet/today-card/background-color-utils.ts b/src/components/jet/today-card/background-color-utils.ts new file mode 100644 index 0000000..c2c0fe6 --- /dev/null +++ b/src/components/jet/today-card/background-color-utils.ts @@ -0,0 +1,54 @@ +import { type Optional, isSome } from '@jet/environment/types/optional'; +import type { + Color, + TodayCardMedia, + TodayCardMediaWithArtwork, +} from '@jet-app/app-store/api/models'; + +import { isTodayCardMediaBrandedSingleApp } from './media/TodayCardMediaBrandedSingleApp.svelte'; +import { isTodayCardMediaAppEvent } from './media/TodayCardMediaAppEvent.svelte'; +import { isTodayCardMediaWithArtwork } from './media/TodayCardMediaWithArtwork.svelte'; + +const DEFAULT_COLOR: Color = { + type: 'named', + name: 'defaultBackground', +}; + +function getBackgroundFromMediaWithArtwork( + media: TodayCardMediaWithArtwork, +): Optional<Color> { + return ( + media.videos[0]?.preview.backgroundColor ?? + media.artworks[0]?.backgroundColor + ); +} + +/** + * Onyx App Store alternative to the `bestBackgroundColor` method that exists on + * the {@linkcode TodayCardMedia} type + * + * This is necessary because the functions on those class instances are not + * carried over to the client when serializing the view-model, making them + * impossible to call in a consistent way from our codebase + */ +export function bestBackgroundColor(media: Optional<TodayCardMedia>): Color { + if (isSome(media)) { + if (isTodayCardMediaAppEvent(media)) { + return media.tintColor; + } + + if (isTodayCardMediaBrandedSingleApp(media)) { + return ( + getBackgroundFromMediaWithArtwork(media) ?? + media.icon.backgroundColor ?? + DEFAULT_COLOR + ); + } + + if (isTodayCardMediaWithArtwork(media)) { + return getBackgroundFromMediaWithArtwork(media) ?? DEFAULT_COLOR; + } + } + + return DEFAULT_COLOR; +} diff --git a/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte new file mode 100644 index 0000000..1faa933 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaAppEvent.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaAppEvent, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaAppEvent( + media: TodayCardMedia, + ): media is TodayCardMediaAppEvent { + return media.kind === 'appEvent'; + } +</script> + +<script lang="ts"> + import type { Profile } from '~/components/Artwork.svelte'; + import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + import AppEventDate from '~/components/AppEventDate.svelte'; + + export let media: TodayCardMediaAppEvent; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; +</script> + +<div class="event-container"> + <span class="time-container"> + <AppEventDate formattedDates={media.formattedDates} /> + </span> + + <div class="artwork-container"> + {#if media.videos.length > 0} + <TodayCardMediaVideo {media} {artworkProfile} /> + {:else if media.artworks.length > 0} + <TodayCardMediaWithArtwork {media} {artworkProfile} /> + {/if} + </div> +</div> + +<style> + .event-container { + --today-card-border-width: 4px; + border: var(--today-card-border-width) solid + var(--today-card-accent-color); + border-radius: var(--today-card-border-radius); + position: relative; + aspect-ratio: 0.75; + width: 100%; + height: 100%; + overflow: hidden; + } + + @container (orientation: landscape) { + .event-container { + aspect-ratio: 16/9; + } + } + + .artwork-container { + height: 100%; + border-radius: calc( + var(--today-card-border-radius) - var(--today-card-border-width) + ); + } + + .time-container :global(time), + .time-container :global(span) { + background: var(--today-card-accent-color); + border-end-end-radius: var(--today-card-border-radius); + font: var(--headline); + padding: 6px 10px 6px 8px; + position: absolute; + top: 0; + z-index: 3; + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte new file mode 100644 index 0000000..a6db985 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaAppIcon.svelte @@ -0,0 +1,62 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaAppIcon, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediAppIcon( + media: TodayCardMedia, + ): media is TodayCardMediaAppIcon { + return media.kind === 'appIcon'; + } +</script> + +<script lang="ts"> + import AppIcon from '~/components/AppIcon.svelte'; + import { colorAsString } from '~/utils/color'; + + export let media: TodayCardMediaAppIcon; + + $: backgroundColor = media.icon.backgroundColor + ? colorAsString(media.icon.backgroundColor) + : null; +</script> + +<div class="container" style:--background-color={backgroundColor}> + <div class="artwork-container"> + <AppIcon + icon={media.icon} + profile="app-icon-xlarge" + fixedWidth={false} + /> + </div> +</div> + +<style> + .container { + height: 100%; + width: 100%; + display: flex; + justify-content: center; + align-items: center; + background-color: var(--background-color); + border-radius: var(--today-card-border-radius); + } + + .artwork-container { + width: 50%; + height: 50%; + } + + @container (orientation: landscape) { + .container { + align-items: start; + padding-top: 5%; + } + + .artwork-container { + width: 30%; + height: 30%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte new file mode 100644 index 0000000..dfdaa0f --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte @@ -0,0 +1,41 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaBrandedSingleApp, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaBrandedSingleApp( + media: TodayCardMedia, + ): media is TodayCardMediaBrandedSingleApp { + return media.kind === 'brandedSingleApp'; + } +</script> + +<script lang="ts"> + import TodayCardMediaWithArtwork from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; + import TodayCardMediaVideo from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; + import type { Profile } from '~/components/Artwork.svelte'; + + export let media: TodayCardMediaBrandedSingleApp; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: Profile | undefined = undefined; + + // There is a small but non-zero set of old legacy Today Cards that can appear on the Today page, + // and those cards have their safe area on the left side of the artwork, rather than the center, + // like all the modern artwork. For those cases, we pin the artwork to the left edge of the card. + $: pinnedToLeft = + media.artworkLayoutsWithMetrics[0].ltr.collapsedLayoutInsets.left < 0; +</script> + +{#if media.videos.length > 0} + <TodayCardMediaVideo {media} {artworkProfile} /> +{:else if media.artworks.length > 0} + <TodayCardMediaWithArtwork + {media} + {artworkProfile} + pinArtworkToLeft={pinnedToLeft} + /> +{/if} diff --git a/src/components/jet/today-card/media/TodayCardMediaList.svelte b/src/components/jet/today-card/media/TodayCardMediaList.svelte new file mode 100644 index 0000000..00f8688 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaList.svelte @@ -0,0 +1,86 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaList, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaList( + media: TodayCardMedia, + ): media is TodayCardMediaList { + return media.kind === 'list'; + } +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import SmallLockupItem from '~/components/jet/item/SmallLockupItem.svelte'; + + export let media: TodayCardMediaList; + + let container: HTMLDivElement; + let fadeTop = '0%'; + let fadeBottom = '0%'; + + function calculateFadeAmounts() { + const { scrollTop, scrollHeight, clientHeight } = container; + + fadeTop = scrollTop > 0 ? '10%' : `${scrollTop}%`; + fadeBottom = scrollTop + clientHeight < scrollHeight - 1 ? '15%' : '0%'; + } + + onMount(() => { + calculateFadeAmounts(); + container.addEventListener('scroll', calculateFadeAmounts); + + return () => + container.removeEventListener('scroll', calculateFadeAmounts); + }); +</script> + +<div + class="container" + style:--fade-top-size={fadeTop} + style:--fade-bottom-size={fadeBottom} + bind:this={container} +> + <ul> + {#each media.lockups as item} + <li> + <SmallLockupItem {item} /> + </li> + {/each} + </ul> +</div> + +<style> + @property --fade-top-size { + syntax: '<percentage>'; + inherits: false; + initial-value: 0%; + } + + @property --fade-bottom-size { + syntax: '<percentage>'; + inherits: false; + initial-value: 0%; + } + + .container { + width: 100%; + overflow: scroll; + padding: 0 var(--today-card-gutter); + mask-image: linear-gradient( + to bottom, + transparent 0%, + black var(--fade-top-size), + black calc(100% - var(--fade-bottom-size)), + transparent 100% + ); + transition: --fade-top-size 105ms cubic-bezier(0.5, 1, 0.89, 1), + --fade-bottom-size 420ms cubic-bezier(0.45, 0, 0.55, 1); + } + + li { + margin-bottom: 16px; + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaRiver.svelte b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte new file mode 100644 index 0000000..d3f9666 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaRiver.svelte @@ -0,0 +1,78 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaRiver, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaRiver( + media: TodayCardMedia, + ): media is TodayCardMediaRiver { + return media.kind === 'river'; + } +</script> + +<script lang="ts"> + import { + getBackgroundGradientCSSVarsFromArtworks, + getLuminanceForRGB, + } from '~/utils/color'; + import AppIconRiver from '~/components/AppIconRiver.svelte'; + + /** + * The actual properties of {@linkcode TodayCardMediaRiver} that are required + * to render this component + */ + type TodayCardMediaRiverRequirements = Pick<TodayCardMediaRiver, 'lockups'>; + + export let media: TodayCardMediaRiverRequirements; + + $: icons = media.lockups.map((lockup) => lockup.icon); + $: backgroundGradientCssVars = getBackgroundGradientCSSVarsFromArtworks( + icons, + { + // sorts from darkest to lightest + sortFn: (a, b) => getLuminanceForRGB(a) - getLuminanceForRGB(b), + }, + ); +</script> + +<div class="container" style={backgroundGradientCssVars}> + {#if icons.length} + <AppIconRiver {icons} /> + {/if} +</div> + +<style> + .container { + --app-icon-river-icon-width: 96px; + height: 100%; + width: 100%; + padding-top: 10%; + overflow: hidden; + border-radius: var(--today-card-border-radius); + background: radial-gradient( + circle at 3% -50%, + var(--top-left, #000) 20%, + transparent 70% + ), + radial-gradient( + circle at -50% 120%, + var(--bottom-left, #000) 40%, + transparent 80% + ), + radial-gradient( + circle at 140% -50%, + var(--top-right, #000) 60%, + transparent 80% + ), + radial-gradient( + circle at 62% 100%, + var(--bottom-right, #000) 50%, + transparent 100% + ); + + @media (--range-small-only) { + padding-top: 5%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaVideo.svelte b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte new file mode 100644 index 0000000..f2524c6 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaVideo.svelte @@ -0,0 +1,72 @@ +<script lang="ts" context="module"> + import type { + TodayCardMedia, + TodayCardMediaVideo, + Video as VideoModel, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaVideo( + media: TodayCardMedia, + ): media is TodayCardMediaVideo { + return ( + media.kind === 'video' || + (media.kind === 'artwork' && 'videos' in media) + ); + } +</script> + +<script lang="ts"> + import mediaQueries from '~/utils/media-queries'; + import type { Profile as ArtworkProfile } from '~/components/Artwork.svelte'; + import Video from '~/components/jet/Video.svelte'; + import { colorAsString } from '~/utils/color'; + + export let media: TodayCardMediaVideo; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: ArtworkProfile | undefined = undefined; + + let videoToDisplay: VideoModel | undefined; + $: videoToDisplay = media.videos[0]; + + let profile: ArtworkProfile; + $: profile = + artworkProfile ?? + ($mediaQueries === 'small' ? 'card' : 'card-horizontal'); + $: backgroundColor = videoToDisplay?.preview.backgroundColor + ? colorAsString(videoToDisplay?.preview.backgroundColor) + : null; +</script> + +{#if videoToDisplay} + <div class="video-wrapper" style:--background-color={backgroundColor}> + <Video + autoplay + loop + {profile} + useControls={false} + video={videoToDisplay} + /> + </div> +{/if} + +<style> + .video-wrapper { + background: black; + aspect-ratio: 3/4; + width: 100%; + position: relative; + overflow: hidden; + border-radius: var(--today-card-border-radius); + background-color: var(--background-color); + } + + @container (orientation: landscape) { + .video-wrapper { + aspect-ratio: 16/9; + height: 100%; + } + } +</style> diff --git a/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte new file mode 100644 index 0000000..e604708 --- /dev/null +++ b/src/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte @@ -0,0 +1,100 @@ +<script lang="ts" context="module"> + import type { + Artwork as ArtworkModel, + TodayCardMedia, + TodayCardMediaWithArtwork, + } from '@jet-app/app-store/api/models'; + + export function isTodayCardMediaWithArtwork( + media: TodayCardMedia, + ): media is TodayCardMediaWithArtwork { + return ( + media.kind === 'artwork' && + 'artworks' in media && + Array.isArray(media.artworks) && + media.artworks.length > 0 + ); + } +</script> + +<script lang="ts"> + import Artwork, { + type Profile as ArtworkProfile, + } from '~/components/Artwork.svelte'; + + export let media: TodayCardMediaWithArtwork; + + export let pinArtworkToLeft: boolean = false; + + /** + * A `Profile` to override the default for the card's media + */ + export let artworkProfile: ArtworkProfile | undefined = undefined; + + let artworkToDisplay: ArtworkModel; + // Today Card artwork comes back from Jet with a width of 800px, even though the source artwork + // is _much_ larger. The shared `Artwork` component doesn't let us render an image beyond the + // artwork's `width` and `height` properties, and we absolutely need to render these images + // larger than 800px wide, so we are forcing these new upper bounds for the artworks dimensions. + // Eventually, we should rethink this and have the proper dimensions come back from Jet: + // rdar://148730199 (Bigger images for TodayCard) + $: artworkToDisplay = Object.assign({}, media.artworks[0], { + width: 3840, + height: 2160, + }); +</script> + +{#if artworkProfile} + <Artwork profile={artworkProfile} artwork={artworkToDisplay} /> +{:else} + <div class="wrapper"> + <div class="artwork-container portrait"> + <Artwork profile="card" artwork={artworkToDisplay} /> + </div> + + <div + class="artwork-container landscape" + class:pinned-to-left={pinArtworkToLeft} + > + <Artwork profile="card-horizontal" artwork={artworkToDisplay} /> + </div> + </div> +{/if} + +<style> + .wrapper, + .artwork-container { + height: 100%; + width: 100%; + } + + .wrapper .artwork-container :global(.artwork-component), + .wrapper .artwork-container :global(img) { + object-fit: cover; + height: 100%; + } + + .pinned-to-left { + --artwork-override-object-position: left; + } + + @container (orientation: landscape) { + .artwork-container.landscape { + display: block; + } + + .artwork-container.portrait { + display: none; + } + } + + @container (orientation: portrait) { + .artwork-container.landscape { + display: none; + } + + .artwork-container.portrait { + display: block; + } + } +</style> diff --git a/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte new file mode 100644 index 0000000..1e7d297 --- /dev/null +++ b/src/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte @@ -0,0 +1,42 @@ +<script lang="ts" context="module"> + import type { + TodayCardOverlay, + TodayCardLockupListOverlay, + } from '@jet-app/app-store/api/models'; + + export function isLockupListOverlay( + overlay: TodayCardOverlay, + ): overlay is TodayCardLockupListOverlay { + return overlay.kind === 'lockupList'; + } +</script> + +<script lang="ts"> + import AppIcon from '~/components/AppIcon.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let overlay: TodayCardLockupListOverlay; +</script> + +<div class="lockup-list"> + {#each overlay.lockups as lockup} + <LinkWrapper action={lockup.clickAction}> + <AppIcon icon={lockup.icon} /> + </LinkWrapper> + {/each} +</div> + +<style> + .lockup-list { + display: flex; + gap: 12px; + + @media (--range-xsmall-only) and (--sidebar-visible) { + gap: 10px; + } + + @media (--range-small-up) { + gap: 16px; + } + } +</style> diff --git a/src/components/jet/web-navigation/CategoryTabItem.svelte b/src/components/jet/web-navigation/CategoryTabItem.svelte new file mode 100644 index 0000000..61f2570 --- /dev/null +++ b/src/components/jet/web-navigation/CategoryTabItem.svelte @@ -0,0 +1,67 @@ +<script lang="ts"> + import { createEventDispatcher } from 'svelte'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import Item from '@amp/web-app-components/src/components/Navigation/Item.svelte'; + import ItemContent from '@amp/web-app-components/src/components/Navigation/ItemContent.svelte'; + + const dispatch = createEventDispatcher(); + + export let item: any; + export let selected: boolean = false; + export let translateFn: (key: string) => string; + $$props; // lets the other props automatically passed to navigation item components enter without being delcared explicitly + + const itemClicked = (): void => { + dispatch('selectItem', item); + }; + + $: backgroundImage = item.artwork + ? buildSrc( + item.artwork.template, + { + crop: 'bb', + width: 40, + height: 40, + fileType: 'webp', + }, + {}, + ) + : undefined; +</script> + +<Item {item} {selected} {translateFn}> + <!-- svelte-ignore a11y-click-events-have-key-events --> + <!-- svelte-ignore a11y-no-static-element-interactions --> + <a + href={item.url} + class="navigation-item__link" + role="button" + aria-pressed={selected} + on:click|preventDefault={itemClicked} + > + <ItemContent label={item.label}> + <div + slot="icon" + aria-hidden={true} + class="icon" + style:--background-image={`url(${backgroundImage})`} + /> + </ItemContent> + </a> +</Item> + +<style> + .icon { + display: flex; + align-self: center; + width: 20px; + height: 20px; + background: var(--keyColor); + mask: var(--background-image) center / contain no-repeat; + + @media (--sidebar-visible) { + width: 18px; + height: 18px; + } + } +</style> diff --git a/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte new file mode 100644 index 0000000..f0fe666 --- /dev/null +++ b/src/components/jet/web-navigation/PlatformSelectorDropdown.svelte @@ -0,0 +1,88 @@ +<script lang="ts"> + import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation'; + + import SFSymbol from '~/components/SFSymbol.svelte'; + import PlatformSelectorItem from '~/components/jet/web-navigation/PlatformSelectorItem.svelte'; + import { getI18n } from '~/stores/i18n'; + import Menu from '~/components/Menu.svelte'; + import { getJet } from '~/jet'; + + export let platformSelectors: WebNavigationLink[]; + + const i18n = getI18n(); + const jet = getJet(); + + $: activeSelector = platformSelectors.find((selector) => selector.isActive); + + const handleShowMenu = () => { + jet.recordCustomMetricsEvent({ + eventType: 'click', + actionType: 'open', + targetType: 'button', + targetId: 'PlatformSelector', + }); + }; +</script> + +{#if activeSelector} + <nav> + <Menu options={platformSelectors} forcedXPosition={25} {handleShowMenu}> + <svelte:fragment slot="trigger"> + <span + class="platform-selector-text" + id="platform-selector-text" + aria-labelledby="app-store-icon-contianer platform-selector-text" + aria-haspopup="menu" + > + {$i18n.t( + 'ASE.Web.AppStore.Navigation.PlatformSelectorText', + { + platform: activeSelector.action.title, + }, + )} + + <SFSymbol name="chevron.down" /> + </span> + </svelte:fragment> + + <svelte:fragment slot="option" let:option> + <PlatformSelectorItem platformSelector={option} /> + </svelte:fragment> + </Menu> + </nav> +{/if} + +<style> + nav { + --menu-item-padding: 0; + --menu-item-margin: 0 0 8px 0; + --menu-popover-padding: 8px; + --menu-common-padding: 8px; + --menu-trigger-padding: 0; + --menu-popover-background-color: var(--pageBg); + --menu-popover-box-shadow: 10px 10px 10px 0 + var(--systemQuaternary-onLight); + --menu-popover-border-radius: 14px; + --menu-popover-border: 1px solid var(--systemQuaternary); + --menu-popover-z-index: calc(var(--z-web-chrome) + 1); + } + + .platform-selector-text { + display: flex; + align-items: center; + gap: var(--platform-selector-trigger-gap, 4px); + font: var(--title-2); + white-space: nowrap; + } + + .platform-selector-text :global(svg) { + height: 0.7em; + position: relative; + top: 2px; + fill: var(--systemPrimary); + } + + nav :global(.menu-popover) { + width: 211px; + } +</style> diff --git a/src/components/jet/web-navigation/PlatformSelectorItem.svelte b/src/components/jet/web-navigation/PlatformSelectorItem.svelte new file mode 100644 index 0000000..9b72fda --- /dev/null +++ b/src/components/jet/web-navigation/PlatformSelectorItem.svelte @@ -0,0 +1,97 @@ +<script lang="ts"> + import { isSome } from '@jet/environment/types/optional'; + import type { WebNavigationLink } from '@jet-app/app-store/api/models/web-navigation'; + import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; + + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let platformSelector: WebNavigationLink; + + const i18n = getI18n(); + + $: ({ action, isActive } = platformSelector); + $: ({ artwork } = action); +</script> + +<FlowAction destination={action}> + <span class="platform-selector" class:is-active={isActive}> + {#if isSome(artwork) && isSystemImageArtwork(artwork)} + <div class="icon-container"> + <SystemImage {artwork} /> + </div> + {/if} + + <span + class="platform-title" + aria-label={$i18n.t( + 'ASE.Web.AppStore.Navigation.AX.PlatformSelectorItem', + { + platform: action.title, + }, + )} + > + {action.title} + </span> + + {#if action.destination && isSearchResultsPageIntent(action.destination)} + <span aria-hidden={true} class="search-icon-container"> + <SFSymbol name="magnifyingglass" /> + </span> + {/if} + </span> +</FlowAction> + +<style lang="scss"> + @use '@amp/web-shared-styles/app/core/globalvars' as *; + + .platform-selector { + display: flex; + border-radius: var(--global-border-radius-medium); + padding: 8px; + margin-bottom: 4px; + gap: 10px; + transition: background-color 175ms ease-in; + } + + .platform-selector:not(.is-active):hover { + background-color: rgba(45, 45, 45, 0.035); + + @media (prefers-color-scheme: dark) { + background-color: rgba(45, 45, 45, 0.35); + } + } + + .platform-selector.is-active { + background-color: var(--systemQuinary); + } + + .icon-container { + display: flex; + justify-content: center; + padding-inline-end: 2px; + } + + .icon-container :global(svg) { + max-height: 16px; + width: 23px; + } + + .search-icon-container { + display: flex; + } + + .search-icon-container :global(svg) { + fill: var(--systemSecondary); + width: 16px; + } + + .platform-title { + font: var(--body); + flex-grow: 1; + } +</style> diff --git a/src/components/navigation/Navigation.svelte b/src/components/navigation/Navigation.svelte new file mode 100644 index 0000000..0114d4c --- /dev/null +++ b/src/components/navigation/Navigation.svelte @@ -0,0 +1,423 @@ +<script lang="ts"> + import { writable } from 'svelte/store'; + import { isSome } from '@jet/environment/types/optional'; + import type { + WebNavigation, + WebNavigationLink, + } from '@jet-app/app-store/api/models/web-navigation'; + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; + + import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte'; + import { sidebarIsHidden } from '@amp/web-app-components/src/stores/sidebar-hidden'; + + import AppStoreLogo from '~/components/icons/AppStoreLogo.svg'; + import PlatformSelectorDropdown from '~/components/jet/web-navigation/PlatformSelectorDropdown.svelte'; + import FlowAction from '~/components/jet/action/FlowAction.svelte'; + import SystemImage, { + isSystemImageArtwork, + } from '~/components/SystemImage.svelte'; + import SearchInput from '~/components/navigation/SearchInput.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + + import { getJetPerform } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + import { + type NavigationItemWithTab, + navigationIdFromLink, + makeNavLinks, + } from '~/components/navigation/navigation-items'; + import mediaQueries from '~/utils/media-queries'; + + import { fade, type EasingFunction } from 'svelte/transition'; + import { circOut } from 'svelte/easing'; + import { flyAndBlur } from '~/utils/transition'; + import { makeCategoryTabsIntent } from '@jet-app/app-store/api/intents/category-tabs-intent'; + import { getJet } from '~/jet'; + import { getPlatformFromPage } from '~/utils/seo/common'; + import type { NavigationId } from '@amp/web-app-components/src/types'; + + const i18n = getI18n(); + const perform = getJetPerform(); + const jet = getJet(); + + const categoryTabsCache: Record<string, WebNavigationLink[]> = {}; + let categoryTabLinks: WebNavigationLink[] = []; + let currentTabStore = writable<NavigationId | null>(null); + + export let webNavigation: WebNavigation; + + $: isXSmallViewport = $mediaQueries === 'xsmall'; + $: searchAction = webNavigation.searchAction as WebSearchFlowAction; + // Mobile first means the inline items are hidden + // However, we still want the list visible in SSR (which is fine for mobile + // since the menu won't be expanded by default) + $: inlinePlatformItems = + isXSmallViewport || typeof window === 'undefined' + ? webNavigation.platforms + : []; + + $: if (webNavigation && typeof window !== 'undefined') { + fetchCategoryTabs(webNavigation); + } + + async function fetchCategoryTabs(nav: WebNavigation) { + const platform = getPlatformFromPage({ + webNavigation: nav, + }); + + if (!platform) { + categoryTabLinks = []; + return; + } + + if (categoryTabsCache[platform]) { + categoryTabLinks = updateActiveStates(categoryTabsCache[platform]); + } else { + try { + const data = await jet.dispatch( + makeCategoryTabsIntent({ + platform, + }), + ); + + categoryTabsCache[platform] = data; + categoryTabLinks = updateActiveStates(data); + } catch (error) { + categoryTabLinks = []; + } + } + + updateCurrentTab(); + } + + function updateActiveStates( + tabs: WebNavigationLink[], + ): WebNavigationLink[] { + return tabs.map((link) => ({ + ...link, + isActive: link.action?.destination?.id + ? window.location.pathname.includes(link.action.destination.id) + : false, + })); + } + + function updateCurrentTab() { + const allLinks: WebNavigationLink[] = [ + ...categoryTabLinks, + ...webNavigation.tabs, + ]; + + const activeLink = allLinks.find((link) => link.isActive); + currentTabStore.set( + activeLink ? navigationIdFromLink(activeLink) : null, + ); + } + + function handleMenuItemClick(event: CustomEvent<NavigationItemWithTab>) { + const navigationItem = event.detail; + const tab = navigationItem.tab; + + perform(tab.action); + } + + const BASE_DELAY = 80; + const BASE_DURATION = 150; + const DURATION_SPREAD = 300; + + // Returns an eased duration for a list item based on its index, e.g. items later in the list + // get longer durations, between BASE_DURATION and BASE_DURATION + DURATION_SPREAD. + function getEasedDuration({ + i, + totalNumberOfItems, + easing = circOut, + }: { + i: number; + totalNumberOfItems: number; + easing?: EasingFunction; + }) { + const t = i / (totalNumberOfItems - 1); + return BASE_DURATION + easing(t) * DURATION_SPREAD; + } +</script> + +<div class="navigation-wrapper"> + <Navigation + translateFn={$i18n.t} + items={makeNavLinks(webNavigation.tabs, { + shouldShowSearchTab: $sidebarIsHidden, + })} + personalizedItemsHeader={$i18n.t( + 'ASE.Web.AppStore.Navigation.Categories.Title', + )} + personalizedItems={makeNavLinks(categoryTabLinks, { + shouldShowSearchTab: $sidebarIsHidden, + })} + currentTab={currentTabStore} + libraryItems={[]} + on:menuItemClick={handleMenuItemClick} + > + <div slot="logo" class="platform-selector-container"> + <span + id="app-store-icon-contianer" + class="app-store-icon-container" + role="img" + aria-label={$i18n.t( + 'ASE.Web.AppStore.Navigation.AX.AppStoreLogo', + )} + > + <AppStoreLogo focusable={false} /> + </span> + + {#if !$sidebarIsHidden && !isXSmallViewport} + <PlatformSelectorDropdown + platformSelectors={webNavigation.platforms} + /> + {/if} + </div> + + <svelte:fragment slot="search"> + <div class="search-input-container"> + <SearchInput {searchAction} /> + </div> + </svelte:fragment> + + <div slot="after-navigation-items" class="platform-selector-inline"> + {#if isXSmallViewport} + <h3 in:fade out:fade={{ delay: 250, duration: BASE_DURATION }}> + {$i18n.t('ASE.Web.AppStore.Navigation.PlatformHeading')} + </h3> + {/if} + + <ul> + {#each inlinePlatformItems as platformSelector, i (platformSelector.action.title)} + {@const { action, isActive } = platformSelector} + {@const artwork = action.artwork} + {@const totalNumberOfItems = inlinePlatformItems.length} + <li + in:flyAndBlur={{ + y: -50, + delay: i * BASE_DELAY, + duration: getEasedDuration({ + i, + totalNumberOfItems, + }), + }} + out:flyAndBlur={{ + y: i * -5, + delay: + // This delay is calculated in a negative/backwards manner, + // which makes it so the items build out from the bottom to the top. + (totalNumberOfItems - i - 1) * (BASE_DELAY / 2), + duration: BASE_DURATION, + }} + > + <FlowAction destination={action}> + <span class="platform" class:is-active={isActive}> + {#if isSome(artwork) && isSystemImageArtwork(artwork)} + <div + class="icon-container" + aria-hidden="true" + > + <SystemImage {artwork} /> + </div> + {/if} + + <span class="platform-title"> + {action.title} + </span> + + {#if action.destination && isSearchResultsPageIntent(action.destination)} + <span + aria-hidden={true} + class="search-icon-container" + > + <SFSymbol name="magnifyingglass" /> + </span> + {/if} + </span> + </FlowAction> + </li> + {/each} + </ul> + </div> + </Navigation> +</div> + +<style lang="scss"> + .navigation-wrapper { + display: contents; + } + + .platform-selector-container { + --header-gap: 3px; + --platform-selector-trigger-gap: var(--header-gap); + display: flex; + gap: var(--header-gap); + position: relative; + + @media (--sidebar-visible) { + padding: 19px 25px 14px; + } + } + + // Japanese and Catalonian both require scaling down the platform selector in order to make it + // fit cleanly in the sidebar, due to their longer character lengths. + .platform-selector-container:lang(ja), + .platform-selector-container:lang(ca) { + --scale-factor: 0.1; + z-index: 3; + transform: scale(calc(1 - var(--scale-factor))); + transform-origin: center left; + + & :global(dialog) { + top: 60px; + // Since the `dialog` is a child of `platform-selector-container, we re-scale it back + // to it's original size by applying the inverse scale transformation. + transform: scale(calc(1 + var(--scale-factor))); + transform-origin: center left; + } + } + + .app-store-icon-container { + display: flex; + align-items: center; + gap: var(--header-gap); + font: var(--title-1); + font-weight: 600; + } + + .app-store-icon-container :global(svg) { + height: 18px; + position: relative; + top: 0.33px; + width: auto; + + @media (--sidebar-visible) and (--range-xsmall-only) { + height: 22px; + width: auto; + } + } + + .search-input-container { + margin: 0 25px; + } + + .navigation-wrapper :global(.navigation__header) { + @media (--sidebar-visible) { + display: flex; + flex-direction: column; + } + } + + .navigation-wrapper :global(.navigation-item__link) { + height: 100%; + display: flex; + } + + .navigation-wrapper :global(.navigation-item__icon) { + --navigation-item-icon-size: 32px; + width: var(--navigation-item-icon-size); + height: var(--navigation-item-icon-size); + display: flex; + justify-content: center; + + @media (--sidebar-visible) { + --navigation-item-icon-size: 24px; + } + } + + // Our SVG icons for the landing pages are sized differently than other Onyx apps, + // so we have to reach into the navigation component and style them so they look + // visually similar to the other Onyx apps + .navigation-wrapper :global(.navigation-item__icon svg) { + color: var(--keyColor); + width: 20px; + + @media (--sidebar-visible) { + width: 18px; + } + } + + // Below is styling for the "inline" version of the Platform Selector + .platform-selector-inline { + margin: 8px 32px; + } + + ul { + display: flex; + flex-direction: column; + gap: 5px; + } + + h3 { + color: var(--systemTertiary); + font: var(--body-emphasized); + margin: 0 0 10px; + padding-top: 20px; + + @media (--sidebar-visible) { + font: var(--footnote-emphasized); + margin: 0 0 6px; + padding-top: 7px; + } + } + + .platform { + display: flex; + gap: 10px; + padding: 8px 0; + color: var(--systemTertiary); + + @media (prefers-color-scheme: dark) { + color: var(--systemSecondary); + } + } + + .platform, + .platform :global(svg) { + transition: color 210ms ease-out; + } + + .platform:not(.is-active):hover, + .platform:not(.is-active):hover :global(svg) { + color: var(--systemPrimary); + } + + .platform.is-active { + color: var(--systemPrimary); + font: var(--body-emphasized); + } + + .platform.is-active :global(svg) { + color: currentColor; + } + + .icon-container { + display: flex; + } + + .icon-container :global(svg) { + color: var(--systemTertiary); + width: 18px; + max-height: 16px; + + @media (prefers-color-scheme: dark) { + color: var(--systemSecondary); + } + } + + .search-icon-container { + display: flex; + } + + .search-icon-container :global(svg) { + fill: var(--systemSecondary); + width: 16px; + } + + .platform-title { + font: var(--body); + flex-grow: 1; + } +</style> diff --git a/src/components/navigation/SearchInput.svelte b/src/components/navigation/SearchInput.svelte new file mode 100644 index 0000000..a04fa4b --- /dev/null +++ b/src/components/navigation/SearchInput.svelte @@ -0,0 +1,82 @@ +<script lang="ts" context="module"> + import type { ComponentProps } from 'svelte'; + import { writable } from 'svelte/store'; + + import SearchInput from '@amp/web-app-components/src/components/SearchInput/SearchInput.svelte'; + + type UnusedSearchInputProps = Pick< + ComponentProps<SearchInput>, + 'currentTab' | 'menuItem' + >; + + // `SearchInput` requires a bunch of properties that are unnecessary + // for our use-case; they're defined here to keep them grouped together + const UNNEEDED_SEARCH_INPUT_PROPS: UnusedSearchInputProps = { + currentTab: writable(null), + menuItem: { + id: { type: 'search' }, + // @ts-expect-error the `menuItem` is not relevant to us; we don't + // need to provide an icon for this + icon: null, + }, + }; +</script> + +<script lang="ts"> + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + import { makeCanonicalSearchResultsPageUrl } from '@jet-app/app-store/common/search/search-page-url'; + + import { getJet } from '~/jet'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); + const jet = getJet(); + + export let searchAction: WebSearchFlowAction; + export let big: boolean = false; + + function dispatchSearchAction(event: CustomEvent<{ term: string }>) { + const { term } = event.detail; + + searchAction.destination.term = term; + + searchAction.pageUrl = makeCanonicalSearchResultsPageUrl( + jet.objectGraph, + searchAction.destination, + ); + + jet.perform(searchAction); + } +</script> + +<div class="search-input-wrapper" class:big> + <SearchInput + {...UNNEEDED_SEARCH_INPUT_PROPS} + defaultValue={searchAction?.destination?.term} + translateFn={(key) => $i18n.t(key)} + on:makeSearchQueryFromInput={dispatchSearchAction} + /> +</div> + +<style> + .search-input-wrapper { + --searchBoxIconFill: var(--keyColor); + position: relative; + display: flex; + flex-direction: column; + justify-content: center; + } + + .search-input-wrapper.big :global(.search-input__text-field) { + height: 48px; + padding-inline-start: 40px; + font: var(--title-2); + border-radius: 8px; + } + + .search-input-wrapper.big :global(.search-svg) { + width: 16px; + height: auto; + inset: 16px 0 0 13px; + } +</style> diff --git a/src/components/navigation/Skeleton.svelte b/src/components/navigation/Skeleton.svelte new file mode 100644 index 0000000..508e523 --- /dev/null +++ b/src/components/navigation/Skeleton.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import { writable } from 'svelte/store'; + + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + + import Navigation from '@amp/web-app-components/src/components/Navigation/Navigation.svelte'; + import AppStoreLogo from '~/components/icons/AppStoreLogo.svg'; + import SearchInput from '~/components/navigation/SearchInput.svelte'; + import { getI18n } from '~/stores/i18n'; + + const i18n = getI18n(); + + $: searchAction = {} as WebSearchFlowAction; +</script> + +<div class="navigation-wrapper"> + <Navigation + translateFn={$i18n.t} + items={[]} + currentTab={writable(null)} + libraryItems={[]} + personalizedItems={[]} + > + <div slot="logo" class="platform-selector-container"> + <span class="app-store-icon-container"> + <AppStoreLogo /> + </span> + </div> + + <svelte:fragment slot="search"> + <div class="search-input-container"> + <SearchInput {searchAction} /> + </div> + </svelte:fragment> + </Navigation> +</div> + +<style lang="scss"> + .navigation-wrapper { + display: contents; + } + + .platform-selector-container { + @media (--sidebar-visible) { + padding: 19px 25px 14px; + } + } + + .app-store-icon-container { + display: flex; + align-items: center; + padding: 2px 0; + } + + .app-store-icon-container :global(svg) { + height: 18px; + position: relative; + top: 0.33px; + width: auto; + + @media (--sidebar-visible) and (--range-xsmall-only) { + height: 22px; + width: auto; + } + } + + .search-input-container { + margin: 0 25px; + } + + .navigation-wrapper :global(.navigation-item__link) { + height: 100%; + display: flex; + } + + .navigation-wrapper :global(.navigation-item__icon) { + --navigation-item-icon-size: 32px; + width: var(--navigation-item-icon-size); + height: var(--navigation-item-icon-size); + + @media (--sidebar-visible) { + --navigation-item-icon-size: 24px; + } + } +</style> diff --git a/src/components/navigation/navigation-items.ts b/src/components/navigation/navigation-items.ts new file mode 100644 index 0000000..8692765 --- /dev/null +++ b/src/components/navigation/navigation-items.ts @@ -0,0 +1,79 @@ +import { + isSome, + unwrapOptional as unwrap, +} from '@jet/environment/types/optional'; + +import type { NavigationItem } from '@amp/web-app-components/src/components/Navigation/types'; +import type { NavigationId } from '@amp/web-app-components/src/types'; +import type { + WebNavigation, + WebNavigationLink, +} from '@jet-app/app-store/api/models/web-navigation'; + +import { + isSystemImageArtwork, + getIconNameFromTemplate, +} from '~/components/SystemImage.svelte'; +import { getIconComponentByName } from '../SFSymbol.svelte'; +import type { Artwork } from '@jet-app/app-store/api/models'; +import CategoryTabItem from '~/components/jet/web-navigation/CategoryTabItem.svelte'; + +/** + * A {@linkcode NavigationItem} that includes the original {@linkcode WebNavigationLink} + * it was defined from, which is needed for the + */ +export interface NavigationItemWithTab extends NavigationItem { + tab: WebNavigationLink; + artwork?: Artwork; + isActive?: boolean; +} + +export function navigationIdFromLink(link: WebNavigationLink): NavigationId { + const intent = unwrap(link.action.destination); + + return { + type: intent.$kind, + // `intent.$kind` will be unique for each `Intent` used here as they are + // each a different `LandingPageIntent` + resourceId: link.action.pageUrl ?? intent.$kind, + }; +} + +/** + * Transform the "tabs" in the `WebNavigation` into a shape that works with our + * shared navigation side-bar components. + */ +export function makeNavLinks( + tabs: WebNavigationLink[], + { shouldShowSearchTab = false }, +): NavigationItemWithTab[] { + return tabs + .filter((tab) => { + const isSearchTab = + tab.action?.destination?.['$kind'].includes('search_Intent'); + + // Allows for filtering our the search tab, which we use when the sidebar is visible, + // since there is a search input in the sidebar + return isSearchTab ? shouldShowSearchTab : true; + }) + .map((tab) => { + const { action, artwork: tabArtwork } = tab; + const { artwork } = action || {}; + const hasSystemImageArtwork = + isSome(artwork) && isSystemImageArtwork(artwork); + + return { + id: navigationIdFromLink(tab), + label: unwrap(tab.action.title), + url: action.pageUrl ?? undefined, + icon: hasSystemImageArtwork + ? getIconComponentByName( + getIconNameFromTemplate(artwork.template), + ) + : undefined, + artwork: tabArtwork, + component: !hasSystemImageArtwork ? CategoryTabItem : undefined, + tab, + }; + }); +} diff --git a/src/components/pages/AppEventDetailPage.svelte b/src/components/pages/AppEventDetailPage.svelte new file mode 100644 index 0000000..a2b798e --- /dev/null +++ b/src/components/pages/AppEventDetailPage.svelte @@ -0,0 +1,44 @@ +<script lang="ts" context="module"> + import type { DefaultPageRequirements } from './DefaultPage.svelte'; +</script> + +<script lang="ts"> + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + + export let page: DefaultPageRequirements; +</script> + +<div class="app-event-detail-page-container"> + <div class="shelf-container"> + {#each page.shelves as shelf} + <ShelfComponent {shelf} /> + {/each} + </div> +</div> + +<style> + .app-event-detail-page-container { + display: flex; + flex-direction: column; + flex-grow: 1; + padding: 0 var(--bodyGutter); + } + + .shelf-container { + display: flex; + flex: 1; + flex-direction: column; + width: 100%; + max-width: 900px; + margin: 0 auto; + + @media (--range-small-up) { + justify-content: center; + } + } + + .shelf-container :global(.shelf) { + margin: 0; + padding: var(--bodyGutter) 0 0; + } +</style> diff --git a/src/components/pages/ArticlePage.svelte b/src/components/pages/ArticlePage.svelte new file mode 100644 index 0000000..32cacb0 --- /dev/null +++ b/src/components/pages/ArticlePage.svelte @@ -0,0 +1,141 @@ +<script lang="ts" context="module"> + import type { ArticlePage } from '@jet-app/app-store/api/models'; + + import type { DefaultPageRequirements } from './DefaultPage.svelte'; + + /** + * Just the `Page` props that we actually need to render this component + */ + export type ArticlePageRequirements = DefaultPageRequirements & { + card: ArticlePage['card']; + footerLockup: ArticlePage['footerLockup']; + }; +</script> + +<script lang="ts"> + import TodayCard from '~/components/jet/today-card/TodayCard.svelte'; + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + import FooterLockupItem from '~/components/jet/item/FooterLockupItem.svelte'; + export let page: ArticlePageRequirements; + + $: ({ card } = page); +</script> + +<div class="article-page-container" data-testid="article-page-container"> + <div class="article-layout"> + {#if card} + <div class="card-container"> + <TodayCard {card} suppressClickAction /> + </div> + {/if} + + <div class="story-container"> + {#each page.shelves as shelf} + {#if !shelf.isHidden} + <ShelfComponent {shelf} /> + {/if} + {/each} + + {#if page.footerLockup} + <div class="footer-lockup-container"> + <FooterLockupItem item={page.footerLockup} /> + </div> + {/if} + </div> + </div> +</div> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .article-page-container { + flex-grow: 1; + width: 100%; + margin: 0 auto; + } + + .article-layout { + --article-page-padding: 20px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--article-page-padding); + max-width: 1600px; + margin: 0 auto; + + @media (--range-small-up) { + padding: 2em var(--bodyGutter); + } + + @media (--range-small-only) { + --article-page-padding: 40px; + } + + @media (--range-medium-up) { + align-items: flex-start; + flex-direction: row; + } + + @media (--range-medium-only) { + --article-page-padding: 20px; + } + + @media (--range-large-up) { + --article-page-padding: 40px; + } + } + + .card-container { + flex-shrink: 0; + aspect-ratio: 3/4; + width: 100%; + + @media (--range-xsmall-only) { + --border-radius: 0; + } + + @media (--range-small-only) { + aspect-ratio: 16/9; + } + + @media (--range-small-up) { + width: 100%; + } + + @media (--range-medium-up) { + position: sticky; + top: 2em; + aspect-ratio: 3 / 4; + height: min(calc(100vh - 80px), calc(33vw * 4 / 3)); + min-height: 420px; + max-height: 700px; + width: auto; + } + } + + .story-container { + width: 100%; + margin-top: 20px; + padding-bottom: var(--bodyGutter); + + @media (--range-small-up) { + width: calc(100%); + margin-top: 0; + } + + @media (--range-medium-up) { + min-width: calc(50% - calc(var(--article-page-padding))); + } + } + + .story-container :global(.shelf:first-of-type) { + padding-top: 0; + padding-bottom: 13px; + } + + .footer-lockup-container { + margin: var(--bodyGutter); + } +</style> diff --git a/src/components/pages/ChartsHubPage.svelte b/src/components/pages/ChartsHubPage.svelte new file mode 100644 index 0000000..a75cb64 --- /dev/null +++ b/src/components/pages/ChartsHubPage.svelte @@ -0,0 +1,11 @@ +<script lang="ts"> + import type { ChartsHubPage } from '@jet-app/app-store/api/models'; + + import TopChartsPage from './TopChartsPage.svelte'; + + export let page: ChartsHubPage; +</script> + +{#each page.charts as chart} + <TopChartsPage page={chart} /> +{/each} diff --git a/src/components/pages/DefaultPage.svelte b/src/components/pages/DefaultPage.svelte new file mode 100644 index 0000000..7905b07 --- /dev/null +++ b/src/components/pages/DefaultPage.svelte @@ -0,0 +1,173 @@ +<script lang="ts" context="module"> + import type { + PagePresentationOptions, + Shelf, + } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + /** + * Just the `Page` props that we actually need to render this component + */ + export interface DefaultPageRequirements extends WebRenderablePage { + shelves: Shelf[]; + presentationOptions?: PagePresentationOptions; + } +</script> + +<script lang="ts"> + import type { MarkerShelf } from '~/components/jet/shelf/MarkerShelf.svelte'; + import { isUberShelf } from '~/components/jet/shelf/UberShelf.svelte'; + import ShelfComponent from '~/components/jet/shelf/Shelf.svelte'; + import { partition } from '~/utils/array'; + import { carouselMediaStyle } from '~/stores/carousel-media-style'; + import mediaQueries from '~/utils/media-queries'; + import { isHeroCarouselShelf } from '../jet/shelf/HeroCarouselShelf.svelte'; + import { isRtl } from '~/utils/locale'; + + interface $$Slots { + 'before-shelves': {}; + + /** + * If {@linkcode ShelfComponent}` recognizes a shelf to be a {@linkcode MarkerShelf}, + * this slot will be rendered so that the "page" data can be supplied by a "parent" + * component + */ + 'marker-shelf': { + shelf: MarkerShelf; + }; + } + + export let page: DefaultPageRequirements; + + $: ({ title, presentationOptions = [] } = page); + + // Some shelves are meant to be rendered above the title, rather than below it + $: [aboveTitleShelves, belowTitleShelves] = partition( + page.shelves, + (shelf) => { + // Some "uber" shelves might be placed above the title + if (isUberShelf(shelf)) { + const [uber] = shelf.items; + return uber.style === 'above'; + } + + // Everything else should be below it + return false; + }, + ); + + $: prefersHiddenPageTitle = presentationOptions.includes( + 'prefersHiddenPageTitle', + ); + $: prefersLargeTitle = presentationOptions.includes('prefersLargeTitle'); + $: prefersOverlayedPageHeader = + $mediaQueries === 'xsmall' && + presentationOptions.includes('prefersOverlayedPageHeader'); + $: isOnDarkBackground = + prefersOverlayedPageHeader && $carouselMediaStyle === 'dark'; + + $: isTitleDuplicatedInHero = (() => { + const firstShelf = page.shelves?.[0]; + + if ( + !firstShelf || + !isHeroCarouselShelf(firstShelf) || + firstShelf.items?.length !== 1 + ) { + return false; + } + + const { items: ltrItems, rtlItems } = firstShelf.items?.[0] ?? {}; + const firstItem = isRtl() && rtlItems?.length ? rtlItems : ltrItems; + const firstTitle = firstItem?.[0]?.overlay?.titleText; + + return title === firstTitle; + })(); +</script> + +<div + class="default-page-container" + data-testid="default-page-container" + class:with-overlaid-title={prefersOverlayedPageHeader} + class:with-title-in-hero={isTitleDuplicatedInHero} +> + {#each aboveTitleShelves as shelf} + <ShelfComponent {shelf}> + <slot name="marker-shelf" slot="marker-shelf" let:shelf {shelf} /> + </ShelfComponent> + {/each} + + {#if title && !prefersHiddenPageTitle && !isTitleDuplicatedInHero} + <h1 + data-test-id="page-title" + class:large-title={prefersLargeTitle} + class:overlaid={prefersOverlayedPageHeader} + class:on-dark-background={isOnDarkBackground} + > + {title} + </h1> + {/if} + + <slot name="before-shelves" /> + + {#each belowTitleShelves as shelf} + {#if !shelf.isHidden} + <ShelfComponent {shelf}> + <slot + name="marker-shelf" + slot="marker-shelf" + let:shelf + {shelf} + /> + </ShelfComponent> + {/if} + {/each} +</div> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .default-page-container { + flex-grow: 1; + width: 100%; + max-width: viewport-content-for(xlarge); + margin: 0 auto; + } + + .default-page-container.with-overlaid-title { + margin-top: -13px; + } + + .default-page-container.with-title-in-hero { + @media (--range-small-up) { + margin-top: 10px; + } + } + + h1 { + padding: 11px var(--bodyGutter); + font: var(--large-title-emphasized); + letter-spacing: -0.5px; + word-wrap: break-word; + color: var(--systemPrimary, #000); + position: relative; + z-index: 1; + transition: color 210ms ease-in; + } + + h1.large-title { + font: var(--large-title-emphasized-tall); + } + + h1.overlaid { + position: absolute; + z-index: 3; + padding: var(--bodyGutter) var(--bodyGutter) 0; + color: var(--systemPrimary-onLight, #000); + } + + h1.on-dark-background { + color: var(--systemPrimary-onDark); + } +</style> diff --git a/src/components/pages/ErrorPage.svelte b/src/components/pages/ErrorPage.svelte new file mode 100644 index 0000000..5756d78 --- /dev/null +++ b/src/components/pages/ErrorPage.svelte @@ -0,0 +1,23 @@ +<script lang="ts" context="module"> + import type { ErrorPage } from '~/jet/models'; +</script> + +<script lang="ts"> + import SharedErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let page: ErrorPage; + + const i18n = getI18n(); +</script> + +<div class="error-page-container"> + <SharedErrorPage translateFn={$i18n.t} error={page.error} /> +</div> + +<style> + .error-page-container :global(.page-error) { + /* -50px compensates for the global footer */ + top: calc(50% - 50px); + } +</style> diff --git a/src/components/pages/ProductPage.svelte b/src/components/pages/ProductPage.svelte new file mode 100644 index 0000000..30b0ad8 --- /dev/null +++ b/src/components/pages/ProductPage.svelte @@ -0,0 +1,77 @@ +<script lang="ts"> + import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models'; + import { isFlowAction } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + import DefaultPage, { + type DefaultPageRequirements, + } from '~/components/pages/DefaultPage.svelte'; + import MarkerShelf from '~/components/jet/shelf/MarkerShelf.svelte'; + import ProductPageArcadeFooter from '~/components/ProductPageArcadeFooter.svelte'; + import { getProductPageShelvesWithExpandedMedia } from '~/utils/shelves'; + import { setAccessibilityLayoutContext } from '~/context/accessibility-layout'; + import { getJet } from '~/jet'; + import { isProductPageLinkShelf } from '~/components/jet/shelf/ProductPageLinkShelf.svelte'; + import { isEulaPageIntent } from '@jet-app/app-store/api/intents/eula-page-intent'; + export let page: ShelfBasedProductPage & WebRenderablePage; + + const jet = getJet(); + + $: ({ presentationOptions, webNavigation } = page); + + $: shelves = getProductPageShelvesWithExpandedMedia(page); + + let defaultPageRequirements: DefaultPageRequirements; + + $: defaultPageRequirements = { + shelves, + presentationOptions, + webNavigation, + }; + + // Set up accessibility layout context for neighbor shelf detection + $: { + setAccessibilityLayoutContext({ shelves }); + + /** + * We suppport "deep linking" to the product page with the License Agreement modal open by + * default, based on the presence of the `lic` query parameter. No other modals support + * opening via deep link, which is why there isn't a more robust solution for this use case. + * Instead, we are just firing off the click action from the license agreement shelf. + */ + if (page.canonicalURL) { + const canonicalUrl = new URL(page.canonicalURL); + const hasLic = canonicalUrl.searchParams.has('lic'); + + if (hasLic && shelves) { + const eulaItem = shelves + .find(isProductPageLinkShelf) + ?.items.find( + ({ clickAction }) => + isFlowAction(clickAction) && + clickAction.destination && + isEulaPageIntent(clickAction.destination), + ); + + if (eulaItem) { + jet.perform(eulaItem.clickAction); + } + } + } + } + + // TODO: replace with `supportsArcade` from Jet + // rdar://143706610 (Support `supportsArcade` attribute) + $: supportsArcade = + page.lockup.offerDisplayProperties?.offerType === 'arcadeApp'; +</script> + +<DefaultPage page={defaultPageRequirements}> + <svelte:fragment slot="marker-shelf" let:shelf> + <MarkerShelf {shelf} {page} /> + </svelte:fragment> +</DefaultPage> + +{#if supportsArcade} + <ProductPageArcadeFooter /> +{/if} diff --git a/src/components/pages/SearchLandingPage.svelte b/src/components/pages/SearchLandingPage.svelte new file mode 100644 index 0000000..3594ece --- /dev/null +++ b/src/components/pages/SearchLandingPage.svelte @@ -0,0 +1,33 @@ +<script lang="ts"> + import type { SearchLandingPage } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action'; + import { unwrapOptional as unwrap } from '@jet/environment/types/optional'; + + type SearchPage = SearchLandingPage; + + import DefaultPage from './DefaultPage.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import SearchInput from '~/components/navigation/SearchInput.svelte'; + import { getI18n } from '~/stores/i18n'; + + export let page: SearchPage; + + const i18n = getI18n(); + + $: webNavigation = unwrap((page as WebRenderablePage).webNavigation); + $: searchAction = webNavigation.searchAction as WebSearchFlowAction; + $: hasShelves = !!page.shelves.filter(({ items }) => items?.length).length; + + $: pageWithoutEmptyShelves = { + ...page, + shelves: hasShelves ? page.shelves : [], + title: $i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'), + }; +</script> + +<DefaultPage page={pageWithoutEmptyShelves}> + <ShelfWrapper slot="before-shelves" centered> + <SearchInput {searchAction} big /> + </ShelfWrapper> +</DefaultPage> diff --git a/src/components/pages/SearchResultsPage.svelte b/src/components/pages/SearchResultsPage.svelte new file mode 100644 index 0000000..c17b644 --- /dev/null +++ b/src/components/pages/SearchResultsPage.svelte @@ -0,0 +1,113 @@ +<script lang="ts"> + import type { SearchResultsPage } from '@jet-app/app-store/api/models'; + + import type { Size } from '@amp/web-app-components/src/types'; + import { ShelfConfig } from '@amp/web-app-components/config/components/shelf'; + + import DefaultPage from './DefaultPage.svelte'; + import { getI18n } from '~/stores/i18n'; + import mediaQueries from '~/utils/media-queries'; + import { + isSearchResultShelf, + isRenderableInSearchResultsShelf, + } from '~/components/jet/shelf/SearchResultShelf.svelte'; + import { getPlatformFromPage } from '~/utils/seo/common'; + + export let page: SearchResultsPage; + + const i18n = getI18n(); + + $: resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null; + + $: renderableItems = (resultsShelf?.items ?? []).filter( + isRenderableInSearchResultsShelf, + ); + + $: columnConfig = ShelfConfig.get().GRID_VALUES.SearchResult; + $: numberOfColumns = columnConfig[$mediaQueries as Size] || 3; + $: numberOfRows = Math.ceil(renderableItems.length / numberOfColumns); + $: middleRow = Math.floor(numberOfRows / 2); + $: insertAt = middleRow * numberOfColumns; + + /** + * This is unfortunate but only these three platforms support the transparency link. + * This link is enabled via the `transparencyLawEditorialItemId` bag key, but when defining + * bag keys, we do not have access to the platform being viewed, so we can't opt-out there. + * We could do this platform check in the Jet layer, but adding two forms of opting into this + * link felt cumbersome and unintuitive, so we can just do it here. + */ + $: transparencyLink = + page.transparencyLink && + ['iphone', 'ipad', 'mac'].includes( + getPlatformFromPage(page).toLowerCase(), + ); + + /** + * Here we are building constructing a new array of shelves _if_ there is a result shelf _and_ + * a transparency link. This creates three shelves: + * 1) the search results before the transparency banner in the linkable text shelf + * 2) the transparency banner + * 3) the search results after the transparency banner + */ + $: shelves = resultsShelf + ? transparencyLink && renderableItems.length + ? [ + insertAt > 0 && { + ...resultsShelf, + items: renderableItems.slice(0, insertAt), + title: null, + isValid: () => true, + }, + { + contentType: 'linkableText', + items: [page.transparencyLink], + }, + { + ...resultsShelf, + items: renderableItems.slice(insertAt), + title: null, + isValid: () => true, + }, + ] + : [{ ...resultsShelf, items: renderableItems, title: null }] + : []; +</script> + +<DefaultPage + page={{ + shelves, + title: renderableItems.length > 0 ? resultsShelf?.title : null, + }} +> + <svelte:fragment slot="before-shelves"> + {#if renderableItems.length === 0} + <div> + <h1> + {$i18n.t('ASE.Web.AppStore.Search.NoResults.FirstLine')} + </h1> + <p> + {$i18n.t('ASE.Web.AppStore.Search.NoResults.SecondLine', { + term: page.searchTermContext?.term, + })} + </p> + </div> + {/if} + </svelte:fragment> +</DefaultPage> + +<style> + div { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 3px; + height: 70vh; + margin: var(--bodyGutter); + } + + p { + font: var(--title-3); + color: var(--systemSecondary); + } +</style> diff --git a/src/components/pages/SeeAllPage.svelte b/src/components/pages/SeeAllPage.svelte new file mode 100644 index 0000000..d401f32 --- /dev/null +++ b/src/components/pages/SeeAllPage.svelte @@ -0,0 +1,56 @@ +<script lang="ts"> + import type { SeeAllPage } from '@jet-app/app-store/api/models'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + import DefaultPage from '~/components/pages/DefaultPage.svelte'; + import { getProductPageShelvesForOrdering } from '~/utils/shelves'; + import { setAccessibilityLayoutContext } from '~/context/accessibility-layout'; + import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte'; + import { isProductReviewShelf } from '~/components/jet/shelf/ProductReviewShelf.svelte'; + import { isProductRatingsShelf } from '~/components/jet/shelf/ProductRatingsShelf.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + + export let page: SeeAllPage & WebRenderablePage; + + $: shelves = getProductPageShelvesForOrdering(page, 'notPurchasedOrdering') + .filter((shelf) => { + const isShelfForReviewPage = + isProductReviewShelf(shelf) || isProductRatingsShelf(shelf); + + return ( + isSmallLockupShelf(shelf) || + (isShelfForReviewPage && page.seeAllType === 'reviews') + ); + }) + .map((shelf) => { + shelf.isHorizontal = false; + shelf.seeAllAction = null; + return shelf; + }); + + $: { + setAccessibilityLayoutContext({ shelves }); + } +</script> + +<DefaultPage page={{ shelves, title: null }}> + <svelte:fragment slot="before-shelves"> + <h1> + <LinkWrapper action={page.lockup.clickAction}> + {page.lockup.title} + </LinkWrapper> + </h1> + </svelte:fragment> +</DefaultPage> + +<style> + h1 { + font: var(--title-1); + color: var(--keyColor); + margin: 13px var(--bodyGutter) 0; + } + + h1 :global(a:hover) { + text-decoration: underline; + } +</style> diff --git a/src/components/pages/StaticMessagePage.svelte b/src/components/pages/StaticMessagePage.svelte new file mode 100644 index 0000000..45c1a36 --- /dev/null +++ b/src/components/pages/StaticMessagePage.svelte @@ -0,0 +1,113 @@ +<script lang="ts" context="module"> + import type { StaticMessagePage } from '~/jet/models'; +</script> + +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + + export let page: StaticMessagePage; + + const i18n = getI18n(); +</script> + +<div class="static-message-page-container"> + <div class="static-message-text-wrapper"> + {#if page.titleLocKey} + <h1>{$i18n.t(page.titleLocKey)}</h1> + {/if} + + <section> + {#if page.contentType === 'win-back' || page.contentType === 'contingent-price'} + <p> + {$i18n.t('ASE.Web.AppStore.WinBack.Subhead')} + </p> + + <p> + <b> + {$i18n.t('ASE.Web.AppStore.WinBack.DirectionalTitle')} + </b> + </p> + + <ul> + <li> + {$i18n.t('ASE.Web.AppStore.WinBack.Update.iOS')} + </li> + <li> + {$i18n.t('ASE.Web.AppStore.WinBack.Update.macOS')} + </li> + </ul> + + <p> + {$i18n.t('ASE.Web.AppStore.WinBack.Body')} + </p> + {:else if page.contentType === 'carrier'} + <p class="carrier__instructions"> + {$i18n.t('ASE.Web.AppStore.Carrier.Update.iOS')} + </p> + <p> + {$i18n.t('ASE.Web.AppStore.Carrier.Body')} + </p> + {:else if page.contentType === 'invoice'} + <p class="invoice__instructions"> + {$i18n.t('ASE.Web.AppStore.Invoice.Body')} + </p> + {/if} + </section> + </div> +</div> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .static-message-page-container { + display: flex; + flex-grow: 1; + width: 100%; + max-width: viewport-content-for(xlarge); + margin: 0 auto; + align-items: center; + } + + @media (--range-sidebar-visible-up) { + .static-message-page-container { + height: 100%; + } + } + + .static-message-text-wrapper { + display: inline-flex; + flex-direction: column; + align-items: flex-start; + width: auto; + margin: 0 auto; + } + + .static-message-page-container h1 { + padding: 13px var(--bodyGutter) 0; + font: var(--header-emphasized); + color: var(--systemPrimary, #000); + position: relative; + z-index: 1; + margin-bottom: 16px; + } + + .static-message-page-container section { + margin: 0 var(--bodyGutter); + font: var(--title-3); + } + + .static-message-page-container li { + list-style-type: disc; + } + + .static-message-page-container p, + .static-message-page-container ul { + margin-bottom: 16px; + text-wrap: pretty; + } + + .static-message-page-container ul { + padding-inline-start: 1em; + } +</style> diff --git a/src/components/pages/TodayPage.svelte b/src/components/pages/TodayPage.svelte new file mode 100644 index 0000000..3d38932 --- /dev/null +++ b/src/components/pages/TodayPage.svelte @@ -0,0 +1,22 @@ +<!-- +@component +Page component for the "Today Page" + +This is required so that the correct layout of the cards within each `TodayCardShelf` +can be computed at the page level, as the algorithm for stretching the correct cards +in each shelf requires knowledge of the previously-rendered shelf +--> +<script lang="ts"> + import type { TodayPage } from '@jet-app/app-store/api/models'; + + import DefaultPage from '~/components/pages/DefaultPage.svelte'; + import { setTodayCardLayoutContext } from '~/context/today-card-layout'; + + export let page: TodayPage; + + $: { + setTodayCardLayoutContext(page); + } +</script> + +<DefaultPage {page} /> diff --git a/src/components/pages/TopChartsPage.svelte b/src/components/pages/TopChartsPage.svelte new file mode 100644 index 0000000..4a3e7b7 --- /dev/null +++ b/src/components/pages/TopChartsPage.svelte @@ -0,0 +1,218 @@ +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + import { isSome } from '@jet/environment/types/optional'; + import type { TopChartsPage } from '@jet-app/app-store/api/models'; + + import DefaultPage from '~/components/pages/DefaultPage.svelte'; + import Shelf from '~/components/Shelf/Wrapper.svelte'; + import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte'; + import Menu from '~/components/Menu.svelte'; + import LinkWrapper from '~/components/LinkWrapper.svelte'; + import SFSymbol from '~/components/SFSymbol.svelte'; + + export let page: TopChartsPage; + + const i18n = getI18n(); + + $: ({ categories, categoriesButtonTitle, segments, initialSegmentIndex } = + page); + $: segment = segments[initialSegmentIndex]; +</script> + +<DefaultPage page={{ shelves: segment.shelves, title: page.title }}> + <Shelf slot="before-shelves" centered> + <header> + <div class="dropdown-container"> + {#if categoriesButtonTitle} + <Menu options={categories}> + <svelte:fragment slot="trigger"> + <span class="menu-trigger-contents"> + {categoriesButtonTitle} + + <SFSymbol name="chevron.down" /> + </span> + </svelte:fragment> + + <svelte:fragment slot="option" let:option> + {@const { artwork, chartSelectAction, name } = + option} + + <LinkWrapper action={chartSelectAction}> + <div + class="category-menu-item" + class:active={name === + categoriesButtonTitle} + > + {#if isSome(artwork)} + <div class="artwork-container"> + <Artwork + {artwork} + profile={getNaturalProfile( + artwork, + [24], + )} + /> + </div> + {/if} + + <span>{name}</span> + </div> + </LinkWrapper> + </svelte:fragment> + </Menu> + {/if} + </div> + + <div class="segment-selector" aria-label={categoriesButtonTitle}> + {#each segments as segment, index} + {@const { segmentSelectAction } = segment} + {@const isSelected = initialSegmentIndex === index} + {@const filterLabel = $i18n.t( + isSelected + ? 'ASE.Web.AppStore.SelectedFilterApps.AX.Label' + : 'ASE.Web.AppStore.FilterApps.AX.Label', + { filterName: segment.shortName }, + )} + + <LinkWrapper + action={segmentSelectAction} + label={filterLabel} + > + <span class="segment" class:selected={isSelected}> + {segment.shortName} + </span> + </LinkWrapper> + {/each} + </div> + </header> + </Shelf> +</DefaultPage> + +<style> + header { + --pill-button-border-radius: 1000px; /* Arbitrary large value for "pill-style" rounded sides */ + --menu-item-padding: 0; + --menu-item-margin: 0 0 8px 0; + --menu-popover-padding: 12px 16px; + --menu-common-padding: 0; + --menu-trigger-border-radius: var(--pill-button-border-radius); + --menu-trigger-background-color: var(--systemPrimary-onDark); + --menu-trigger-padding: 6px 16px; + --menu-trigger-font: var(--body-semibold-tall); + --menu-popover-background-color: white; + --menu-popover-box-shadow: 10px 10px 10px 0 + var(--systemQuaternary-onLight); + --menu-popover-border-radius: 14px; + --menu-popover-border: 1px solid var(--systemQuaternary); + --menu-popover-z-index: calc(var(--z-web-chrome) + 1); + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + + @media (--range-small-up) { + display: grid; + align-items: center; + justify-items: start; + grid-template-columns: 1fr max-content 1fr; + } + + @media (prefers-color-scheme: dark) { + --menu-trigger-background-color: var(--systemQuaternary); + --menu-popover-background-color: var(--systemQuaternary-vibrant); + } + } + + .segment-selector { + display: flex; + justify-self: end; + gap: 4px; + padding: 2px; + background: var(--systemQuaternary); + border-radius: var(--pill-button-border-radius); + + @media (--range-small-up) { + align-items: center; + justify-self: center; + grid-column: 2; + } + } + + .segment-selector :global(a) { + display: contents; + } + + .segment { + border-radius: var(--pill-button-border-radius); + font: var(--body-semibold-tall); + padding: 6px 16px; + } + + .segment.selected { + background-color: var(--systemPrimary-onDark); + color: var(--systemPrimary); + + @media (prefers-color-scheme: dark) { + background-color: var(--systemQuaternary); + } + } + + .dropdown-container { + justify-self: start; + + @media (--range-small-up) { + grid-column: 1; + } + } + + .menu-trigger-contents { + display: flex; + align-items: center; + gap: 4px; + } + + .menu-trigger-contents :global(svg) { + height: 0.7em; + } + + .menu-trigger-contents :global(path:not([fill='none'])) { + fill: currentColor; + } + + .category-menu-item { + display: flex; + align-items: center; + padding: 8px; + border-radius: 10px; + height: 40px; + transition: background 150ms ease-in; + } + + .category-menu-item.active { + background: var(--systemQuinary); + } + + .category-menu-item:not(.active):hover { + background: rgba(0, 0, 0, 0.035); + } + + .artwork-container { + width: 24px; + margin-inline-end: 8px; + flex-shrink: 0; + } + + .category-menu-item span { + text-overflow: ellipsis; + overflow: hidden; + } + + .dropdown-container :global(.menu-popover) { + max-width: 600px; + width: 100%; + column-count: 2; + + @media (--range-medium-up) { + column-count: 3; + } + } +</style> diff --git a/src/components/pages/VisionProPage.svelte b/src/components/pages/VisionProPage.svelte new file mode 100644 index 0000000..c87ee09 --- /dev/null +++ b/src/components/pages/VisionProPage.svelte @@ -0,0 +1,12 @@ +<script lang="ts"> + import type { GenericPage } from '@jet-app/app-store/api/models'; + + import DefaultPage from './DefaultPage.svelte'; + import VisionProFooter from '~/components/structure/VisionProFooter.svelte'; + + export let page: GenericPage; +</script> + +<DefaultPage {page} /> + +<VisionProFooter /> diff --git a/src/components/structure/Fonts.svelte b/src/components/structure/Fonts.svelte new file mode 100644 index 0000000..63af7b6 --- /dev/null +++ b/src/components/structure/Fonts.svelte @@ -0,0 +1,19 @@ +<script lang="ts"> + import { BASE, getFontURL } from '@amp/web-apps-fonts'; + + export let language: string; + + $: fontURL = getFontURL(language); +</script> + +<svelte:head> + <link rel="preconnect" href={BASE} crossorigin="anonymous" /> + + <link + rel="stylesheet" + as="style" + href={fontURL} + type="text/css" + referrerpolicy="strict-origin-when-cross-origin" + /> +</svelte:head> diff --git a/src/components/structure/Footer.svelte b/src/components/structure/Footer.svelte new file mode 100644 index 0000000..ceabfec --- /dev/null +++ b/src/components/structure/Footer.svelte @@ -0,0 +1,47 @@ +<script lang="ts"> + import { getI18n } from '~/stores/i18n'; + import Footer, { + type Translate, + } from '@amp/web-app-components/src/components/Footer/Footer.svelte'; + import LocaleSwitcherButton from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/LocaleSwitcherButton.svelte'; + import { items } from '~/constants/footer-items'; + import { getLocale } from '~/utils/locale'; + import { + regions, + languages, + storefrontNameTranslations, + } from '~/utils/storefront-data'; + + const i18n = getI18n(); + const locale = getLocale(); + + const translate: Translate = (key, options) => $i18n.t(key, options); +</script> + +<section class="footer-container"> + <Footer footerItems={items} translateFn={translate}> + <LocaleSwitcherButton + slot="secondary-content" + translateFn={translate} + {regions} + {languages} + {locale} + {storefrontNameTranslations} + defaultRoute="iphone/today" + /> + </Footer> +</section> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + .footer-container { + background-color: var(--footerBg); + } + + .footer-container :global(footer) { + max-width: calc(viewport-content-for(xlarge)); + margin: 0 auto; + } +</style> diff --git a/src/components/structure/MetaTags.svelte b/src/components/structure/MetaTags.svelte new file mode 100644 index 0000000..11b9477 --- /dev/null +++ b/src/components/structure/MetaTags.svelte @@ -0,0 +1,68 @@ +<script lang="ts"> + import type { Opt } from '@jet/environment/types/optional'; + import type { Organization, WithContext } from 'schema-dts'; + import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + + import MetaTags from '@amp/web-app-components/src/components/MetaTags/MetaTags.svelte'; + import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + import { getLocale } from '@amp/web-app-components/src/utils/internal/locale'; + import { getPageDir } from '@amp/web-apps-localization/src'; + + import { getI18n } from '~/stores/i18n'; + + export let page: WebRenderablePage; + + const i18n = getI18n(); + const locale = getLocale(); + + const organizationSchema: WithContext<Organization> = { + '@context': 'https://schema.org', + '@id': 'https://apps.apple.com/#organization', + '@type': 'Organization', + name: 'App Store', + url: 'https://apps.apple.com', + logo: 'https://apps.apple.com/assets/app-store.png', + sameAs: [ + 'https://www.wikidata.org/wiki/Q368215', + 'https://twitter.com/AppStore', + 'https://www.instagram.com/appstore/', + 'https://www.facebook.com/appstore/', + ], + parentOrganization: { + '@type': 'Organization', + name: 'Apple', + '@id': 'https://www.apple.com/#organization', + url: 'https://www.apple.com/', + }, + }; + + // This cast of `.seoData` is technically a little risky, but our app fully + // defines this property, which should make it fairly safe. Whatever is returned + // for the page from the `SEO` dependency on the Object Graph will be the value + // reflected here. + $: seoData = (page.seoData as Opt<SeoData>) ?? undefined; + + // Provide default title for pages not yet set up with SEO data + $: defaultTitle = $i18n.t('ASE.Web.AppStore.Meta.SiteName'); + $: pageDir = getPageDir(locale.language) ?? 'ltr'; +</script> + +<MetaTags + {defaultTitle} + {locale} + {pageDir} + {seoData} + origin={'https://apps.apple.com/'} +> + <svelte:fragment slot="schemaOrganizationData"> + {#if import.meta.env.SSR} + <svelte:element + this="script" + id="organization" + type="application/ld+json" + > + {JSON.stringify(organizationSchema)} + </svelte:element> + {/if} + </svelte:fragment> +</MetaTags> diff --git a/src/components/structure/VisionProFooter.svelte b/src/components/structure/VisionProFooter.svelte new file mode 100644 index 0000000..59dcd5b --- /dev/null +++ b/src/components/structure/VisionProFooter.svelte @@ -0,0 +1,142 @@ +<script lang="ts"> + import ShelfTitle from '~/components/Shelf/Title.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import Grid from '~/components/Grid.svelte'; + import { getI18n } from '~/stores/i18n'; + import { getLocale } from '~/utils/locale'; + + const locale = getLocale(); + const i18n = getI18n(); + + let links: Record<string, string>; + + function getAboutAppStoreUrl(storefront: string, language: string) { + let storefrontSlug = `${storefront}/`; + + if (storefront === 'us') { + storefrontSlug = ''; + } else if (storefront === 'gb') { + // The UK "About App Store" link is https://www.apple.com/uk/app-store/, not https://www.apple.com/gb/app-store/. + storefrontSlug = 'uk/'; + } else if (storefront === 'ae' && language === 'ar') { + storefrontSlug = 'ae-ar/'; + } + + return `https://www.apple.com/${storefrontSlug}app-store/`; + } + + $: storefront = locale.storefront; + $: links = { + 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutAppStore': + getAboutAppStoreUrl(storefront, locale.language), + 'ASE.Web.AppStore.VisionPro.Footer.Links.AboutPurchases': `https://apps.apple.com/${storefront}/story/id1436214772`, + 'ASE.Web.AppStore.VisionPro.Footer.Links.RequestRefund': `https://www.apple.com/${storefront}/shop/goto/help/sales_refunds`, + 'ASE.Web.AppStore.VisionPro.Footer.Links.PaymentMethods': `https://support.apple.com/118429`, + }; + + $: if (storefront === 'fr') { + links[ + 'AppStore.QuickLinks.AboutFrenchAppStore.Title' + ] = `https://apps.apple.com/${storefront}/story/1700848501`; + } +</script> + +<ShelfWrapper centered={false} withBottomPadding={false}> + <section data-test-id="vision-footer"> + <p class="blurb"> + {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Blurb')} + </p> + + <article class="quick-links-container"> + <ShelfTitle + title={$i18n.t('ASE.Web.AppStore.VisionPro.Footer.LinksTitle')} + /> + + <navigation> + <Grid + items={Object.entries(links)} + gridType="FooterLink" + let:item + > + {@const [title, href] = item} + <a {href}>{$i18n.t(title)}</a> + </Grid> + </navigation> + </article> + + <article class="disclaimer-container"> + <p> + {$i18n.t('ASE.Web.AppStore.VisionPro.Footer.Disclaimer')} + </p> + </article> + </section> +</ShelfWrapper> + +<style lang="scss"> + @use 'ac-sasskit/modules/viewportcontent/core' as *; + @use 'amp/stylekit/core/viewports' as *; + + section { + font: var(--body-tall); + } + + .blurb { + flex-grow: 1; + width: 100%; + max-width: calc(viewport-content-for(xlarge) * 0.66); + margin: 40px auto 50px; + padding: 0 var(--shelfGridPaddingInline, 40px); + text-align: center; + } + + .quick-links-container { + max-width: viewport-content-for(xlarge); + margin: 50px auto; + padding: 0 var(--bodyGutter); + } + + a { + display: block; + padding: var(--grid-column-gap-medium) 0 var(--grid-column-gap-medium); + word-break: break-all; + font: var(--title-2); + color: var(--keyColor); + border-bottom: 1px solid var(--systemQuinary); + + @media (--range-xsmall-down) { + padding: var(--grid-column-gap-xsmall) 0 + var(--grid-column-gap-xsmall); + } + } + + @media (--range-medium-up) { + .quick-links-container li:nth-child(n + 4) a { + border-bottom: none; + } + } + + @media (--small) { + .quick-links-container li:nth-child(n + 5) a { + border-bottom: none; + } + } + + @media (--range-xsmall-down) { + .quick-links-container li:last-child a { + border-bottom: none; + } + } + + .disclaimer-container { + flex-grow: 1; + width: 100%; + color: var(--systemTertiary); + background-color: var(--footerBg); + } + + .disclaimer-container p { + max-width: viewport-content-for(xlarge); + margin: 0 auto; + padding: 32px var(--bodyGutter, 40px); + } +</style> diff --git a/src/config/build.ts b/src/config/build.ts new file mode 100644 index 0000000..d64f14b --- /dev/null +++ b/src/config/build.ts @@ -0,0 +1 @@ +export const BUILD = process.env.VERSION as string; diff --git a/src/config/components/artwork.ts b/src/config/components/artwork.ts new file mode 100644 index 0000000..49c0f8e --- /dev/null +++ b/src/config/components/artwork.ts @@ -0,0 +1,163 @@ +import { ASPECT_RATIOS } from '@amp/web-app-components/src/components/Artwork/constants'; +import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork'; +import type { ArtworkProfileMap } from '@amp/web-app-components/config/components/artwork'; +import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types'; + +const { HD, ONE, THREE_QUARTERS, HD_ASPECT_RATIO } = ASPECT_RATIOS; + +const AppIconSize = { + DEFAULT: [48], + SMALL: [64], + MEDIUM: [100], + LARGE: [200], + XLARGE: [800], +}; + +const cardSizes = [525, 525, 490, 394, 738]; +const brickSizes = [520, 400, 450, 450, 300]; +const heroSizes = [1600, 1240, 920, 920, 490]; + +export type NamedProfile = + | 'app-event-detail' + | 'app-event-detail-small' + | 'app-icon' + | 'app-icon-large' + | 'app-icon-medium' + | 'app-icon-small' + | 'app-icon-xlarge' + | 'app-icon-pill' + | 'app-icon-large-pill' + | 'app-icon-medium-pill' + | 'app-icon-small-pill' + | 'app-icon-river-pill' + | 'app-icon-tv-rect' + | 'app-icon-large-tv-rect' + | 'app-icon-xlarge-tv-rect' + | 'app-icon-medium-tv-rect' + | 'app-icon-small-tv-rect' + | 'app-icon-river-tv-rect' + | 'app-icon-river' + | 'app-promotion' + | 'app-promotion-in-article' + | 'app-trailer-lockup-video' + | 'brick' + | 'brick-app-icon' + | 'card' + | 'card-horizontal' + | 'category-brick' + | 'editorial-story-card' + | 'in-app-purchase' + | 'large-brick' + | 'large-hero' + | 'large-hero-portrait' + | 'large-hero-portrait-iphone' + | 'large-hero-breakout' + | 'large-hero-breakout-rtl' + | 'large-hero-west' + | 'large-hero-east' + | 'large-hero-story-card' + | 'large-hero-story-card-portrait' + | 'large-hero-story-card-rtl' + | 'large-image-lockup' + | 'poster-lockup' + | 'poster-title' + | 'medium-story-card' + | 'screenshot-vision' + | 'screenshot-phone' + | 'screenshot-phone_portrait' + | 'screenshot-iphone_5_8' + | 'screenshot-iphone_5_8_portrait' + | 'screenshot-iphone_6_5' + | 'screenshot-iphone_6_5_portrait' + | 'screenshot-iphone_d74' + | 'screenshot-iphone_d74_portrait' + | 'screenshot-mac' + | 'screenshot-tv' + | 'screenshot-pad' + | 'screenshot-pad-portrait' + | 'screenshot-watch' + | 'small-brick' + | 'small-story-card-portrait' + | 'small-story-card' + | 'small-story-card-legacy' + | 'uber-shelf'; + +const PROFILES: ArtworkProfileMap<NamedProfile> = new Map([ + ['app-event-detail', [[480, 336, 336], 9 / 16, 'sr']], + ['app-event-detail-small', [[480, 336, 336], HD_ASPECT_RATIO, 'sr']], + ['app-icon', [AppIconSize.DEFAULT, ONE, 'bb']], + ['app-icon-large', [AppIconSize.LARGE, ONE, 'bb']], + ['app-icon-medium', [AppIconSize.MEDIUM, ONE, 'bb']], + ['app-icon-small', [AppIconSize.SMALL, ONE, 'bb']], + ['app-icon-xlarge', [AppIconSize.XLARGE, ONE, 'bb']], + ['app-icon-pill', [AppIconSize.DEFAULT, 4 / 3, 'sr']], + ['app-icon-large-pill', [AppIconSize.LARGE, 4 / 3, 'sr']], + ['app-icon-medium-pill', [AppIconSize.MEDIUM, 4 / 3, 'sr']], + ['app-icon-small-pill', [AppIconSize.SMALL, 4 / 3, 'sr']], + ['app-icon-tv-rect', [AppIconSize.DEFAULT, HD, 'sr']], + ['app-icon-large-tv-rect', [AppIconSize.LARGE, HD, 'sr']], + ['app-icon-xlarge-tv-rect', [AppIconSize.XLARGE, HD, 'sr']], + ['app-icon-medium-tv-rect', [AppIconSize.MEDIUM, HD, 'sr']], + ['app-icon-small-tv-rect', [AppIconSize.SMALL, HD, 'bb']], + ['app-icon-river-tv-rect', [[128, 128, 128, 88, 88], HD, 'bb']], + ['app-icon-river', [[128, 128, 128, 88, 88], ONE, 'bb']], + ['app-icon-river-pill', [[128, 128, 128, 88, 88], 4 / 3, 'sr']], + ['app-promotion', [[385, 330, 400, 450, 298], 16 / 9, 'sr']], + ['app-promotion-in-article', [[800, 600], 16 / 9, 'sr']], + ['app-trailer-lockup-video', [[385, 330, 400, 450, 298], 16 / 10, 'sr']], + ['brick', [brickSizes, HD, 'sr']], + ['brick-app-icon', [[83, 60, 60, 60, 50], ONE, 'bb']], + ['card', [cardSizes, THREE_QUARTERS, 'sr']], + ['card-horizontal', [[1020], HD, 'sr']], + ['category-brick', [[368, 368, 368, 208, 288], HD, 'sr']], + ['editorial-story-card', [[385, 400, 450], THREE_QUARTERS, 'sr']], + ['in-app-purchase', [[153, 160, 160, 140, 168], ONE, 'sr']], + ['large-brick', [[520, 610, 450, 298, 298], HD, 'sr']], + ['large-hero', [heroSizes, HD, 'sr']], + ['large-hero-portrait', [[430], 9 / 16, 'sr']], + ['large-hero-portrait-iphone', [[430], THREE_QUARTERS, 'SH.ApCSC01']], + ['large-hero-west', [heroSizes, 2.79, 'grav.west']], + ['large-hero-east', [heroSizes, 2.79, 'grav.east']], + ['large-hero-story-card', [heroSizes, 2.25, 'CC.ApSHW01']], + ['large-hero-story-card-rtl', [heroSizes, 2.25, 'sr']], + ['large-hero-story-card-portrait', [cardSizes, 3 / 4, 'CC.ApSHT01']], + ['large-hero-breakout', [heroSizes, 8 / 3, 'sr']], + ['large-image-lockup', [[790, 610, 919, 298, 298], HD, 'sr']], + ['poster-lockup', [[520, 520, 400, 450, 300], HD, 'sr']], + ['poster-title', [[400, 300, 200], HD, 'bb']], + [ + 'large-hero-breakout-rtl', + [[1600, 1240, 920, 920, 688], 8 / 3, 'gk' as CropCode], + ], // the `rtl` version of `large-hero-breakout` assets max out at 1344px wide + ['medium-story-card', [[298, 579, 490, 394], THREE_QUARTERS, 'sr']], + ['small-brick', [[300, 300, 300, 200, 300], HD, 'sr']], + ['small-story-card-portrait', [[182, 232, 215, 192], THREE_QUARTERS, 'sr']], + ['screenshot-vision', [[480, 480, 335, 520, 520], HD, 'sr']], + ['screenshot-phone', [[313, 643, 313, 480, 643], 20 / 9, 'w']], + ['screenshot-phone_portrait', [[230, 230, 157, 300, 300], 9 / 20, 'w']], + ['screenshot-iphone_5_8', [[313, 643, 313, 480, 643], 498 / 230, 'w']], + [ + 'screenshot-iphone_5_8_portrait', + [[230, 230, 157, 300, 300], 230 / 498, 'w'], + ], + ['screenshot-iphone_6_5', [[313, 643, 313, 480, 643], 498 / 230, 'w']], + [ + 'screenshot-iphone_6_5_portrait', + [[230, 230, 157, 300, 300], 230 / 498, 'w'], + ], + ['screenshot-iphone_d74', [[313, 643, 313, 480, 643], 466 / 215, 'w']], + [ + 'screenshot-iphone_d74_portrait', + [[230, 230, 157, 300, 300], 215 / 466, 'w'], + ], + ['screenshot-mac', [[313, 643, 313, 480, 643], 16 / 10, 'w']], + ['screenshot-tv', [[313, 643, 313, 480, 643], HD, 'w']], + ['screenshot-pad', [[313, 643, 313, 480, 643], 4 / 3, 'w']], + ['screenshot-pad-portrait', [[480, 528, 313, 480, 643], 3 / 4, 'w']], + ['screenshot-watch', [[300, 157, 230, 230, 230], 4 / 5, 'w']], + ['small-story-card', [brickSizes, HD, 'CC.ApSHSC01']], + ['small-story-card-legacy', [brickSizes, HD, 'SCS.ApDPCS01']], + ['uber-shelf', [[1680, 1680, 1320, 1000, 390], 8 / 3, 'sr']], +]); + +ArtworkConfig.set({ PROFILES }); diff --git a/src/config/components/shelf.ts b/src/config/components/shelf.ts new file mode 100644 index 0000000..515bf0f --- /dev/null +++ b/src/config/components/shelf.ts @@ -0,0 +1,208 @@ +import { ShelfConfig } from '@amp/web-app-components/config/components/shelf'; + +const defaultShelfConfig = ShelfConfig.get(); +const { GRID_MAX_CONTENT, GRID_VALUES, GRID_ROW_GAP } = defaultShelfConfig; + +ShelfConfig.set({ + ...defaultShelfConfig, + GRID_MAX_CONTENT: { + ...GRID_MAX_CONTENT, + Brick: GRID_MAX_CONTENT.A, + FooterLink: GRID_MAX_CONTENT.A, + InAppPurchaseLockup: GRID_MAX_CONTENT.C, + LargeBrick: GRID_MAX_CONTENT.A, + LargeLockup: GRID_MAX_CONTENT.C, + MediumLockup: GRID_MAX_CONTENT.A, + PosterLockup: GRID_MAX_CONTENT.A, + ScreenshotLarge: GRID_MAX_CONTENT.A, + ScreenshotVision: GRID_MAX_CONTENT.A, + ScreenshotPhone: GRID_MAX_CONTENT.G, + ScreenshotPad: GRID_MAX_CONTENT.A, + SearchLink: GRID_MAX_CONTENT.A, + SearchResult: GRID_MAX_CONTENT.A, + SmallLockup: GRID_MAX_CONTENT.A, + SmallLockupWithOrdinal: {}, + SmallStoryCard: GRID_MAX_CONTENT.A, + ProductBadge: GRID_MAX_CONTENT.D, + }, + GRID_VALUES: { + ...GRID_VALUES, + Brick: { + ...GRID_VALUES.A, + medium: 3, + }, + InAppPurchaseLockup: { + xsmall: 3, + small: 5, + medium: 6, + large: 8, + xlarge: 8, + }, + LargeBrick: { + ...GRID_VALUES.C, + small: 2, + medium: 2, + large: 3, + xlarge: 3, + }, + LargeLockup: { + xsmall: 2, + small: 3, + medium: 4, + large: 5, + xlarge: 6, + }, + MediumLockup: { + xsmall: 2, + small: 2, + medium: 4, + large: 5, + xlarge: 5, + }, + PosterLockup: { + ...GRID_VALUES.A, + xsmall: 1, + large: 2, + }, + ProductBadge: { + ...GRID_VALUES.D, + small: 5, + medium: 6, + }, + SearchLink: { + xsmall: 1, + small: 2, + medium: 3, + large: 3, + xlarge: 3, + }, + SearchResult: { + xsmall: 1, + small: 2, + medium: 3, + large: 3, + xlarge: 3, + }, + FooterLink: { + xsmall: 1, + small: 2, + medium: 3, + large: 3, + xlarge: 3, + }, + SmallLockup: { + xsmall: 2, + small: 2, + medium: 3, + large: 4, + xlarge: 4, + }, + SmallLockupWithOrdinal: { + xsmall: 2, + small: 4, + medium: 5, + large: 6, + xlarge: 6, + }, + SmallStoryCard: { + xsmall: 2, + small: 2, + medium: 2, + large: 2, + xlarge: 2, + }, + ScreenshotLarge: { + xsmall: 1, + small: 2, + medium: 2, + large: 3, + xlarge: 3, + }, + ScreenshotVision: { + xsmall: 1, + small: 1, + medium: 2, + large: 3, + xlarge: 3, + }, + ScreenshotPhone: { + xsmall: 2, + small: 3, + medium: 4, + large: 5, + xlarge: 5, + }, + ScreenshotPad: { + xsmall: 1, + small: 3, + medium: 4, + large: 4, + xlarge: 4, + }, + }, + GRID_ROW_GAP: { + ...GRID_ROW_GAP, + Brick: GRID_ROW_GAP.None, + FooterLink: GRID_ROW_GAP.None, + InAppPurchaseLockup: GRID_ROW_GAP.None, + LargeBrick: { + xsmall: 24, + small: 24, + medium: 24, + large: 24, + xlarge: 24, + }, + LargeLockup: { + xsmall: 20, + small: 20, + medium: 20, + large: 20, + xlarge: 20, + }, + MediumLockup: { + xsmall: 24, + small: 24, + medium: 24, + large: 24, + xlarge: 24, + }, + PosterLockup: GRID_ROW_GAP.None, + ScreenshotLarge: GRID_ROW_GAP.None, + ScreenshotVision: GRID_ROW_GAP.None, + ScreenshotPhone: GRID_ROW_GAP.None, + ScreenshotPad: GRID_ROW_GAP.None, + SearchLink: { + xsmall: 10, + small: 20, + medium: 20, + large: 20, + xlarge: 20, + }, + SearchResult: { + xsmall: 24, + small: 24, + medium: 24, + large: 24, + xlarge: 24, + }, + SmallLockup: { + xsmall: 24, + small: 24, + medium: 24, + large: 24, + xlarge: 24, + }, + SmallLockupWithOrdinal: { + xsmall: 24, + small: 24, + medium: 24, + large: 24, + xlarge: 24, + }, + SmallStoryCard: GRID_ROW_GAP.None, + ProductBadge: GRID_ROW_GAP.None, + }, + GRID_COL_GAP: { + ProductBadge: { small: '20', medium: '0', large: '0', xlarge: '0' }, + }, +}); diff --git a/src/config/errorkit.ts b/src/config/errorkit.ts new file mode 100644 index 0000000..4178680 --- /dev/null +++ b/src/config/errorkit.ts @@ -0,0 +1,17 @@ +import { BUILD } from './build'; +import type { ErrorKitConfig } from '@amp/web-apps-logger/src/errorkit'; + +const APPS_PROD_SUBDOMAIN = ['apps']; +const PROJECT_ID = 'onyx_apps'; + +const getSentryEnv = () => { + const location = + typeof window !== 'undefined' && window.location.host.split('.')[0]; + return APPS_PROD_SUBDOMAIN.includes(location) ? 'prod' : 'dev'; +}; + +export const ERROR_KIT_CONFIG: ErrorKitConfig = { + project: PROJECT_ID, + environment: getSentryEnv(), + release: BUILD, +}; diff --git a/src/config/hlsjs.ts b/src/config/hlsjs.ts new file mode 100644 index 0000000..0551d94 --- /dev/null +++ b/src/config/hlsjs.ts @@ -0,0 +1,25 @@ +declare global { + interface Window { + Hls?: any; + } +} + +/** + * Base URL for CDN hosting HLS.js files + */ +export const HLSJS_CDN = 'https://js-cdn.music.apple.com/hls.js'; + +/** + * HLS.js version to load. + */ +export const HLSJS_VERSION = '2.820.0'; + +/** + * Generate a URL for loading HLS.js. + */ +export function generateHLSJSURL(version?: string): URL { + // FIXME: Add a local storage override for the HLS.js version + version = version ?? HLSJS_VERSION; + + return new URL(`${HLSJS_CDN}/${version}/hls.js/hls.js`); +} diff --git a/src/config/media-api/browser.ts b/src/config/media-api/browser.ts new file mode 100644 index 0000000..91cf7c2 --- /dev/null +++ b/src/config/media-api/browser.ts @@ -0,0 +1 @@ +export const MEDIA_API_JWT = import.meta.env.BROWSER_MEDIA_API_JWT ?? ''; diff --git a/src/config/media-api/search-jwt.ts b/src/config/media-api/search-jwt.ts new file mode 100644 index 0000000..1f7e3d2 --- /dev/null +++ b/src/config/media-api/search-jwt.ts @@ -0,0 +1,27 @@ +export function shouldUseSearchJWT(url: URL): boolean { + // We should only ever use the "search" JWT on the server + if (!import.meta.env.SSR) { + return false; + } + + // Search API Endpoint + if (url.pathname.endsWith('/search')) { + return true; + } + + // All other endpoints should use the default JWT + return false; +} + +/** + * Creates the `Authorization` header using the App Store "search JWT" + * + * Note: this function specifically returns a bad value for a "browser" + * build so that the "search JWT" is removed from the browser payload + * by dead-code elimination + */ +export function makeSearchJWTAuthorizationHeader() { + return import.meta.env.SSR + ? { Authorization: `Bearer ${import.meta.env.SEARCH_MEDIA_API_JWT}` } + : { Authorization: '' }; +} diff --git a/src/config/metrics.ts b/src/config/metrics.ts new file mode 100644 index 0000000..b9ff0b4 --- /dev/null +++ b/src/config/metrics.ts @@ -0,0 +1,17 @@ +import { BUILD } from './build'; + +const APP_NAME = 'com.apple.apps'; +const APP_DELEGATE = 'web-appstore-app'; + +export const config = { + baseFields: { + appName: APP_NAME, + delegateApp: APP_DELEGATE, + appVersion: BUILD, + resourceRevNum: BUILD, + }, + clickstream: { + constraintProfiles: ['AMPWeb'], + topic: 'xp_amp_appstore_unidentified', + }, +}; diff --git a/src/config/rtcjs.ts b/src/config/rtcjs.ts new file mode 100644 index 0000000..f9e8a67 --- /dev/null +++ b/src/config/rtcjs.ts @@ -0,0 +1,103 @@ +import { platform } from '@amp/web-apps-utils'; +import { HLSJS_CDN, HLSJS_VERSION } from './hlsjs'; + +declare global { + interface Window { + rtc?: any; + } +} + +export type ReportingOptions = { + storeBagURL: string; + clientName: string; + serviceName: string; + applicationName: string; + applicationVersion: string; + browserName: string; + browserMajorVersion: string; + browserMinorVersion: string; + osName: string; + osVersion: string; +}; + +/** + * Generate a URL for loading HLS.js. + */ +export function generateRTCJSURL(version?: string): URL { + // FIXME: Add a local storage override for the HLS.js version + version = version ?? HLSJS_VERSION; + + return new URL(`${HLSJS_CDN}/${version}/rtc.js/rtc.js`); +} + +export function getRTCNamespace() { + if (window.rtc === undefined) { + throw new Error('Unable to load RTC library'); + } + + return window.rtc; +} + +export function getReportingOptions(): ReportingOptions { + // FIXME: Add correct information for RTC reporting for Web App Store + return { + storeBagURL: + 'https://mediaservices.cdn-apple.com/store_bags/hlsjs/aasw/v1/rtc_storebag.json', + + // Application + clientName: 'AASW', + serviceName: 'com.apple.apps.external', + applicationName: 'AppleAppStoreVWeb', + applicationVersion: 'WebAppStore/1.0.0', + + // Browser + browserName: platform.clientName() ?? '', + browserMajorVersion: platform.majorVersion()?.toString() ?? '0', + browserMinorVersion: platform.minorVersion()?.toString() ?? '0', + + // Operating System + osName: platform.osName() ?? '', + osVersion: platform.osName() ?? '', + } as const; +} + +/** + * Generate the configuration used for an `RTCReportingAgent`. + * + * @see {@link makeReportingAgent} + */ +export function generateReportingConfig(rtc: any) { + rtc = rtc ?? getRTCNamespace(); + const options = getReportingOptions(); + const key = rtc.RTCReportingAgentConfigKeys; + + return { + [key.Sender]: 'HLSJS', + [key.ClientName]: options.clientName, + [key.ServiceName]: options.serviceName, + [key.ApplicationName]: options.applicationName, + [key.DeviceName]: options.osVersion, + [key.ReportingStoreBag]: new rtc.RTCStorebag.RTCReportingStoreBag( + options.storeBagURL, + options.clientName, + options.serviceName, + options.applicationName, + options.browserName, + { iTunesAppVersion: options.applicationVersion }, + ), + + // Fake out these fields + model: options.browserName, + firmwareVersion: `${options.browserMajorVersion}.${options.browserMinorVersion}`, + }; +} + +/** + * Create an `RTCReportingAgent` with default configuration from `generateReportingConfig`. + * + * The reporting agent can be used with HLS.js playback to enable RTC reporting. + */ +export function makeReportingAgent(rtc: any): any { + rtc = rtc ?? getRTCNamespace(); + return new rtc.RTCReportingAgent(generateReportingConfig(rtc)); +} diff --git a/src/constants/footer-items.ts b/src/constants/footer-items.ts new file mode 100644 index 0000000..6538d71 --- /dev/null +++ b/src/constants/footer-items.ts @@ -0,0 +1,24 @@ +import type { FooterItem } from '@amp/web-app-components/src/components/Footer/types'; + +export const items: FooterItem[] = [ + { + id: 'terms-of-use', + url: 'AMP.Shared.Footer.TermsOfUse.URL', + locKey: 'AMP.Shared.Footer.TermsOfUse.Text', + }, + { + id: 'privacy-policy', + url: 'ASE.Web.AppStore.Shared.Footer.PrivacyPolicy.URL', + locKey: 'ASE.Web.AppStore.Shared.Footer.PrivacyPolicy.Text', + }, + { + id: 'cookie-policy', + url: 'AMP.Shared.Footer.CookiePolicy.URL', + locKey: 'AMP.Shared.Footer.CookiePolicy.Text', + }, + { + id: 'get-help', + url: 'ASE.Web.AppStore.Shared.Footer.GetHelp.URL', + locKey: 'ASE.Web.AppStore.Shared.Footer.GetHelp.Text', + }, +]; diff --git a/src/constants/media-metrics.ts b/src/constants/media-metrics.ts new file mode 100644 index 0000000..67d30b2 --- /dev/null +++ b/src/constants/media-metrics.ts @@ -0,0 +1,18 @@ +type ValueOf<T> = T[keyof T]; + +export const MetricsActionType = { + PLAY: 'play', + STOP: 'stop', +} as const; + +export type MetricsActionTypeItem = ValueOf<typeof MetricsActionType>; + +export const MetricsActionDetails = { + AUTOPLAY: 'autoplay', + AUTOPAUSE: 'autopause', + PLAY: 'play', + COMPLETE: 'complete', + PAUSE: 'pause', +} as const; + +export type MetricsActionDetailItem = ValueOf<typeof MetricsActionDetails>; diff --git a/src/constants/storefront.ts b/src/constants/storefront.ts new file mode 100644 index 0000000..e73f111 --- /dev/null +++ b/src/constants/storefront.ts @@ -0,0 +1,60 @@ +import type { + NormalizedLanguage, + NormalizedStorefront, +} from '@jet-app/app-store/api/locale'; + +export const DEFAULT_STOREFRONT_CODE = 'us' as NormalizedStorefront; +export const DEFAULT_LANGUAGE_BCP47 = 'en-US' as NormalizedLanguage; + +export const EU_STOREFRONTS = [ + 'at', + 'be', + 'bg', + 'cy', + 'cz', + 'dk', + 'ee', + 'fi', + 'fr', + 'de', + 'gr', + 'hr', + 'hu', + 'ie', + 'it', + 'lv', + 'lt', + 'lu', + 'mt', + 'nl', + 'pl', + 'pt', + 'ro', + 'sk', + 'si', + 'es', + 'se', + 'uk', +]; + +export const SUPPORTED_STOREFRONTS_FOR_VISION = new Set<NormalizedStorefront>([ + 'us', + 'cn', + 'hk', + 'jp', + 'sg', + 'au', + 'ca', + 'fr', + 'de', + 'gb', + 'kr', + 'ae', + 'tw', +] as NormalizedStorefront[]); + +export const UNSUPPORTED_STOREFRONTS_FOR_ARCADE = new Set([ + 'cn', + 'hk', + 'mo', +] as NormalizedStorefront[]); diff --git a/src/context/accessibility-layout.ts b/src/context/accessibility-layout.ts new file mode 100644 index 0000000..110100f --- /dev/null +++ b/src/context/accessibility-layout.ts @@ -0,0 +1,93 @@ +import { getContext, setContext } from 'svelte'; + +import type { Shelf } from '@jet-app/app-store/api/models'; +import { isAccessibilityHeaderShelf } from '~/components/jet/shelf/AccessibilityHeaderShelf.svelte'; +import { isAccessibilityFeaturesShelf } from '~/components/jet/shelf/AccessibilityFeaturesShelf.svelte'; +import { isAccessibilityDeveloperLinkShelf } from '~/components/jet/shelf/AccessibilityDeveloperLinkShelf.svelte'; + +/** + * Describes the layout configuration for accessibility shelves + */ +interface AccessibilityLayoutConfiguration { + withBottomPadding: boolean; +} + +const ACCESSIBILITY_LAYOUT_FALLBACK: AccessibilityLayoutConfiguration = + Object.freeze({ + withBottomPadding: false, + }); + +type AccessibilityLayoutStore = WeakMap< + Shelf, + AccessibilityLayoutConfiguration +>; +type AccessibilityLayoutStoreContext = AccessibilityLayoutStore | undefined; + +const ACCESSIBILITY_LAYOUT_CONTEXT_ID = 'accessibility-layout-context'; + +/** + * Check if a shelf is accessibility-related + */ +function isAccessibilityRelated(shelf: Shelf): boolean { + return ( + shelf.contentType === 'accessibilityParagraph' || + shelf.contentType === 'accessibilityFeatures' + ); +} + +/** + * Check if a shelf is one of the target accessibility shelves + */ +function isTargetAccessibilityShelf(shelf: Shelf): boolean { + return ( + isAccessibilityHeaderShelf(shelf) || + isAccessibilityFeaturesShelf(shelf) || + isAccessibilityDeveloperLinkShelf(shelf) + ); +} + +/** + * Store the {@linkcode AccessibilityLayoutConfiguration} for each accessibility shelf + * in "context", so it can be retrieved at the shelf-component level + * + * This determines bottom padding based on whether the next shelf is accessibility-related + */ +export function setAccessibilityLayoutContext(page: { shelves: Shelf[] }) { + const store: AccessibilityLayoutStore = new WeakMap(); + + for (let i = 0; i < page.shelves.length; i++) { + const shelf = page.shelves[i]; + + // Only process target accessibility shelves + if (!isTargetAccessibilityShelf(shelf)) { + continue; + } + + // Check if the next shelf is accessibility-related + const nextShelf = page.shelves[i + 1]; + const hasAccessibilityNext = + nextShelf && isAccessibilityRelated(nextShelf); + + store.set(shelf, { + withBottomPadding: !hasAccessibilityNext, + }); + } + + setContext<AccessibilityLayoutStoreContext>( + ACCESSIBILITY_LAYOUT_CONTEXT_ID, + store, + ); +} + +/** + * Retrieve the {@linkcode AccessibilityLayoutConfiguration} for a given accessibility shelf + */ +export function getAccessibilityLayoutConfiguration( + shelf: Shelf, +): AccessibilityLayoutConfiguration { + const accessibilityLayout = getContext<AccessibilityLayoutStoreContext>( + ACCESSIBILITY_LAYOUT_CONTEXT_ID, + ); + + return accessibilityLayout?.get(shelf) ?? ACCESSIBILITY_LAYOUT_FALLBACK; +} diff --git a/src/context/today-card-layout.ts b/src/context/today-card-layout.ts new file mode 100644 index 0000000..54f66ec --- /dev/null +++ b/src/context/today-card-layout.ts @@ -0,0 +1,98 @@ +import { getContext, setContext } from 'svelte'; + +import type { TodayPage } from '@jet-app/app-store/api/models'; +import { + type TodayCardShelf, + isTodayCardShelf, +} from '~/components/jet/shelf/TodayCardShelf.svelte'; + +/** + * Describes the configuration of the card layout within a {@linkcode TodayCardShelf} + */ +interface LayoutConfiguration { + wrap: { + shouldStretchFirstCard: boolean; + }; + nowrap: { + shouldStretchFirstCard: boolean; + }; +} + +const LAYOUT_CONFIGURATION_FALLBACK: LayoutConfiguration = Object.freeze({ + wrap: { + shouldStretchFirstCard: true, + }, + nowrap: { + shouldStretchFirstCard: true, + }, +}); + +type TodayCardLayoutStore = WeakMap<TodayCardShelf, LayoutConfiguration>; +type TodayCardLayoutStoreContext = TodayCardLayoutStore | undefined; + +const TODAY_CARD_LAYOUT_CONTEXT_ID = 'today-card-layout-context'; + +/** + * Store the {@linkcode LayoutConfiguration} for each {@linkcode TodayCardShelf} in a + * {@linkcode TodayPage} in "context", so it can be retrieved at the shelf-component level + * + * This is necessary because the layout of the cards within each shelf of a {@linkcode TodayPage} + * is only knowable given information about the shelves that were rendered before it + * + * The information about the shelf layout is persisted through the "context" API so that the + * rendering of a {@linkcode TodayPage} can defer to the "default" page component, which requires + * that we pass no additional arguments into each shelf component + * + * {@linkcode getTodayCardLayoutConfiguration} can be used to look up the {@linkcode LayoutConfiguration} + * stored for a given {@linkcode TodayCardShelf} + */ +export function setTodayCardLayoutContext(page: Pick<TodayPage, 'shelves'>) { + const store: TodayCardLayoutStore = new WeakMap(); + + let shouldStretchFirstCardMultiline = false; + let shouldStretchFirstCardInline = false; + + for (const shelf of page.shelves) { + // Skip any non-`TodayCard` shelves + if (!isTodayCardShelf(shelf)) { + continue; + } + + store.set(shelf, { + wrap: { + shouldStretchFirstCard: shouldStretchFirstCardMultiline, + }, + nowrap: { + shouldStretchFirstCard: shouldStretchFirstCardInline, + }, + }); + + // In the multi-line card configuration, shelves with two or three cards in them will + // require that the next shelf swaps to stretching the cards at the opposite end + if (shelf.items.length === 2 || shelf.items.length === 3) { + shouldStretchFirstCardMultiline = !shouldStretchFirstCardMultiline; + } + + // In the "inline" card configuration, each shelf should always alternate which end the + // card is stretched on + shouldStretchFirstCardInline = !shouldStretchFirstCardInline; + } + + setContext<TodayCardLayoutStoreContext>( + TODAY_CARD_LAYOUT_CONTEXT_ID, + store, + ); +} + +/** + * Retrieve the {@linkcode LayoutConfiguration} for a given {@linkcode TodayCardShelf} + */ +export function getTodayCardLayoutConfiguration( + shelf: TodayCardShelf, +): LayoutConfiguration { + const todayCardLayout = getContext<TodayCardLayoutStoreContext>( + TODAY_CARD_LAYOUT_CONTEXT_ID, + ); + + return todayCardLayout?.get(shelf) ?? LAYOUT_CONFIGURATION_FALLBACK; +} diff --git a/src/jet/action-handlers/browser.ts b/src/jet/action-handlers/browser.ts new file mode 100644 index 0000000..08d1a5a --- /dev/null +++ b/src/jet/action-handlers/browser.ts @@ -0,0 +1,16 @@ +// Browser ONLY logic. Must have the same exports as server.ts +// See: docs/isomorphic-imports.md + +import type { Dependencies } from './types'; + +import { registerHandler as registerFlowActionHandler } from '~/jet/action-handlers/flow-action'; +import { registerHandler as registerExternalURLActionHandler } from '~/jet/action-handlers/external-url-action'; +import { registerHandler as registerCompoundActionHandler } from '~/jet/action-handlers/compound-action'; + +export type { Dependencies }; + +export function registerActionHandlers(dependencies: Dependencies) { + registerCompoundActionHandler(dependencies); + registerFlowActionHandler(dependencies); + registerExternalURLActionHandler(dependencies); +} diff --git a/src/jet/action-handlers/compound-action.ts b/src/jet/action-handlers/compound-action.ts new file mode 100644 index 0000000..9cf1be0 --- /dev/null +++ b/src/jet/action-handlers/compound-action.ts @@ -0,0 +1,33 @@ +import type { LoggerFactory } from '@amp/web-apps-logger'; +import type { Jet } from '~/jet'; +import type { CompoundAction } from '~/jet/models'; + +export type Dependencies = { + jet: Jet; + logger: LoggerFactory; +}; + +export async function registerHandler(dependencies: Dependencies) { + const { jet, logger } = dependencies; + + const log = logger.loggerFor('jet/action-handlers/compound-action'); + + jet.onAction('compoundAction', async (action: CompoundAction) => { + log.info('received CompoundAction:', action); + + const { subactions = [] } = action; + + // Perform actions in sequence + for (const action of subactions) { + await jet.perform(action).catch((e) => { + // Throwing error stops for...of execution + // TODO: rdar://73165545 (Error Handling Across App) + throw new Error( + `an error occurred while handling CompoundAction: ${e}`, + ); + }); + } + + return 'performed'; + }); +} diff --git a/src/jet/action-handlers/external-url-action.ts b/src/jet/action-handlers/external-url-action.ts new file mode 100644 index 0000000..a9d3769 --- /dev/null +++ b/src/jet/action-handlers/external-url-action.ts @@ -0,0 +1,19 @@ +import type { Jet } from '~/jet'; +import type { LoggerFactory } from '@amp/web-apps-logger'; +import type { ExternalUrlAction } from '@jet-app/app-store/api/models'; + +export type Dependencies = { + jet: Jet; + logger: LoggerFactory; +}; + +export function registerHandler(dependencies: Dependencies) { + const { jet, logger } = dependencies; + + const log = logger.loggerFor('jet/action-handlers/external-url-action'); + + jet.onAction('ExternalUrlAction', async (action: ExternalUrlAction) => { + log.info('received external URL action:', action); + return 'performed'; + }); +} diff --git a/src/jet/action-handlers/flow-action.ts b/src/jet/action-handlers/flow-action.ts new file mode 100644 index 0000000..4cdcc5e --- /dev/null +++ b/src/jet/action-handlers/flow-action.ts @@ -0,0 +1,369 @@ +import { isNothing, unwrapOptional } from '@jet/environment'; +import type { Intent } from '@jet/environment/dispatching'; +import type { LoggerFactory } from '@amp/web-apps-logger'; +import { History } from '@amp/web-apps-utils'; + +import type { FlowAction } from '@jet-app/app-store/api/models'; +import { isSearchResultsPageIntent } from '@jet-app/app-store/api/intents/search-results-page-intent'; +import { isChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent'; + +import type { Jet } from '~/jet'; +import { type Page, assertIsPage, FLOW_ACTION_KIND } from '~/jet/models'; +import { mapException } from '~/utils/error'; +import { stripHost } from '~/utils/url'; + +import type { ComponentProps } from 'svelte'; +import type AppComponent from '~/App.svelte'; +import { handleModalPresentation } from '~/jet/utils/handle-modal-presentation'; +import { addRejectedIntent } from '../utils/error-metadata'; + +type AppComponentProps = Partial<ComponentProps<AppComponent>>; + +// This action handler is responsible for all routing and related state +// management. +// +// Take care when making modifications here. There are many subtle invariants +// that must be maintained. They should be documented in comments throughout. +// It might be best to read the whole file to understand this full context +// before attempting even a small fix. +// +// High level overview: +// +// There are two ways for routing state changes to arise in the app: +// +// 1. Direct user interaction with the app (a FlowAction) +// 2. Indirect user interaction via browser back/forward buttons (popstate) +// +// FlowAction is the bedrock of navigation in the app. Anytime the user interacts +// with a button, link, etc. a FlowAction is performed (Jet.perform). When that +// happens, the Jet runtime eventually invokes the handler in this file +// (see jet.onAction below) to change the state of the app. +// +// This file manages the browser history and thus has the dual responsibility +// of handling state changes that come from the back and forward buttons. The +// state stored off when handling a FlowAction is later used by the popstate +// handler to navigate backwards without needing to re-fetch the previous page. +// +// Take note that these two processes are coupled fairly tightly due to the +// popstate needing data from the previous navigation. This is stored in the +// State interface. Take care when updating one flow that a modification is +// likely needed in the other. +// +// At the end of both of these processes, a call to updateApp is made. This +// changes the view model passed down to the top level <App> component. As a +// result of Svelte's reactivity, this could result in the entire page changing +// or just a part of it being amended to or removed. Additionally, the `page` +// passed in (the view model) can also be a promise. In which case, <App> will +// await it and display a loading spinner until it resolves or rejects. +// +// Notable specifics: +// +// Handling a FlowAction roughly has the following steps: +// +// 1. Extract a "destination" intent from the FlowAction. Recall that Jet +// actions communicate a user interaction, but return no value. Jet +// intents can be contained within an action and return data. In this case, +// the intent derived from a FlowAction is used to retrieve the data for +// the new page to which the FlowAction sends the user. +// +// 2. Dispatch the "destination" intent. Here, we resolve the Promise when +// the page is ready, but we'll resolve early with an unresolve page +// promise after 500ms. We take advantage of that the fact that passing a +// Promise to updateApp will show a loading spinner. We wait 500ms, +// because we don't want to immediately show a loading spinner or change +// the page. +// +// 3. Update current page state in the history (ex. scroll position) and then +// push a new history state for the page we're about to display. Note that +// this must be done after the page Promise resolves, because we need to +// store the page view model itself and we only know the canonicalURL of +// it once it resolves. This state is used by popstate to return to the +// page should the user ever leave and then come back to it. +// +// 4. Call updateApp to change the UI presented. At this point, it could be a +// completed page (in which case step 3 will have already happened). The +// app will display the new page immediately. Or, it could still be a +// Promise (in which case step 3 will happen once it resolves and then the +// page will resolve). The <App> will display a loading spinner until this +// resolution happens. +// +// Handling a popstate event follows a similar pattern, but has some additional +// complexity. +// +// The simple case is that the state that we stored off above in step 3 is +// available. In which case, returning to the old page only involves calling +// updateApp with the view model we stored. +// +// But, we don't want to store an infinite history as these view models are +// sizable. We limit history to an arbitrary depth. After the user has +// navigated beyond that depth, we forget the oldest states. If a user ever +// were to back button all the way back to them, there would be no view model +// to restore. But, we do have the URL, so we use that and pretend like we're +// deeplinking into the app again for the first time. Care must be taken here +// to not perform a FlowAction, since that would modify the history. popstate +// events have already modified the browser history to point to the desired +// new state. So, we manually dispatch the page intent and perform other +// actions (such as switching the selected tab) ourselves. We then use the page +// promise as above to call updateApp. +export type Dependencies = { + jet: Jet; + logger: LoggerFactory; + updateApp: (props: AppComponentProps) => void; +}; + +interface State { + page: Page; +} + +export function registerHandler(dependencies: Dependencies) { + const { jet, logger, updateApp } = dependencies; + + const history = new History<State>(logger, { + getScrollablePageElement() { + return ( + document.getElementById('scrollable-page-override') || + document.getElementById('scrollable-page') || + // If we haven't defined a specific scrollable element, + // scroll the whole page + document.getElementsByTagName('html')?.[0] + ); + }, + }); + + const log = logger.loggerFor('jet/action-handlers/flow-action'); + + let isFirstPage = true; + + jet.onAction(FLOW_ACTION_KIND, async (action: FlowAction) => { + log.info('received FlowAction:', action); + // timer for request time start + // TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit) + // const pageSpeedMetric = perfkit.makeNewPageSpeedMetric(); + // pageSpeedMetric.capturePageRequestTime(); + + let intent: Intent<unknown>; + try { + intent = unwrapOptional(action.destination); + } catch (e) { + log.info( + '`FlowAction` received without a destination `Intent`: update the Jet app to attach an `Intent` to this `FlowAction`', + ); + + return; + } + + // If the destination `Intent` must be performed server-side, determine + // the destination URL and perform full browser navigation to that location + if (!isFirstPage && mustPerformServerSide(intent)) { + const { pageUrl } = action; + + if (isNothing(pageUrl)) { + log.error( + `\`${intent.$kind}\` must be performed server-side, but the action lacks a \`pageUrl\` to navigate to`, + ); + return 'performed'; + } + + window.location.href = stripHost(pageUrl); + return 'performed'; + } + + // We capture this variable since below it is used asynchronously, but + // we updated it at the end of this handler (so it could change before + // it's used below). + const shouldReplace = isFirstPage; + + // Resolves either when the page is ready or 800ms have elapsed + // (we want to show a loading spinner after 800ms) + const page = await getPage(intent, action); + + // If the action requires the page to be rendered in a modal. + if (action.presentationContext === 'presentModal') { + handleModalPresentation(page, log, action.page); + return 'performed'; + } + + // This must happen before history.replaceState/pushState + // We call this now, because the next line updates <App> which changes + // the DOM. After that point we can't do things like record scroll + // position, etc. + history.beforeTransition(); + + updateApp({ + page: page.promise.then((page: Page): Page => { + const state = { + page, + }; + + const canonicalURL = mapException( + () => unwrapOptional(page.canonicalURL), + '`page` resolved without a `canonicalURL`, which is required for navigation', + ); + + // TODO: fix perfkit - rdar://111465791 ([Onyx] Foundation - PerfKit) + // perfkit.setPageType(page.pageMetrics?.pageFields?.pageType as string | undefined || 'unknown'); + + if (shouldReplace) { + history.replaceState(state, canonicalURL); + } else { + history.pushState(state, canonicalURL); + } + + didEnterPage(page); + return page; + }), + isFirstPage, + }); + + // Future updates won't be for the first page + isFirstPage = false; + + return 'performed'; + }); + + history.onPopState( + async (url: string, state: State | undefined): Promise<void> => { + // NOTE: We don't call history.beforeTransition() anywhere here, + // because we don't expect to save any state from the previous page + // on back. + + if (state) { + const { page } = state; + + log.info('received popstate, so resetting page:', page); + didEnterPage(page); + updateApp({ page, isFirstPage }); + + return; + } + + // If the state is missing page data, we have to recompute the view model + const routing = await jet.routeUrl(url); + + if (!routing) { + log.error( + 'received popstate without data, but URL was unroutable:', + url, + ); + + // This probably shouldn't happen (since we only ever push valid + // URLs to the history), but if it does, the best we can do is show + // an error. + didEnterPage(null); // to exit the current page + updateApp({ + page: Promise.reject(new Error('404')), + isFirstPage, + }); + return; + } + + log.info( + 'received popstate without data, so routing URL to:', + routing, + ); + + // We can't perform the FlowAction here, as that would cause a new + // history state to be pushed. Since we're in the context of a + // popState, that would cause an infinite history loop where the back + // button goes back but then immediately pushes again to the history + // (so the user doesn't actually go back in history). + // See: rdar://92621382 (Navigating more than 10 pages and then going back breaks back button) + // + // Careful reading will note that this promise will not reject. + // Only the page.promise can reject (and we'll hand that to updateApp, + // which will display the appropriate error for this case). + // + // Like in the handling of FlowAction (above), this blocks for at + // most 800ms before resolving. Either the page is ready, or we + // want to display a loading spinner. updateApp() will show a + // spinner if page.promise is not ready. + const page = await getPage(routing.intent, routing.action); + + updateApp({ + page: page.promise.then((page: Page): Page => { + // No history.replaceState/pushState like in handling FlowAction + // (above) since this is in the context of a popstate. The + // history stack, URL bar, etc. have already been updated. + + didEnterPage(page); + return page; + }), + isFirstPage, + }); + }, + ); + + /** + * Get a Page by dispatching its intent. Returns a promise that resolves + * when the page is ready or after 800ms, whichever is first. + * + * The promise-inside-an-object-inside-a-promise return type is + * intentional. If we just returned Promise<Page>, then this function + * would not resolve until the page was ready. But we want it to resolve + * after 800ms, even if the page isn't ready. + */ + async function getPage( + intent: Intent<unknown>, + sourceAction: FlowAction | undefined, + ): Promise<{ promise: Promise<Page> }> { + const page = (async (): Promise<Page> => { + try { + let page = await jet.dispatch(intent); + log.info('FlowAction destination resolved to:', page); + + assertIsPage(page); + + return page; + } catch (e: any) { + log.error('FlowAction destination rejected:', e); + + // Provide a way to retry the flow action from <ErrorPage> + if (!e.userInfo || e.userInfo.status !== 404) { + e.retryFlowAction = sourceAction; + } + + e.isFirstPage = isFirstPage; + addRejectedIntent(e, intent); + throw e; + } + })(); + + // Wait until the page loads (or up to 500ms, then show loading spinner) + await Promise.race([ + page, + // Note that this has interplay with <PageResolver> + new Promise((resolve) => setTimeout(resolve, 500)), + // TODO: rdar://78166703 Add test to ensure catch no-ops + // + // NOTE: This catch is important. If the page promise rejects, we + // want that to flow down into updateApp, where the appropriate + // error page will be displayed. If we don't no-op here, we'll + // cause the FlowAction to not finish handling (and updateApp will + // never be called). + ]).catch(() => {}); + + // Wrapping in an object to prevent this function's promise from + // not resolving until the page is ready. We want to resolve + // immediately if it's already been 800ms + return { promise: page }; + } + + function didEnterPage(page: Page | null): void { + // Wrapped in an IIFE to avoid blocking anything (or breaking anything + // if this fails) + (async (): Promise<void> => { + try { + await jet.didEnterPage(page); + } catch (e) { + log.error('didEnterPage error:', e); + } + })(); + } +} + +/** + * Determines if an `Intent` must be performed server-side + */ +function mustPerformServerSide(intent: Intent<unknown>): boolean { + return isSearchResultsPageIntent(intent) || isChartsPageIntent(intent); +} diff --git a/src/jet/bootstrap.ts b/src/jet/bootstrap.ts new file mode 100644 index 0000000..32b10a0 --- /dev/null +++ b/src/jet/bootstrap.ts @@ -0,0 +1,125 @@ +import { makeRouterUsingRegisteredControllers } from '@jet/environment/routing'; + +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { AppStoreIntentDispatcher } from '@jet-app/app-store/foundation/runtime/app-store-intent-dispatcher'; +import { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime'; + +import { + type Dependencies, + ObjectGraphType, + makeObjectGraph, +} from '~/jet/dependencies'; + +import { AppEventPageIntentController } from '@jet-app/app-store/controllers/app-events/app-event-page-intent-controller'; +import { BundlePageIntentController } from '@jet-app/app-store/controllers/product-page/bundle-page-intent-controller'; +import { EditorialPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-page-intent-controller'; +import { EditorialShelfCollectionPageIntentController } from '@jet-app/app-store/controllers/editorial-pages/editorial-shelf-collection-page-intent-controller'; +import { GroupingPageIntentController } from '@jet-app/app-store/controllers/grouping/grouping-page-intent-controller'; +import { ProductPageIntentController } from '@jet-app/app-store/controllers/product-page/product-page-intent-controller'; +import { SearchLandingPageIntentController } from '@jet-app/app-store/controllers/search/search-landing-page-intent-controller'; +import { SearchResultsPageIntentController } from '@jet-app/app-store/controllers/search/search-results-controller'; +import { RoutableArticlePageIntentController } from '@jet-app/app-store/controllers/today/routable-article-page-intent-controller'; +import { ArcadeGroupingPageIntentController } from '@jet-app/app-store/controllers/arcade/arcade-grouping-page-intent-controller'; +import { DeveloperPageIntentController } from '@jet-app/app-store/controllers/developer/developer-page-intent-controller'; +import { ChartsPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-page-intent-controller'; +import { ChartsHubPageIntentController } from '@jet-app/app-store/controllers/top-charts/charts-hub-page-intent-controller'; +import { SeeAllPageIntentController } from '@jet-app/app-store/controllers/product-page/see-all-intent-controller'; +import { RoutableTodayPageIntentController } from '@jet-app/app-store/controllers/today/routable-today-page-intent-controller'; +import { RoomPageIntentController } from '@jet-app/app-store/controllers/room/room-page-intent-controller'; +import { RoutableArcadeSeeAllPageController } from '@jet-app/app-store/controllers/arcade/routable-arcade-see-all-page-controller'; +import * as landingPageNavigationControllers from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers'; +import { RootRedirectController } from '@jet-app/app-store/common/web-navigation/platform-landing-page-intent-controllers'; +import { EulaPageIntentController } from '@jet-app/app-store/controllers/product-page/eula-page-intent-controller'; +import { CategoryTabsIntentController } from '@jet-app/app-store/controllers/web-navigation/category-tabs-intent-controller'; + +import { ErrorPageIntentController } from '~/jet/intents/error-page-intent-controller'; +import { ChartsPageRedirectIntentController } from '~/jet/intents/charts-page-redirect-intent-controller'; + +import { + RouteUrlIntentController, + LintMetricsEventIntentController, +} from '~/jet/intents'; +import * as staticMessagePageControllers from '~/jet/intents/static-message-pages'; + +function makeIntentDispatcher(): AppStoreIntentDispatcher { + const intentDispatcher = new AppStoreIntentDispatcher(); + + intentDispatcher.register(RouteUrlIntentController); + intentDispatcher.register(LintMetricsEventIntentController); + + // Route Providers + for (const Controller of Object.values(landingPageNavigationControllers)) { + // `RootRedirectController` needs to be registered last, due to it's path match of `/{sf}`, + // it could inadvertently match a landing page route like `/vision`, so we are skipping it here + // and registering it at the bottom of this function. + if (Controller !== RootRedirectController) { + intentDispatcher.register(Controller); + } + } + + for (const StaticMessagePageController of Object.values( + staticMessagePageControllers, + )) { + intentDispatcher.register(StaticMessagePageController); + } + + intentDispatcher.register(ArcadeGroupingPageIntentController); + intentDispatcher.register(BundlePageIntentController); + intentDispatcher.register(EditorialPageIntentController); + intentDispatcher.register(EditorialShelfCollectionPageIntentController); + intentDispatcher.register(GroupingPageIntentController); + intentDispatcher.register(new SearchResultsPageIntentController()); + intentDispatcher.register(SearchLandingPageIntentController); + intentDispatcher.register(DeveloperPageIntentController); + intentDispatcher.register(RoutableArticlePageIntentController); + intentDispatcher.register(RoutableTodayPageIntentController); + intentDispatcher.register(RoomPageIntentController); + intentDispatcher.register(RoutableArcadeSeeAllPageController); + intentDispatcher.register(EulaPageIntentController); + intentDispatcher.register(ChartsPageRedirectIntentController); + intentDispatcher.register(ErrorPageIntentController); + + // "Charts" Pages; "hub" must come first since so it's URL matches before the "detail" page + intentDispatcher.register(ChartsHubPageIntentController); + intentDispatcher.register(ChartsPageIntentController); + + // Product Page Routes; order is important due to overlapping URL patterns + // The product page itself must come last or it will match the more-specific patterns + intentDispatcher.register(AppEventPageIntentController); + intentDispatcher.register(SeeAllPageIntentController); + intentDispatcher.register(ProductPageIntentController); + + intentDispatcher.register(new CategoryTabsIntentController()); + + // We register the root redirect controller last so more specific path patterns can be matched first + intentDispatcher.register(RootRedirectController); + + return intentDispatcher; +} + +/** + * Bootstraps the Jet runtime for Apps + * + * @param dependencies dependencies to initialize the Object Graph with + */ +export function bootstrap(dependencies: Dependencies): { + runtime: AppStoreRuntime; + objectGraph: AppStoreObjectGraph; +} { + const intentDispatcher = makeIntentDispatcher(); + + const baseObjectGraph = makeObjectGraph(dependencies); + + const router = makeRouterUsingRegisteredControllers( + intentDispatcher, + baseObjectGraph, + ); + const appObjectGraph = baseObjectGraph + .adding(ObjectGraphType.router, router) + .adding(ObjectGraphType.dispatcher, intentDispatcher); + + return { + runtime: new AppStoreRuntime(intentDispatcher, appObjectGraph), + objectGraph: appObjectGraph, + }; +} diff --git a/src/jet/dependencies/bag.ts b/src/jet/dependencies/bag.ts new file mode 100644 index 0000000..32f6bc7 --- /dev/null +++ b/src/jet/dependencies/bag.ts @@ -0,0 +1,290 @@ +import type { Bag as NativeBag, BagKeyDescriptor } from '@jet/environment'; +import type { Opt } from '@jet/environment'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +import type { Locale } from './locale'; +import { + EU_STOREFRONTS, + SUPPORTED_STOREFRONTS_FOR_VISION, + UNSUPPORTED_STOREFRONTS_FOR_ARCADE, +} from '~/constants/storefront'; + +export type BagRetrievalMethod = Exclude<keyof NativeBag, 'registerBagKeys'>; + +export function makeUnimplementedKeyRequestWarning( + method: BagRetrievalMethod, + key: string, +) { + return `requested unimplemented \`${method}\` key \`${key}\``; +} + +export class WebBag implements NativeBag { + private readonly log: Logger; + private readonly locale: Locale; + + constructor(loggerFactory: LoggerFactory, locale: Locale) { + this.log = loggerFactory.loggerFor('Bag'); + this.locale = locale; + } + + private provideNoValue(method: BagRetrievalMethod, key: string): null { + this.log.warn(makeUnimplementedKeyRequestWarning(method, key)); + + return null; + } + + registerBagKeys(_keys: BagKeyDescriptor[]): void { + // We hardcode, so registration is a no-op + } + + double(key: string): Opt<number> { + switch (key) { + case 'game-controller-recommended-rollout-rate': + return 1.0; // set to 1.0 to enable `learn more` button for game controller capability + case 'icon-artwork-rollout-rate': + return 1.0; // set to 1.0 to enable new icon artwork style + default: + return this.provideNoValue('double', key); + } + } + + integer(key: string): Opt<number> { + return this.provideNoValue('integer', key); + } + + boolean(key: string): Opt<boolean> { + switch (key) { + case 'enableAppEvents': + return true; + case 'enable-app-accessibility-labels': + return true; + case 'enable-app-store-age-ratings': + return true; + case 'enable-external-purchase': + return true; + case 'enable-privacy-nutrition-labels': + return true; + case 'enable-system-app-reviews': + return true; + case 'enable-vision-platform': + return SUPPORTED_STOREFRONTS_FOR_VISION.has( + this.locale.activeStorefront, + ); + case 'arcade-enabled': + return !UNSUPPORTED_STOREFRONTS_FOR_ARCADE.has( + this.locale.activeStorefront, + ); + + // Enable required `GroupingPage` features + case 'enable-featured-categories-on-groupings': + case 'enable-category-bricks-on-groupings': + return true; + case 'enable-seller-info': + return true; + case 'enable-preview-platform-for-web': + return false; + case 'enableProductPageVariants': + return true; + case 'game-center-extend-supported-features': + return true; + case 'enable-product-page-install-size': + return true; + case 'enable-icon-artwork': + return true; + default: + return this.provideNoValue('boolean', key); + } + } + + array(key: string): Opt<unknown> { + switch (key) { + // URL patterns that are opted into the "edge" domains + // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L350 + case 'apps-media-api-edge-end-points': + return [ + // Including a pattern that matches our "search" API endpoint ensures + // that the built URL uses the `apps-media-api-search-edge-host` host + // https://github.pie.apple.com/app-store/ios-appstore-app/blob/83834eea5dfcad22d902fe395c4d140ec7fa8cea/src/foundation/media/url-builder.ts#L352 + '/search', + ]; + case 'enabled-external-purchase-placements': + return ['product-page-banner', 'product-page-info-section']; + case 'tabs/standard': + return [ + { + id: 'today', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Today', + ), + 'image-identifier': 'text.rectangle.page', + }, + { + id: 'apps', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Apps', + ), + 'image-identifier': 'app.3.stack.3d.fill', + }, + { + id: 'apps-and-games', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.AppsAndGames', + ), + 'image-identifier': 'rocket.fill', + }, + { + id: 'arcade', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Arcade', + ), + 'image-identifier': 'joystickcontroller.fill', + }, + { + id: 'create', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Create', + ), + 'image-identifier': 'paintbrush.fill', + }, + { + id: 'discover', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Discover', + ), + 'image-identifier': 'star.fill', + }, + { + id: 'games', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Games', + ), + 'image-identifier': 'rocket.fill', + }, + { + id: 'work', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Work', + ), + 'image-identifier': 'paperplane.fill', + }, + { + id: 'play', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Play', + ), + 'image-identifier': 'rocket.fill', + }, + { + id: 'develop', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Develop', + ), + 'image-identifier': 'hammer.fill', + }, + { + id: 'categories', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Categories', + ), + 'image-identifier': 'square.grid.2x2.fill', + }, + { + id: 'search', + title: this.locale.i18n.t( + 'ASE.Web.AppStore.Navigation.LandingPage.Search', + ), + 'image-identifier': 'magnifyingglass', + }, + ]; + default: + return this.provideNoValue('array', key); + } + } + + dictionary(key: string): Opt<unknown> { + return this.provideNoValue('dictionary', key); + } + + url(key: string): Opt<string> { + switch (key) { + case 'apps-media-api-host': + return 'amp-api-edge.apps.apple.com'; + case 'apps-media-api-edge-host': + return 'amp-api-edge.apps.apple.com'; + case 'apps-media-api-search-edge-host': + return 'amp-api-search-edge.apps.apple.com'; + + default: + return this.provideNoValue('url', key); + } + } + + string(key: string): Opt<string> { + switch (key) { + case 'countryCode': + return this.locale.activeStorefront; + + case 'language-tag': + return this.locale.activeLanguage; + + case 'language': + // TODO: rdar://78159789: util for this? What about zh-Hant, etc. + return this.locale.activeLanguage.split('-')[0]; + + // Some URLs are accessed as strings + // TODO: fix this upstream in `ios-appstore-app` so it uses `.url()` instead + case 'apps-media-api-edge-host': + case 'apps-media-api-search-edge-host': + return this.url(key); + + case 'game-controller-learn-more-editorial-item-id': + return '1687769242'; + + case 'familySubscriptionsLearnMoreEditorialItemId': + return '1563279606'; + + case 'external-purchase-learn-more-editorial-item-id': + if (this.locale.activeStorefront === 'kr') { + return 'id1727067165'; + } + + return 'id1760810284'; + + case 'appPrivacyLearnMoreEditorialItemId': + return 'id1538632801'; + + case 'ageRatingLearnMoreEditorialItemId': + return '1825160725'; + + case 'accessibility-learn-more-editorial-item-id': + return '1814164299'; + + case 'external-purchase-product-page-banner-text-variant': + return '2'; + case 'external-purchase-product-page-annotation-variant': + return '4'; + + case 'transparencyLawEditorialItemId': + if (EU_STOREFRONTS.includes(this.locale.activeStorefront)) { + return 'id1620909697'; + } + + return null; + + case 'appPrivacyDefinitionsEditorialItemId': + return '1539235847'; + + case 'metrics_topic': + return 'xp_amp_appstore_unidentified'; + + case 'in-app-purchases-learn-more-editorial-item-id': + return '1436214772'; + + case 'web-navigation-category-tabs-editorial-item-id': + return '1842456901'; + + default: + return this.provideNoValue('string', key); + } + } +} diff --git a/src/jet/dependencies/client.ts b/src/jet/dependencies/client.ts new file mode 100644 index 0000000..6b8a979 --- /dev/null +++ b/src/jet/dependencies/client.ts @@ -0,0 +1,96 @@ +import type { Locale } from './locale'; + +export class WebClient implements Client { + private readonly locale: Locale; + + deviceType: DeviceType = 'web'; + + // Tell the App Store Client that we're *really* the "web", even if the `DeviceType` + // says otherwise + __isReallyWebClient = true as const; + + // TODO: how do we define this for the "client" web, when it can change over time? + screenSize: { width: number; height: number } = { width: 0, height: 0 }; + + // TODO: how is this used? We can't have a consistent value across multiple sessions + guid: string = 'xxx-xx-xxx'; + + screenCornerRadius: number = 0; + + newPaymentMethodEnabled = false; + + isActivityAvailable = false; + + isElectrocardiogramInstallationAllowed = false; + + isScandiumInstallationAllowed = false; + + isSidepackingEnabled = false; + + isTinkerWatch = false; + + supportsHEIF: boolean = false; + + isMandrakeSupported: boolean = false; + + isCharonSupported: boolean = false; + + buildType: BuildType; + + maxAppContentRating: number = 1000; + + isIconArtworkCapable: boolean = true; + + constructor(buildType: BuildType, locale: Locale) { + this.buildType = buildType; + this.locale = locale; + } + + get storefrontIdentifier(): string { + return this.locale.activeStorefront; + } + + deviceHasCapabilities(_capabilities: string[]): boolean { + return false; + } + + deviceHasCapabilitiesIncludingCompatibilityCheckIsVisionOSCompatibleIOSApp( + _capabilities: string[], + _supportsVisionOSCompatibleIOSBinary: boolean, + ): boolean { + return false; + } + + isActivePairedWatchSystemVersionAtLeastMajorVersionMinorVersionPatchVersion( + _majorVersion: number, + _minorVersion: number, + _patchVersion: number, + ): boolean { + return false; + } + + canDevicePerformAppActionWithAppCapabilities( + _appAction: string, + _appCapabilities: string[] | undefined | null, + ): boolean { + return false; + } + + isAutomaticDownloadingEnabled(): boolean { + return false; + } + + isAuthorizedForUserNotifications(): boolean { + return false; + } + + deletableSystemAppCanBeInstalledOnWatchWithBundleID( + _bundleId: string, + ): boolean { + return false; + } + + isDeviceEligibleForDomain(_domain: string): boolean { + return false; + } +} diff --git a/src/jet/dependencies/console.ts b/src/jet/dependencies/console.ts new file mode 100644 index 0000000..fe0ba64 --- /dev/null +++ b/src/jet/dependencies/console.ts @@ -0,0 +1,26 @@ +import type { LoggerFactory, Logger } from '@amp/web-apps-logger'; +import type { RequiredConsole } from '@jet-app/app-store/foundation/wrappers/console'; + +export class WebConsole implements RequiredConsole { + private readonly logger: Logger; + + constructor(loggerFactory: LoggerFactory) { + this.logger = loggerFactory.loggerFor('jet-console'); + } + + error(...data: unknown[]): void { + this.logger.error(...data); + } + + info(...data: unknown[]): void { + this.logger.info(...data); + } + + log(...data: unknown[]): void { + this.logger.info(...data); + } + + warn(...data: unknown[]): void { + this.logger.warn(...data); + } +} diff --git a/src/jet/dependencies/feature-flags.ts b/src/jet/dependencies/feature-flags.ts new file mode 100644 index 0000000..e745137 --- /dev/null +++ b/src/jet/dependencies/feature-flags.ts @@ -0,0 +1,20 @@ +const ENABLED_FEATURES = new Set([ + // Make the `ProductPageIntentController` return a `ShelfBasedProductPage` instance + 'shelves_2_0_product', + // Enable shelf-based "Top Charts" features + // 'shelves_2_0_top_charts', + // Make the `RibbonBarShelf` contain an array of `RibbonBarItem`s + 'shelves_2_0_generic', + // Enable AX Metadata + 'product_accessibility_support_2025A', +]); + +export class WebFeatureFlags implements FeatureFlags { + isEnabled(feature: string): boolean { + return ENABLED_FEATURES.has(feature); + } + + isGSEUIEnabled(_feature: string): boolean { + return false; + } +} diff --git a/src/jet/dependencies/locale.ts b/src/jet/dependencies/locale.ts new file mode 100644 index 0000000..e48e935 --- /dev/null +++ b/src/jet/dependencies/locale.ts @@ -0,0 +1,99 @@ +import type { Locale as JetLocaleDependency } from '@jet-app/app-store/foundation/dependencies/locale/locale'; +import type { + NormalizedLanguage, + NormalizedStorefront, + NormalizedLocale, + UnnormalizedLocale, +} from '@jet-app/app-store/api/locale'; +import type I18N from '@amp/web-apps-localization'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +import type { Jet } from '~/jet/jet'; +import { + DEFAULT_STOREFRONT_CODE, + DEFAULT_LANGUAGE_BCP47, +} from '~/constants/storefront'; +import { + type NormalizedLocaleWithDefault, + normalizeStorefront, + normalizeLanguage, +} from '~/utils/locale'; +import type { Optional } from '@jet/environment'; + +/** + * Contains information related to the locale of the request currently being + * made to the application. + * + * Typically, localization information is expected to be known when the Jet + * instance is initialized. The Web, however, will not know the current + * locale and langauge until after routing has already taken place. + * + * This object exists to contain that lazily-determined locale information, + * so that other dependencies can retreive it from here. It is to be created + * with the rest of the dependencies and passed to them when they are created. + * + * Localization information is set in the {@linkcode Jet#setLocale} method + */ +export class Locale implements JetLocaleDependency { + private readonly logger: Logger; + + private _storefront: NormalizedStorefront | undefined; + private _language: NormalizedLanguage | undefined; + + i18n: I18N | undefined; + + constructor(loggerFactory: LoggerFactory) { + this.logger = loggerFactory.loggerFor('locale'); + } + + get activeStorefront(): NormalizedStorefront { + if (!this._storefront) { + this.logger.warn('`storefront` was accessed before being set'); + return DEFAULT_STOREFRONT_CODE; + } + + return this._storefront; + } + + get activeLanguage(): NormalizedLanguage { + if (!this._language) { + this.logger.warn('`language` was accessed before being set'); + return DEFAULT_LANGUAGE_BCP47; + } + + return this._language; + } + + setActiveLocale(locale: NormalizedLocale): void { + this._storefront = locale.storefront; + this._language = locale.language; + } + + normalize({ + storefront, + language, + }: UnnormalizedLocale): NormalizedLocaleWithDefault { + const { + storefront: normalizedStorefront, + languages, + defaultLanguage, + } = normalizeStorefront(storefront); + + return { + storefront: normalizedStorefront, + ...normalizeLanguage(language || '', languages, defaultLanguage), + }; + } + + deriveLocaleForUrl(locale: NormalizedLocale): { + storefront: string; + language: Optional<string>; + } { + const { isDefaultLanguage } = this.normalize(locale); + + return { + storefront: locale.storefront, + language: isDefaultLanguage ? undefined : locale.language, + }; + } +} diff --git a/src/jet/dependencies/localization.ts b/src/jet/dependencies/localization.ts new file mode 100644 index 0000000..d6961e4 --- /dev/null +++ b/src/jet/dependencies/localization.ts @@ -0,0 +1,523 @@ +import type I18N from '@amp/web-apps-localization'; +import type { LoggerFactory, Logger } from '@amp/web-apps-logger'; +import { isNothing } from '@jet/environment'; + +import type { Locale } from './locale'; +import { abbreviateNumber } from '~/utils/number-formatting'; +import { getFileSizeParts } from '~/utils/file-size'; +import { + getPlural, + interpolateString, +} from '@amp/web-apps-localization/src/translator'; +import type { Locale as SupportedLanguageIdentifier } from '@amp/web-apps-localization'; + +const SECONDS_PER_MINUTE = 60; +const SECONDS_PER_HOUR = 60 * 60; +const SECONDS_PER_DAY = SECONDS_PER_HOUR * 24; +const SECONDS_PER_YEAR = SECONDS_PER_DAY * 365; + +export function makeWebDoesNotImplementException(property: keyof Localization) { + return new Error( + `\`Localization\` method \`${property}\` is not implemented for the "web" platform`, + ); +} + +/** + * Determines if {@linkcode key} appears to be a "client" translation key + * + * "Client" keys are defined in `SCREAMING_SNAKE_CASE` + */ +function isClientLocalizationKey(key: string): boolean { + return /^[A-Z_]+$/.test(key); +} + +/** + * Transforms an App Store Client-used translation key to the format that we have + * a value for. + * + * This accounts for the fact that the "raw" key used by the App Store Client + * is either a "client" key, that we filed an analogue for in our own translations, + * or a "server" key that exists in the App Store Client translations under their + * own namespace. In either case, we need to perform a transformation on the key as + * they use it into a format that we have a value for. + */ +function transformKeyToSupportedFormat(key: string): string { + return isClientLocalizationKey(key) + ? transformClientKeyToSupportedFormat(key) + : transformServerKeyToSupportedFormat(key); +} + +/** + * Transforms an App Store Client server-side translation key into the format + * that we have a value for. + * + * This handles the fact that the App Store Client namespaces all of + * their translation strings under `AppStore.` but does not include + * that namespace when referencing the key. Since their tooling implicitly + * injects that namespace for them, we have to do the same thing manually. + + * @example + * transformServerKeyToSupportedFormat('Account.Purchases'); + * // "AppStore.Account.Purchases" + */ +function transformServerKeyToSupportedFormat(key: string): string { + return `AppStore.${key}`; +} + +/** + * Capitalizes the first character in {@linkcode input} + */ +function capitalizeFirstCharacter(input: string): string { + const [first, ...rest] = input; + + return first.toUpperCase() + rest.join(''); +} + +/** + * Transforms an App Store Client client-side translation key into the format + * that we have a value for. + * + * "Client" keys used by the App Store Client are typically provided by the OS; + * this is not available to a web application, we need an alternative to providing + * values for these translation keys. + * + * To accomplish this, we have submitted these keys to the server-side localization + * service ourelves, under a specific namespace that designates that they are the + * client-side keys that we needed to define. Other formatting changes are made to + * the key at the request of the LOC team. + * + * @example + * transformClientKeyToSupportedFormat('ACCOUNT_PURCHASES'); + * // "ASE.Web.AppStoreClient.Account.Purchases" + */ +function transformClientKeyToSupportedFormat(key: string): string { + const keyInSrvLocFormat = key + .toLowerCase() + .split('_') + .map((segment) => capitalizeFirstCharacter(segment)) + .join('.'); + + return `ASE.Web.AppStoreClient.${keyInSrvLocFormat}`; +} + +/** + * "Web" implementation of the `AppStoreKit` {@linkcode Localization} dependency + */ +export class WebLocalization implements Localization { + private readonly locale: Locale; + private readonly logger: Logger; + + constructor(locale: Locale, loggerFactory: LoggerFactory) { + this.locale = locale; + this.logger = loggerFactory.loggerFor('jet/dependency/localization'); + } + + get i18n(): I18N { + if (this.locale.i18n) { + return this.locale.i18n; + } + + throw new Error('`i18n` not yet configured '); + } + + /** + * The `BCP 47` identifier for the active locale + * + * @see {@link https://developer.apple.com/documentation/foundation/locale | Foundation Frameworks Locale Documentation} + * @see {@link https://en.wikipedia.org/wiki/IETF_language_tag | BCP 47} + */ + get identifier() { + return this.locale.activeLanguage; + } + + decimal( + n: number | null | undefined, + decimalPlaces?: number | null | undefined, + ): string | null { + if (isNothing(n)) { + return null; + } + + let langCode: string = this.locale.activeLanguage; + + if (!langCode.includes('-')) { + langCode = `${this.locale.activeLanguage}-${this.locale.activeStorefront}`; + } + + const numberingSystem = new Intl.NumberFormat( + langCode, + ).resolvedOptions().numberingSystem; + + const formatter = new Intl.NumberFormat(this.locale.activeLanguage, { + numberingSystem, + minimumFractionDigits: decimalPlaces ?? undefined, + maximumFractionDigits: decimalPlaces ?? undefined, + }); + + return formatter.format(n); + } + + string(key: string): string { + const keyInSupportedFormat = transformKeyToSupportedFormat(key); + + // `.getUninterpolatedString` is used instead of `.t` here to match + // the behavior of the `.stringWithCount` method + return this.i18n.getUninterpolatedString(keyInSupportedFormat); + } + + stringForPreferredLocale(_key: string, _locale: string | null): string { + throw makeWebDoesNotImplementException('stringForPreferredLocale'); + } + + stringWithCount(key: string, count: number): string { + let keyInSupportedFormat = transformKeyToSupportedFormat(key); + + // The App Store Client has some behavior around pluralization that differs + // from how the Media Apps localization normally works. In order to handle + // this, we have to avoid the default pluralization behavior of the `.i18n.t` + // method and do the pluralization ourselves + const keyWithPluralizationSuffix = getPlural( + count, + keyInSupportedFormat, + this.identifier as SupportedLanguageIdentifier, + ); + + // The key difference in pluralization logic is that the `other` case is + // actually handled by the "base" key without any suffix. + // Therefore, we should only use the pluralized key if it does not reflect + // the `other` case + if (!keyWithPluralizationSuffix.endsWith('.other')) { + keyInSupportedFormat = keyWithPluralizationSuffix; + } + + const uninterpolatedValue = + this.i18n.getUninterpolatedString(keyInSupportedFormat); + + // Since the `count` might be interpolated into the localization string, + // we need to run the interpolation ourselves on uninterpolated value + return interpolateString( + key, + uninterpolatedValue, + { count }, + null, + this.identifier as SupportedLanguageIdentifier, + ); + } + + stringWithCounts(_key: string, _counts: number[]): string { + throw makeWebDoesNotImplementException('stringWithCounts'); + } + + uppercased(_value: string): string { + throw makeWebDoesNotImplementException('uppercased'); + } + + /** + * Converts a number of bytes into a localized file size string + * + * @param bytes The number of bytes to convert + * @return The localized file size string + */ + fileSize(bytes: number): string | null { + let { count, unit } = getFileSizeParts(bytes); + + return this.i18n.t(`ASE.Web.AppStore.FileSize.${unit}`, { + count, + }); + } + + formattedCount(count: number | null | undefined): string | null { + if (isNothing(count)) { + return null; + } + + return abbreviateNumber(count, this.locale.activeLanguage); + } + + formattedCountForPreferredLocale( + count: number | null, + locale: string | null, + ): string | null { + if (isNothing(count)) { + return null; + } + + return isNothing(locale) + ? abbreviateNumber(count, this.locale.activeLanguage) + : abbreviateNumber(count, locale); + } + + /** + * Convert a date into a time ago label, showing how long ago + * the date occurred. + * + * @param date The date object to convert + * @return The localized string representing the amount of time that has passed + */ + timeAgo(date: Date | null | undefined): string | null { + if (!date || !(date instanceof Date) || isNaN(date.getTime())) { + return null; + } + + const relativeTimeIntl = new Intl.RelativeTimeFormat( + this.locale.activeLanguage, + { + style: 'narrow', + }, + ); + + const now = new Date(); + + const secondsAgo = (now.getTime() - date.getTime()) / 1000; + const minutesAgo = Math.floor(secondsAgo / SECONDS_PER_MINUTE); + const hoursAgo = Math.floor(secondsAgo / SECONDS_PER_HOUR); + const daysAgo = Math.floor(secondsAgo / SECONDS_PER_DAY); + const yearsAgo = Math.floor(secondsAgo / SECONDS_PER_YEAR); + const isSameYear = now.getFullYear() === date.getFullYear(); + const isUpcoming = date.getTime() > now.getTime(); + + if (secondsAgo < 0 && isUpcoming) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + month: 'short', + day: 'numeric', + }).format(date); + } + + if (secondsAgo < 60) { + return relativeTimeIntl.format(-secondsAgo, 'seconds'); + } + + if (minutesAgo < 60) { + return relativeTimeIntl.format(-minutesAgo, 'minutes'); + } + + if (hoursAgo < 24) { + return relativeTimeIntl.format(-hoursAgo, 'hours'); + } + + if (daysAgo < 7) { + return relativeTimeIntl.format(-daysAgo, 'days'); + } + + if (isSameYear) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + month: 'short', + day: 'numeric', + }).format(date); + } + + if (yearsAgo >= 0) { + return new Intl.DateTimeFormat(this.locale.activeLanguage, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }).format(date); + } + + // this return statement is here to satisfy typescript, all possible cases are + // satisfied by the above conditionals. + return null; + } + + timeAgoWithContext( + _date: Date | null | undefined, + _context: DateContext, + ): string | null { + return null; + } + + formatDate(format: string, date: Date | null | undefined): string | null { + if (isNothing(date)) { + return null; + } + + let formatterConfiguration: Intl.DateTimeFormatOptions | undefined; + + switch (format) { + case 'MMM d': // e.g. Jan 29 + formatterConfiguration = { + month: 'short', + day: 'numeric', + }; + break; + case 'MMMM d': // e.g. January 29 + formatterConfiguration = { + month: 'long', + day: 'numeric', + }; + break; + case 'j:mm': // e.g. 9:00 + formatterConfiguration = { + hour: 'numeric', + minute: '2-digit', + }; + break; + case 'MMM d, y': // e.g. Jan 29, 2025 + formatterConfiguration = { + month: 'short', + day: 'numeric', + year: 'numeric', + }; + break; + case 'MMMM d, y': // e.g. "January 29, 2025" + formatterConfiguration = { + year: 'numeric', + month: 'long', + day: 'numeric', + }; + break; + case 'EEE j:mm': // e.g. "SAT 9:00PM" + formatterConfiguration = { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }; + break; + case 'd، MMM، yyyy': // e.g. "29 Jan 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'MMM d, yyyy': // e.g. "Jan 29, 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd MMM yyyy': // e.g. "29 January 2025" + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + break; + case 'yyyy MMMM d': // e.g. "2025 January 29" + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + case 'd M yyyy': + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd MMM., yyyy': + formatterConfiguration = { + day: 'numeric', + month: 'long', + year: 'numeric', + }; + break; + case 'dd/MM/yyyy': // e.g. "29/01/2025" + formatterConfiguration = { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }; + break; + case 'd MMM , yyyy': // e.g. "29 Jan , 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + case 'd. MMM. yyyy.': // e.g. "29. Jan. 2025." + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'd. MMM yyyy': // e.g. "29. Jan 2025" + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'yyyy. MMM d.': // e.g. "2025. Jan 29." + formatterConfiguration = { + day: 'numeric', + month: 'short', + year: 'numeric', + }; + break; + + case 'd.M.yyyy': // e.g. "29.1.2025" + formatterConfiguration = { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }; + break; + + case 'd/M/yyyy': // e.g. "29/1/2025" + formatterConfiguration = { + day: 'numeric', + month: 'numeric', + year: 'numeric', + }; + break; + default: + this.logger.warn( + `\`formatDate\` called with unexpected format \`${format}\``, + ); + return null; + } + + return new Intl.DateTimeFormat( + this.locale.activeLanguage, + formatterConfiguration, + ).format(date); + } + + formatDateWithContext( + format: string, + date: Date | null | undefined, + _context: DateContext, + ): string | null { + return this.formatDate(format, date); + } + + formatDateInSentence( + sentence: string, + format: string, + date: Date | null | undefined, + ): string | null { + const formattedDate = this.formatDate(format, date); + + if (isNothing(formattedDate)) { + return null; + } + + return ( + sentence + // "Server-Side" LOC keys us `@@date@@` to mark the date to replace + .replace('@@date@@', formattedDate) + // "Client-Side" LOC keys use `%@` to mark the date to replace + .replace('%@', formattedDate) + ); + } + + relativeDate(date: Date | null | undefined): string | null { + if (isNothing(date)) { + return null; + } + + return date.toString(); + } + + formatDuration(_value: number, _unit: TimeUnit): string | null { + throw makeWebDoesNotImplementException('formatDuration'); + } +} diff --git a/src/jet/dependencies/make-dependencies.ts b/src/jet/dependencies/make-dependencies.ts new file mode 100644 index 0000000..f03c7ca --- /dev/null +++ b/src/jet/dependencies/make-dependencies.ts @@ -0,0 +1,45 @@ +import type { LoggerFactory as AppLoggerFactory } from '@amp/web-apps-logger'; + +import { Random } from '@amp/web-apps-common/src/jet/dependencies/random'; +import { Host } from '@amp/web-apps-common/src/jet/dependencies/host'; +import { WebBag } from './bag'; +import { WebClient } from './client'; +import { WebConsole } from './console'; +import { Locale } from './locale'; +import { WebLocalization } from './localization'; +import { makeProperties } from './properties'; +import { WebMetricsIdentifiers } from './metrics-identifiers'; +import { Net, type FeaturesCallbacks } from './net'; +import { WebStorage } from './storage'; +import { makeUnauthenticatedUser } from './user'; +import { SEO } from './seo'; + +export type Dependencies = ReturnType<typeof makeDependencies>; + +export function makeDependencies( + loggerFactory: AppLoggerFactory, + fetch: typeof window.fetch, + featuresCallbacks?: FeaturesCallbacks, +) { + const locale = new Locale(loggerFactory); + return { + bag: new WebBag(loggerFactory, locale), + client: new WebClient( + // TODO: set the right `BuildType` based on the environment where the app is running + 'production', + locale, + ), + console: new WebConsole(loggerFactory), + host: new Host(), + localization: new WebLocalization(locale, loggerFactory), + locale, + metricsIdentifiers: new WebMetricsIdentifiers(), + net: new Net(fetch, featuresCallbacks), + properties: makeProperties(), + random: new Random(), + seo: new SEO(locale), + storage: new WebStorage(), + user: makeUnauthenticatedUser(), + URL, + }; +} diff --git a/src/jet/dependencies/media-token-service.ts b/src/jet/dependencies/media-token-service.ts new file mode 100644 index 0000000..45cae9e --- /dev/null +++ b/src/jet/dependencies/media-token-service.ts @@ -0,0 +1,11 @@ +import { MEDIA_API_JWT } from '~/config/media-api'; + +export class WebMediaTokenService implements MediaTokenService { + refreshToken(): Promise<string> { + return Promise.resolve(MEDIA_API_JWT); + } + + resetToken(): void { + // No-op; every request uses the same token for the "web" platform + } +} diff --git a/src/jet/dependencies/metrics-identifiers.ts b/src/jet/dependencies/metrics-identifiers.ts new file mode 100644 index 0000000..e48c9d1 --- /dev/null +++ b/src/jet/dependencies/metrics-identifiers.ts @@ -0,0 +1,13 @@ +export class WebMetricsIdentifiers implements MetricsIdentifiers { + async getIdentifierForContext( + _metricsIdentifierKeyContext: MetricsIdentifierKeyContext, + ): Promise<string | undefined> { + return undefined; + } + + async getMetricsFieldsForContexts( + _metricsIdentifierKeyContexts: MetricsIdentifierKeyContext[], + ): Promise<JSONData | undefined> { + return undefined; + } +} diff --git a/src/jet/dependencies/net.ts b/src/jet/dependencies/net.ts new file mode 100644 index 0000000..dd7fdb9 --- /dev/null +++ b/src/jet/dependencies/net.ts @@ -0,0 +1,117 @@ +import type { Network, FetchRequest, FetchResponse } from '@jet/environment'; +import { fromEntries } from '@amp/web-apps-utils'; + +import { + shouldUseSearchJWT, + makeSearchJWTAuthorizationHeader, +} from '~/config/media-api'; + +const CORRELATION_KEY_HEADER = 'x-apple-jingle-correlation-key'; + +type FetchFunction = typeof window.fetch; + +// TODO: these URLs are also referenced in `bag` definition; we should have a single +// source-of-truth for these domains +const MEDIA_API_ORIGINS = [ + 'https://amp-api.apps.apple.com', + 'https://amp-api-edge.apps.apple.com', + 'https://amp-api-search-edge.apps.apple.com', +]; + +export interface FeaturesCallbacks { + getITFEValues(): string | undefined; +} + +export class Net implements Network { + private readonly underlyingFetch: FetchFunction; + private readonly getITFEValues: () => string | undefined = () => undefined; + + constructor( + underlyingFetch: FetchFunction, + featuresCallbacks?: FeaturesCallbacks, + ) { + this.underlyingFetch = underlyingFetch; + this.getITFEValues = + featuresCallbacks?.getITFEValues ?? this.getITFEValues; + } + + async fetch(request: FetchRequest): Promise<FetchResponse> { + const requestStartTime = getTimestampMs(); + const requestURL = new URL(request.url); + + request.headers = request.headers ?? {}; + + if (MEDIA_API_ORIGINS.includes(requestURL.origin)) { + // Need to fake this for the server due to Kong origin checks. + // Has no effect clientside. + request.headers['origin'] = 'https://apps.apple.com'; + + const itfe = this.getITFEValues?.(); + + if (itfe) { + // Add ITFE value as query string when set + requestURL.searchParams.set('itfe', itfe); + } + } + + // The App Store Client will have already injected the JWT from the + // `media-token-service` ObjectGraph dependency into the headers. However, + // some endpoints need a different JWT. Here we determine if that's the + // case and override the existing JWT if necessary. + if (shouldUseSearchJWT(requestURL)) { + request.headers = { + ...request.headers, + ...makeSearchJWTAuthorizationHeader(), + }; + } + + // TODO: rdar://78158575: timeout + const response = await this.underlyingFetch(requestURL.toString(), { + ...request, + cache: request.cache ?? undefined, + credentials: 'include', + headers: request.headers ?? undefined, + method: request.method ?? undefined, + }); + + const responseStartTime = getTimestampMs(); + + const { ok, redirected, status, statusText, url } = response; + + const headers = fromEntries(response.headers); + const body = await response.text(); + + const responseEndTime = getTimestampMs(); + + return { + ok, + headers, + redirected, + status, + statusText, + url, + body, + // TODO: rdar://78158575: redirect: 'manual' to get all metrics? + metrics: [ + { + clientCorrelationKey: response.headers.get( + CORRELATION_KEY_HEADER, + ), + pageURL: response.url, + requestStartTime, + responseStartTime, + responseEndTime, + // TODO: rdar://78158575: responseWasCached? + // TODO: rdar://78158575: parseStartTime/parseEndTime + }, + ], + }; + } +} + +/** + * Returns the current UTC timestamp in milliseconds. + */ +function getTimestampMs(): number { + return Date.now(); +} diff --git a/src/jet/dependencies/object-graph.ts b/src/jet/dependencies/object-graph.ts new file mode 100644 index 0000000..40ad0a9 --- /dev/null +++ b/src/jet/dependencies/object-graph.ts @@ -0,0 +1,59 @@ +import { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { ObjectGraphType } from '@jet-app/app-store/gameservicesui/src/foundation/object-graph-types'; + +import type { Dependencies } from './make-dependencies'; +import { WebFeatureFlags } from './feature-flags'; +import { WebMediaTokenService } from './media-token-service'; + +export { ObjectGraphType }; + +class AppStoreWebObjectGraph extends AppStoreObjectGraph { + /** + * Configures the ObjectGraph from our `Dependencies` definition + * + * @param dependencies + * @returns + */ + configureWithDependencies(dependencies: Dependencies) { + const { + bag, + client, + console, + host, + locale, + localization, + metricsIdentifiers, + net, + properties, + random, + seo, + storage, + user, + } = dependencies; + + return this.addingClient(client) + .addingNetwork(net) + .addingHost(host) + .addingBag(bag) + .addingLoc(localization) + .addingMediaToken(new WebMediaTokenService()) + .addingConsole(console) + .addingAppleSilicon(undefined) + .addingProperties(properties) + .addingLocale(locale) + .addingUser(user) + .addingFeatureFlags(new WebFeatureFlags()) + .addingMetricsIdentifiers(metricsIdentifiers) + .addingSEO(seo) + .addingStorage(storage) + .addingRandom(random); + } +} + +export function makeObjectGraph( + dependencies: Dependencies, +): AppStoreObjectGraph { + const objectGraph = new AppStoreWebObjectGraph('app-store'); + + return objectGraph.configureWithDependencies(dependencies); +} diff --git a/src/jet/dependencies/properties.ts b/src/jet/dependencies/properties.ts new file mode 100644 index 0000000..8956d7f --- /dev/null +++ b/src/jet/dependencies/properties.ts @@ -0,0 +1,5 @@ +export function makeProperties(): PackageProperties { + return { + clientFeatures: {}, + }; +} diff --git a/src/jet/dependencies/seo.ts b/src/jet/dependencies/seo.ts new file mode 100644 index 0000000..0938afa --- /dev/null +++ b/src/jet/dependencies/seo.ts @@ -0,0 +1,254 @@ +import type { Opt } from '@jet/environment/types/optional'; +import type { + ArcadeSeeAllGamesPage, + ArticlePage, + ChartsHubPage, + GenericPage, + ReviewsPage, + SearchLandingPage, + SearchResultsPage, + SeeAllPage, + ShelfBasedProductPage, + TodayPage, + TopChartsPage, +} from '@jet-app/app-store/api/models'; +import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; +import type { SEO as SEODependency } from '@jet-app/app-store/foundation/dependencies/seo'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import type { DataContainer } from '@jet-app/app-store/foundation/media/data-structure'; + +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + +import type { Locale } from './locale'; + +import { seoDataForAnyPage, updateCanonicalURL } from '~/utils/seo/common'; +import { seoDataForArticlePage } from '~/utils/seo/article-page'; +import { seoDataForChartsPage } from '~/utils/seo/charts-page'; +import { seoDataForChartsHubPage } from '~/utils/seo/charts-hub-page'; +import { seoDataForDeveloperPage } from '~/utils/seo/developer-page'; +import { seoDataForProductPage } from '~/utils/seo/product-page'; +import { seoDataForAppEventDetailPage } from '~/utils/seo/app-event-detail-page'; +import { seoDataForReviewsPage } from '~/utils/seo/reviews-page'; +import { seoDataForSearchLandingPage } from '~/utils/seo/search-landing-page'; +import { seoDataForSearchResultsPage } from '~/utils/seo/search-results-page'; +import { seoDataForEditorialShelfCollectionPage } from '~/utils/seo/editorial-shelf-collection-page'; +import { seoDataForArcadeSeeAllPage } from '~/utils/seo/arcade-see-all-page'; +import { seoDataForSeeAllPage } from '~/utils/seo/see-all-page'; + +export class SEO implements SEODependency { + private locale: Locale; + + constructor(locale: Locale) { + this.locale = locale; + } + + private get i18n() { + if (this.locale.i18n) { + return this.locale.i18n; + } + + throw new Error('`i18n` not yet configured '); + } + + private getSEODataForGenericPage(page: GenericPage): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + }; + } + + updateCanonicalURL(page: WebRenderablePage, canonicalURL: string): void { + updateCanonicalURL(page, canonicalURL); + } + + /// MARK: Page SEO Data Hooks + + getSEODataForAppEventPage( + objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForAppEventDetailPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForArcadeSeeAllPage( + _objectGraph: AppStoreObjectGraph, + page: ArcadeSeeAllGamesPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForArcadeSeeAllPage(page, this.i18n), + }; + } + + getSEODataForArticlePage( + objectGraph: AppStoreObjectGraph, + page: ArticlePage, + response: Opt<DataContainer>, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForArticlePage( + objectGraph, + this.i18n, + page, + response, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForBundlePage( + objectGraph: AppStoreObjectGraph, + page: ShelfBasedProductPage, + data: Opt<DataContainer>, + ): Opt<SeoData> { + return this.getSEODataForProductPage(objectGraph, page, data); + } + + getSEODataForChartsPage( + objectGraph: AppStoreObjectGraph, + page: TopChartsPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForChartsPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForChartsHubPage( + objectGraph: AppStoreObjectGraph, + page: ChartsHubPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForChartsHubPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForDeveloperPage( + objectGraph: AppStoreObjectGraph, + page: GenericPage, + response: Opt<DataContainer>, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForDeveloperPage(objectGraph, response, this.i18n), + }; + } + + getSEODataForEditorialPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return this.getSEODataForGenericPage(page); + } + + getSEODataForEditorialShelfCollectionPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForEditorialShelfCollectionPage(page, this.i18n), + }; + } + + getSEODataForGroupingPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return this.getSEODataForGenericPage(page); + } + + getSEODataForProductPage( + objectGraph: AppStoreObjectGraph, + page: ShelfBasedProductPage, + data: Opt<DataContainer>, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForProductPage( + objectGraph, + page, + data, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForReviewsPage( + objectGraph: AppStoreObjectGraph, + page: ReviewsPage, + productPage: ShelfBasedProductPage, + ): Opt<SeoData> { + return { + ...this.getSEODataForGenericPage(page), + ...seoDataForReviewsPage(this.i18n, page, productPage, objectGraph), + }; + } + + getSEODataForRoomPage( + _objectGraph: AppStoreObjectGraph, + page: GenericPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + }; + } + + getSEODataForSearchLandingPage( + _objectGraph: AppStoreObjectGraph, + page: SearchLandingPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSearchLandingPage(page, this.i18n), + }; + } + + getSEODataForSearchResultsPage( + objectGraph: AppStoreObjectGraph, + page: SearchResultsPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSearchResultsPage( + page, + this.i18n, + objectGraph.locale.activeLanguage, + ), + }; + } + + getSEODataForTodayPage( + _objectGraph: AppStoreObjectGraph, + page: TodayPage, + ): Opt<SeoData> { + return seoDataForAnyPage(page, this.i18n); + } + + getSEODataForSeeAllPage( + _objectGraph: AppStoreObjectGraph, + page: SeeAllPage, + ): Opt<SeoData> { + return { + ...seoDataForAnyPage(page, this.i18n), + ...seoDataForSeeAllPage(page, this.i18n), + }; + } +} diff --git a/src/jet/dependencies/storage.ts b/src/jet/dependencies/storage.ts new file mode 100644 index 0000000..fe1da2c --- /dev/null +++ b/src/jet/dependencies/storage.ts @@ -0,0 +1,44 @@ +/** + * `AppStoreKit` `Storage` implementation for the "web" client + * + * Note: The `AppStoreKit` `Storage` interface is declared as a global, which has the (presumably + * accidental) side-effect of implicitly being merged with the DOM library's own `Storage` interface + * (like `localStorage`), since interfaces declared in the same scope are merged together by TypeScript. + * There's no way to tell TypeScript that we only care about the `AppStoreKit` part of it, so + * satifying TypeScript here means that we need to implement both interfaces. + */ +export class WebStorage extends Map<string, string> implements Storage { + /* == "DOM" `Storage` Interface == */ + + get length() { + return this.size; + } + + getItem(key: string): string | null { + return this.get(key) ?? null; + } + + key(_index: number): string | null { + throw new Error('Method not implemented.'); + } + + removeItem(key: string): void { + this.delete(key); + } + + setItem(key: string, value: string): void { + this.set(key, value); + } + + /* == AppStoreKit `Storage` Interface == */ + + storeString(aString: string, key: string): void { + this.set(key, aString); + } + + retrieveString(key: string): string { + // Fallback value designed based on how the ObjectGraph `StorageWrapper` handles that specific value + // https://github.pie.apple.com/app-store/ios-appstore-app/blob/1761d575b8dc3d7a63e7e36f3320cf9245be9f37/src/foundation/wrappers/storage.ts#L13 + return this.get(key) ?? '<null>'; + } +} diff --git a/src/jet/dependencies/user.ts b/src/jet/dependencies/user.ts new file mode 100644 index 0000000..2dad212 --- /dev/null +++ b/src/jet/dependencies/user.ts @@ -0,0 +1,30 @@ +/** + * Create an "unauthenticated" {@linkcode User} representation + * + * The property values below match the way that `AppStoreKit` will define the `user` + * when the session is not authenticated. + */ +export function makeUnauthenticatedUser(): User { + return { + accountIdentifier: undefined, + dsid: undefined, + firstName: undefined, + // Note: this property is `true` for the native apps but `false` makes + // more sense in the context of the "web" client + isFitnessAppInstallationAllowed: false, + isManagedAppleID: false, + isOnDevicePersonalizationEnabled: false, + isUnderThirteen: false, + katanaId: undefined, + lastName: undefined, + treatmentGroupIdOverride: undefined, + userAgeIfAvailable: undefined, + + onDevicePersonalizationDataContainerForAppIds(appIds) { + return { + personalizationData: {}, + metricsData: {}, + }; + }, + }; +} diff --git a/src/jet/intents/charts-page-redirect-intent-controller.ts b/src/jet/intents/charts-page-redirect-intent-controller.ts new file mode 100644 index 0000000..06d41ce --- /dev/null +++ b/src/jet/intents/charts-page-redirect-intent-controller.ts @@ -0,0 +1,68 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; +import { makeChartsPageURL } from '@jet-app/app-store/common/charts/charts-page-url'; +import { makeChartsPageIntent } from '@jet-app/app-store/api/intents/charts-page-intent'; +import { GenericPage } from '@jet-app/app-store/api/models'; +import { isPreviewPlatform } from '@jet-app/app-store/api/models/preview-platform'; +import { notFoundError } from '@jet-app/app-store/foundation/media/network'; + +const makeIntent = (opts) => ({ + ...opts, + $kind: 'ChartsPageRedirect', +}); + +// This will catch URLs like `/charts/iphone` +const { routes: routesWithoutGenreId } = generateRoutes( + makeIntent, + '/charts/{platform}', +); + +// This will catch URLs like `/charts/iphone/utilities-apps/6002` +const { routes: routesWithGenreId } = generateRoutes( + makeIntent, + '/charts/{platform}/{slug}/{genreId}', +); + +function chartsPageRedirectRoutes(objectGraph: AppStoreObjectGraph) { + return [ + ...routesWithoutGenreId(objectGraph), + ...routesWithGenreId(objectGraph), + ]; +} + +export const ChartsPageRedirectIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'ChartsPageRedirect', + + routes: chartsPageRedirectRoutes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new GenericPage([]); + const chartPageIntent = makeChartsPageIntent(intent); + + if (!isPreviewPlatform(intent.platform)) { + throw notFoundError(); + } + + // Setting the `canonicalUrl` on the page to normal Charts Page URL (e.g. /{platform}/charts) + // will trigger a 301 redirect to the that page. + page.canonicalURL = makeChartsPageURL( + objectGraph, + chartPageIntent, + ); + + injectWebNavigation(objectGraph, page, intent.platform); + + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/error-page-intent-controller.ts b/src/jet/intents/error-page-intent-controller.ts new file mode 100644 index 0000000..59ac1fd --- /dev/null +++ b/src/jet/intents/error-page-intent-controller.ts @@ -0,0 +1,52 @@ +import type { Intent, IntentController } from '@jet/environment/dispatching'; +import type { Opt } from '@jet/environment/types/optional'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { ErrorPage } from '~/jet/models/error-page'; +import type { Page } from '~/jet/models/page'; +import { getRejectedIntent } from '~/jet/utils/error-metadata'; +import { isWithPlatform } from '~/jet/utils/with-platform'; + +interface ErrorPageIntent extends Intent<Page> { + $kind: 'ErrorPageIntent'; + error: Opt<Error>; +} + +export function makeErrorPageIntent( + options: Omit<ErrorPageIntent, '$kind'>, +): ErrorPageIntent { + return { + ...options, + $kind: 'ErrorPageIntent', + }; +} + +export const ErrorPageIntentController: IntentController<ErrorPageIntent> = { + $intentKind: 'ErrorPageIntent', + + async perform( + intent, + objectGraphWithoutActiveIntent: AppStoreObjectGraph, + ): Promise<Page> { + const { error } = intent; + const rejectedIntent = error ? getRejectedIntent(error) : null; + const platform = + (rejectedIntent && isWithPlatform(rejectedIntent) + ? rejectedIntent.platform + : null) ?? 'iphone'; + + return await withActiveIntent( + objectGraphWithoutActiveIntent, + { ...intent, platform }, + async (objectGraph) => { + const page = new ErrorPage({ error: intent.error }); + + injectWebNavigation(objectGraph, page, platform); + + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts new file mode 100644 index 0000000..046914b --- /dev/null +++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-controller.ts @@ -0,0 +1,18 @@ +import type { IntentController } from '@jet/environment/dispatching/base/intent-controller'; +import type { LintedMetricsEvent } from '@jet/environment/types/metrics'; + +import { + type LintMetricsEventIntent, + LintMetricsEventIntentKind, +} from './lint-metrics-event-intent'; + +export const LintMetricsEventIntentController: IntentController<LintMetricsEventIntent> = + { + $intentKind: LintMetricsEventIntentKind.Name, + + async perform( + intent: LintMetricsEventIntent, + ): Promise<LintedMetricsEvent> { + return { fields: intent.fields }; + }, + }; diff --git a/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts new file mode 100644 index 0000000..a2a085e --- /dev/null +++ b/src/jet/intents/lint-metrics-event/lint-metrics-event-intent.ts @@ -0,0 +1,23 @@ +import type { Intent } from '@jet/environment/dispatching'; +import type { + LintedMetricsEvent, + MetricsFields, +} from '@jet/environment/types/metrics'; + +export const enum LintMetricsEventIntentKind { + Name = 'LintMetricsEventIntent', +} + +export interface LintMetricsEventIntent extends Intent<LintedMetricsEvent> { + $kind: LintMetricsEventIntentKind.Name; + fields: MetricsFields; +} + +export function makeLintMetricsEventIntent( + options: Omit<LintMetricsEventIntent, '$kind'>, +): LintMetricsEventIntent { + return { + ...options, + $kind: LintMetricsEventIntentKind.Name, + }; +} diff --git a/src/jet/intents/route-url/route-url-controller.ts b/src/jet/intents/route-url/route-url-controller.ts new file mode 100644 index 0000000..8c8fdb6 --- /dev/null +++ b/src/jet/intents/route-url/route-url-controller.ts @@ -0,0 +1,28 @@ +import { isSome } from '@jet/environment/types/optional'; +import type { IntentController } from '@jet/environment/dispatching'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { isRoutableIntent } from '@jet-app/app-store/api/intents/routable-intent'; + +import type { RouteUrlIntent } from '~/jet/intents'; +import { makeFlowAction } from '~/jet/models'; + +export const RouteUrlIntentController: IntentController<RouteUrlIntent> = { + $intentKind: 'RouteUrlIntent', + + async perform(intent: RouteUrlIntent, objectGraph: AppStoreObjectGraph) { + const targetIntent = objectGraph.router.intentFor(intent.url); + + if (isSome(targetIntent) && isRoutableIntent(targetIntent)) { + return { + // intent needed for SSR + intent: targetIntent, + // only ever used by client; only clients have actions + action: makeFlowAction(targetIntent), + storefront: targetIntent.storefront, + language: targetIntent.language, + }; + } + + return null; + }, +}; diff --git a/src/jet/intents/route-url/route-url-intent.ts b/src/jet/intents/route-url/route-url-intent.ts new file mode 100644 index 0000000..841bd25 --- /dev/null +++ b/src/jet/intents/route-url/route-url-intent.ts @@ -0,0 +1,48 @@ +import type { Optional } from '@jet/environment/types/optional'; +import type { Intent } from '@jet/environment/dispatching'; +import type { FlowAction } from '@jet-app/app-store/api/models'; + +import type { + NormalizedStorefront, + NormalizedLanguage, +} from '@jet-app/app-store/api/locale'; + +/** + * A response from the router given an incoming (deeplink) URL. + */ +export interface RouterResponse { + /** + * The intent to dispatch to get the view model for this URL. + */ + intent: Intent<unknown>; + + /** + * action to navigate to a new page of the app. + */ + action: FlowAction; + + storefront: NormalizedStorefront; + + language: NormalizedLanguage; +} + +export interface RouteUrlIntent extends Intent<Optional<RouterResponse>> { + $kind: 'RouteUrlIntent'; + + /** + * The URL to route (ex. "https://podcasts.apple.com/us/show/serial/id123"). + */ + url: string; +} + +export function isRouteUrlIntent( + intent: Intent<unknown>, +): intent is RouteUrlIntent { + return intent.$kind === 'RouteUrlIntent'; +} + +export function makeRouteUrlIntent( + options: Omit<RouteUrlIntent, '$kind'>, +): RouteUrlIntent { + return { $kind: 'RouteUrlIntent', ...options }; +} diff --git a/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts new file mode 100644 index 0000000..a1b049c --- /dev/null +++ b/src/jet/intents/static-message-pages/carrier-page-intent-controller.ts @@ -0,0 +1,41 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'CarrierPageIntent', + }), + '/carrier', +); + +export const CarrierPageIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'CarrierPageIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.Carrier.Title', + contentType: 'carrier', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts new file mode 100644 index 0000000..ba2babd --- /dev/null +++ b/src/jet/intents/static-message-pages/contingent-price-page-intent-controller.ts @@ -0,0 +1,49 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'ContingentPriceIntent', + }), + '/contingent-price/{offerId}', + [], + { + extraRules: [ + { + regex: [/(?:\/[a-z]{2})?\/contingent-price/], + }, + ], + }, +); + +export const ContingentPricingIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'ContingentPriceIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.WinBack.Title', + contentType: 'contingent-price', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts new file mode 100644 index 0000000..caa02f4 --- /dev/null +++ b/src/jet/intents/static-message-pages/invoice-page-intent-controller.ts @@ -0,0 +1,41 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'InvoicePageIntent', + }), + '/invoice', +); + +export const InvoicePageIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'InvoicePageIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.Invoice.Title', + contentType: 'invoice', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts new file mode 100644 index 0000000..2b78ba0 --- /dev/null +++ b/src/jet/intents/static-message-pages/win-back-page-intent-controller.ts @@ -0,0 +1,49 @@ +import type { IntentController } from '@jet/environment/dispatching'; +import type { RouteProvider } from '@jet/environment/routing'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { withActiveIntent } from '@jet-app/app-store/foundation/dependencies/active-intent'; +import { generateRoutes } from '@jet-app/app-store/common/util/generate-routes'; +import { injectWebNavigation } from '@jet-app/app-store/common/web-navigation/inject-web-navigation'; + +import { StaticMessagePage } from '~/jet/models/static-message-page'; + +const { routes, makeCanonicalUrl } = generateRoutes( + (opts) => ({ + ...opts, + $kind: 'WinBackPageIntent', + }), + '/win-back/{offerId}', + [], + { + extraRules: [ + { + regex: [/(?:\/[a-z]{2})?\/win-back/], + }, + ], + }, +); + +export const WinBackPageIntentController: IntentController<any> & + RouteProvider = { + $intentKind: 'WinBackPageIntent', + + routes, + + async perform(intent, objectGraphWithoutActiveIntent: AppStoreObjectGraph) { + return await withActiveIntent( + objectGraphWithoutActiveIntent, + intent, + async (objectGraph) => { + const page = new StaticMessagePage({ + titleLocKey: 'ASE.Web.AppStore.WinBack.Title', + contentType: 'win-back', + }); + + page.canonicalURL = makeCanonicalUrl(objectGraph, intent); + + injectWebNavigation(objectGraph, page, intent.platform); + return page; + }, + ); + }, +}; diff --git a/src/jet/jet.ts b/src/jet/jet.ts new file mode 100644 index 0000000..75b0afc --- /dev/null +++ b/src/jet/jet.ts @@ -0,0 +1,320 @@ +import type I18N from '@amp/web-apps-localization'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; + +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import type { AppStoreRuntime } from '@jet-app/app-store/foundation/runtime/runtime'; +import type { + NormalizedStorefront, + NormalizedLanguage, +} from '@jet-app/app-store/api/locale'; + +import type { + LintedMetricsEvent, + MetricsFields, + PageMetrics, +} from '@jet/environment/types/metrics'; +import { type Opt } from '@jet/environment/types/optional'; +import type { Intent, IntentReturnType } from '@jet/environment/dispatching'; +import { + type ActionImplementation, + ActionDispatcher, + type ActionOutcome, + type MetricsBehavior, +} from '@jet/engine'; + +import { Metrics } from '@amp/web-apps-metrics-8'; +import { makeMetricsSettings } from '~/jet/metrics/settings'; +import { makeMetricsProviders } from '~/jet/metrics/providers'; +import { config as metricsConfig } from '~/config/metrics'; + +import { bootstrap } from '~/jet/bootstrap'; +import { makeDependencies } from '~/jet/dependencies'; +import type { Locale } from '~/jet/dependencies/locale'; +import type { WebLocalization } from '~/jet/dependencies/localization'; +import { + type RouterResponse, + type RouteUrlIntent, + makeRouteUrlIntent, + makeLintMetricsEventIntent, +} from '~/jet/intents'; +import type { Page, ActionModel } from '~/jet/models'; +import { PrefetchedIntents } from '@amp/web-apps-common/src/jet/prefetched-intents'; +import { CONTEXT_NAME } from '~/jet/svelte'; +import type { FeaturesCallbacks } from './dependencies/net'; + +/** + * The entry point for interacting with the Jet shared business logic. + */ +export class Jet { + private readonly log: Logger; + private readonly runtime: AppStoreRuntime; + private readonly actionDispatcher: ActionDispatcher; + private readonly metrics: Metrics; + private readonly locale: Locale; + + /** + * Intents (and their resolved data) that have yet to be dispatched that + * were recently dispatched. These are consulted before dispatching + * intents. If a prefetched intent exists for an ongoing dispatch, it will + * be used as the return value instead of actually dispatching. + * + * This can be used, for example, for intents that are dispatched during + * SSR. The server can serialize the intents it dispatches and then the + * client can populate this, to avoid re-dispatching the intents. + */ + private readonly prefetchedIntents: PrefetchedIntents; + + /** + * A set of the action types that already have registered implementations to catch + * double registers. + */ + private readonly wiredActions: Set<string>; + + readonly objectGraph: AppStoreObjectGraph; + readonly localization: WebLocalization; + + static load({ + loggerFactory, + context, + fetch, + prefetchedIntents = PrefetchedIntents.empty(), + featuresCallbacks, + }: { + loggerFactory: LoggerFactory; + context: Map<string, unknown>; + fetch: typeof window.fetch; + prefetchedIntents?: PrefetchedIntents; + featuresCallbacks?: FeaturesCallbacks; + }): Jet { + const dependencies = makeDependencies( + loggerFactory, + fetch, + featuresCallbacks, + ); + const { runtime, objectGraph } = bootstrap(dependencies); + let jet: Jet; + + const processEvent = async ( + fields: MetricsFields, + ): Promise<LintedMetricsEvent> => { + const intent = makeLintMetricsEventIntent({ fields }); + return jet.dispatch(intent); + }; + const metrics = Metrics.load( + loggerFactory, + context, + processEvent, + metricsConfig, + makeMetricsProviders(objectGraph), + makeMetricsSettings(context), + ); + const actionDispatcher = new ActionDispatcher( + // `@amp/web-apps-metrics` depends on a different version of `@jet/engine` with a different + // type definition for `MetricsPipeline` + // @ts-expect-error + metrics.metricsPipeline, + ); + + jet = new Jet( + loggerFactory.loggerFor('Jet'), + runtime, + objectGraph, + actionDispatcher, + metrics, + dependencies.locale, + prefetchedIntents, + dependencies.localization, + ); + + context.set(CONTEXT_NAME, jet); + + return jet; + } + + private constructor( + log: Logger, + runtime: AppStoreRuntime, + objectGraph: AppStoreObjectGraph, + actionDispatcher: ActionDispatcher, + metrics: Metrics, + locale: Locale, + prefetchedIntents: PrefetchedIntents, + localization: WebLocalization, + ) { + this.log = log; + this.runtime = runtime; + this.objectGraph = objectGraph; + this.actionDispatcher = actionDispatcher; + + this.metrics = metrics; + this.locale = locale; + this.localization = localization; + + this.prefetchedIntents = prefetchedIntents; + + this.wiredActions = new Set(); + } + + async didEnterPage(page: Page | null): Promise<void> { + // This is a very temporary hacky fix to move the `platformContext` value from + // `pageRenderFields` to `pageFields`, which will eventually happen in the Jet + // business logic. + const pageWithMetrics = { ...page }; + if (pageWithMetrics.pageMetrics?.pageFields) { + pageWithMetrics.pageMetrics.pageFields.platformContext = + pageWithMetrics.pageMetrics.pageRenderFields?.platformContext; + } + + // @ts-expect-error - pageMetrics property not required at runtime + await this.metrics.didEnterPage(page); + } + + get pageMetrics(): Opt<PageMetrics> { + return this.metrics.currentPageMetrics?.pageMetrics; + } + + /** + * Dispatch a Jet intent, returning its output. + * + * @param intent The intent to dispatch + * @return output The value returned by the intent's controller + */ + async dispatch<I extends Intent<unknown>>( + intent: I, + ): Promise<IntentReturnType<I>> { + const data = this.prefetchedIntents.get(intent); + if (data) { + this.log.info( + 're-using prefetched intent response for:', + intent, + 'data:', + data, + ); + return data; + } + + // TODO: rdar://73165545 (Error Handling Across App) + return this.runtime.dispatch(intent); + } + + /** + * Perform a Jet action, returning the outcome. + * + * @param action The action to perform + * @param metricsBehavior Indicates how to handle metrics for this action + * @return outcome Either 'performed' or 'unsupported' + */ + async perform( + action: ActionModel, + metricsBehavior?: MetricsBehavior, + ): Promise<ActionOutcome> { + if (!metricsBehavior) { + if (this.pageMetrics) { + metricsBehavior = { + behavior: 'fromAction', + context: this.pageMetrics || {}, + }; + } else { + this.log.warn( + 'No pageMetrics found for jet.perform action:', + action, + ); + metricsBehavior = { behavior: 'notProcessed' }; + } + } + // TODO: rdar://73165545 (Error Handling Across App): handle throw + const outcome = await this.actionDispatcher.perform( + action, + metricsBehavior, + ); + + if (outcome === 'unsupported') { + this.log.error( + 'unable to perform action:', + action, + metricsBehavior, + ); + } + + return outcome; + } + + /** + * Register an implementation to handle a Jet action. + * + * @param kind The type of the action + * @param implementation The code to run when that action is performed + */ + onAction<A extends ActionModel>( + kind: string, + implementation: ActionImplementation<A>, + ): void { + if (this.wiredActions.has(kind)) { + throw new Error( + `onAction called twice with the same action type: ${kind}`, + ); + } + + this.actionDispatcher.register(kind, implementation); + this.wiredActions.add(kind); + } + + /** + * Route a URL using Jet, returning the routing if the URL could be routed. + * + * @param url The URL to route + * @return routing The routing of the URL or null if unrouteable + */ + async routeUrl(url: string): Promise<RouterResponse | null> { + // TODO: rdar://73165545 (Error Handling Across App): what about 404s? + const routerResponse = await this.dispatch<RouteUrlIntent>( + makeRouteUrlIntent({ url }), + ); + + if (routerResponse && routerResponse.action) { + return routerResponse; + } + + this.log.warn( + 'url did not resolve to a flow action with a discernable intent:', + url, + routerResponse, + ); + + return null; + } + + /** + * Propagates the routing-derrived localization information through the Jet app + * + * The {@link Locale} instance that is configured here is referenced by + * the rest of our Jet dependencies in order to lazily retreive the locale + * information. + * + * @param localizer + * @param storefront + * @param language + */ + setLocale( + localizer: I18N, + storefront: NormalizedStorefront, + language: NormalizedLanguage, + ): void { + this.locale.i18n = localizer; + this.locale.setActiveLocale({ storefront, language }); + } + + recordCustomMetricsEvent(fields?: Opt<MetricsFields>) { + this.metrics.recordCustomEvent(fields); + } + + enableFunnelKit(): void { + this.metrics.enableFunnelKit(); + } + + disableFunnelKit(): void { + this.metrics.disableFunnelKit(); + } + + // TODO: rdar://75011660 (Bridge Jet to MetricsKit and PerfKit for reporting) +} diff --git a/src/jet/metrics/providers/StorefrontFieldsProvider.ts b/src/jet/metrics/providers/StorefrontFieldsProvider.ts new file mode 100644 index 0000000..f4c5448 --- /dev/null +++ b/src/jet/metrics/providers/StorefrontFieldsProvider.ts @@ -0,0 +1,19 @@ +import type { + MetricsFieldsBuilder, + MetricsFieldsContext, + MetricsFieldsProvider, +} from '@jet/engine'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { getLocale } from '@jet-app/app-store/common/locale'; + +export class StorefrontFieldsProvider implements MetricsFieldsProvider { + constructor(private readonly objectGraph: AppStoreObjectGraph) {} + + addMetricsFields( + builder: MetricsFieldsBuilder, + _context: MetricsFieldsContext, + ) { + const { storefront } = getLocale(this.objectGraph); + builder.addValue(storefront, 'storeFrontCountryCode'); + } +} diff --git a/src/jet/metrics/providers/index.ts b/src/jet/metrics/providers/index.ts new file mode 100644 index 0000000..98f3780 --- /dev/null +++ b/src/jet/metrics/providers/index.ts @@ -0,0 +1,15 @@ +import type { MetricsProvider } from '@amp/web-apps-metrics-8'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; + +import { StorefrontFieldsProvider } from './StorefrontFieldsProvider'; + +export function makeMetricsProviders( + objectGraph: AppStoreObjectGraph, +): MetricsProvider[] { + return [ + { + provider: new StorefrontFieldsProvider(objectGraph), + request: 'storeFrontCountryCode', + }, + ]; +} diff --git a/src/jet/metrics/settings.ts b/src/jet/metrics/settings.ts new file mode 100644 index 0000000..c0c5075 --- /dev/null +++ b/src/jet/metrics/settings.ts @@ -0,0 +1,20 @@ +import type { MetricSettings } from '@amp/web-apps-metrics-8'; + +/** + * Generates a metric settings for Metrics class. + * + * @param context - app context map + * @returns MetricSettings + */ +export function makeMetricsSettings( + context: Map<string, unknown>, +): MetricSettings { + return { + shouldEnableFunnelKit: function (): boolean { + return false; + }, + getConsumerId: async function (): Promise<string> { + return null; + }, + }; +} diff --git a/src/jet/models/error-page.ts b/src/jet/models/error-page.ts new file mode 100644 index 0000000..80bcdf5 --- /dev/null +++ b/src/jet/models/error-page.ts @@ -0,0 +1,15 @@ +import { GenericPage } from '@jet-app/app-store/api/models'; +import type { Opt } from '@jet/environment'; + +export class ErrorPage extends GenericPage { + constructor({ error }: { error: Opt<Error> }) { + super([]); + this.error = error; + } + + // Used in our type guards to narrow a `Page` down to a `ErrorPage` + pageType: string = 'errorPage'; + + // The browser `Error`, used to determine which message to display to the user + error: Opt<Error>; +} diff --git a/src/jet/models/external-action.ts b/src/jet/models/external-action.ts new file mode 100644 index 0000000..25dbd14 --- /dev/null +++ b/src/jet/models/external-action.ts @@ -0,0 +1,7 @@ +import type { Action, ExternalUrlAction } from '@jet-app/app-store/api/models'; + +export function isExternalUrlAction( + action: Action, +): action is ExternalUrlAction { + return action.$kind === 'ExternalUrlAction'; +} diff --git a/src/jet/models/flow-action.ts b/src/jet/models/flow-action.ts new file mode 100644 index 0000000..d5edb40 --- /dev/null +++ b/src/jet/models/flow-action.ts @@ -0,0 +1,28 @@ +import type { Intent } from '@jet/environment/dispatching'; +import { FlowAction } from '@jet-app/app-store/api/models'; + +export const FLOW_ACTION_KIND: FlowAction['$kind'] = 'flowAction'; + +/** + * Creates a FlowAction For a given destination. + * + * Note: this is only here temporarily as a convenience for the "web" client, to be used + * while the upstream `FlowAction` is represented as a class that needs to be constructed, + * so those details are abstracted away from our codebase. Once `FlowAction` has been + * migrated to a POJO, there should be a factory-function provided that we should leverage + * instead + * + * @param destination Destination of the `FlowAction` + */ +export function makeFlowAction(destination: Intent<unknown>): FlowAction { + const action = new FlowAction( + // This data is only used by the Jet app's `PageRouter` architecture, which is not + // relevant for us. We should safely be able to pass an arbitrary value here. + 'page', + ); + + // The important part for the "web" client router: setting the `destination` + action.destination = destination; + + return action; +} diff --git a/src/jet/models/page.ts b/src/jet/models/page.ts new file mode 100644 index 0000000..a05e59f --- /dev/null +++ b/src/jet/models/page.ts @@ -0,0 +1,177 @@ +import type { + ArticlePage, + ChartsHubPage, + GenericPage, + SearchLandingPage, + SearchResultsPage, + ShelfBasedProductPage, + TopChartsPage, + TodayPage, + SeeAllPage, +} from '@jet-app/app-store/api/models'; +import { StaticMessagePage } from '~/jet/models/static-message-page'; +import { isObject } from '~/utils/types'; +import { ErrorPage } from './error-page'; +import type { WebRenderablePage } from 'node_modules/@jet-app/app-store/src/api/models/web-renderable-page'; + +/** + * The union of every type of page that the App Store Onyx app can render + */ +export type Page = ( + | ArticlePage + | ChartsHubPage + | GenericPage + | SearchLandingPage + | SearchResultsPage + | ShelfBasedProductPage + | StaticMessagePage + | TopChartsPage + | TodayPage + | ErrorPage +) & + // TS needs to be told this explicitly, even though all the above implement this + WebRenderablePage; + +/** + * Detects if {@linkcode page} is actually an {@linkcode AppEventDetailPage} + */ +export function isAppEventDetailPage(page: Page): page is GenericPage { + return ( + 'shelves' in page && + page.shelves.some(({ contentType }) => contentType === 'appEventDetail') + ); +} + +/** + * Detects if {@linkcode page} is actually an {@linkcode ArticlePage} + */ +export function isArticlePage(page: Page): page is ArticlePage { + return 'card' in page && 'shelves' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode ChartsHubPage} + */ +export function isChartsHubPage(page: Page): page is ChartsHubPage { + return 'charts' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode GenericPage} + */ +export function isGenericPage(page: Page): page is GenericPage { + return 'shelves' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode ShelfBasedProductPage} + */ +export function isShelfBasedProductPage( + page: Page, +): page is ShelfBasedProductPage { + return 'shelfMapping' in page && !('seeAllType' in page); +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode SeeAllPage} + */ +export function isSeeAllPage(page: Page): page is SeeAllPage { + return 'seeAllType' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode SearchLandingPage} + */ +export function isSearchLandingPage(page: Page): page is SearchLandingPage { + return 'adIncidents' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode SearchResultsPage} + */ +export function isSearchResultsPage(page: Page): page is SearchResultsPage { + return 'searchClearAction' in page || 'searchCancelAction' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode TopChartsPage} + */ +export function isTopChartsPage(page: Page): page is TopChartsPage { + return 'segments' in page && 'categories' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode TodayPage} + */ +export function isTodayPage(page: Page): page is TodayPage { + return 'titleDetail' in page; +} + +/** + * Detects if {@linkcode page} is actually a {@linkcode StaticMessagePage} + */ +export function isStaticMessagePage( + page: GenericPage, +): page is StaticMessagePage { + return 'pageType' in page && page.pageType === 'staticMessagePage'; +} + +export function isErrorPage(page: GenericPage) { + return 'pageType' in page && page.pageType === 'errorPage'; +} + +/** + * Type-guard that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition + */ +export function isPage(page: unknown): page is Page { + if (!isObject(page)) { + return false; + } + + return [ + isAppEventDetailPage, + isArticlePage, + isChartsHubPage, + isGenericPage, + isShelfBasedProductPage, + isSearchLandingPage, + isSearchResultsPage, + isTopChartsPage, + isTodayPage, + isErrorPage, + isSeeAllPage, + ].some((specificPageTypePredicate) => + specificPageTypePredicate( + // This type-cast reflects the fact that we don't really know if `page` is really a `Page`, + // but that we're going to use the type-guards of our `Page` members to see if `page` looks + // like one of them + page as Page, + ), + ); +} + +/** + * Type-assertion that determines if the provided {@linkcode page} matches a renderable {@linkcode Page} definition + */ +export function assertIsPage(page: unknown): asserts page is Page { + if (!isPage(page)) { + throw new Error( + 'The view-model for the dispatched `Intent` does not match a known renderable shape', + ); + } +} + +/** + * Detects if {@linkcode page} has the Vision Pro pathname in it's URL. + */ +export function hasVisionProUrl(page: GenericPage) { + if (!page.canonicalURL) { + return false; + } + + const url = new URL(page.canonicalURL); + return ( + url.pathname.includes('/vision/apps-and-games') || + url.pathname.includes('/vision/arcade') + ); +} diff --git a/src/jet/models/static-message-page.ts b/src/jet/models/static-message-page.ts new file mode 100644 index 0000000..91dafb0 --- /dev/null +++ b/src/jet/models/static-message-page.ts @@ -0,0 +1,33 @@ +import { GenericPage } from '@jet-app/app-store/api/models'; + +const contentTypes = [ + 'win-back', + 'carrier', + 'invoice', + 'contingent-price', +] as const; + +export type ContentType = (typeof contentTypes)[number]; + +export class StaticMessagePage extends GenericPage { + constructor({ + titleLocKey, + contentType, + }: { + titleLocKey: string; + contentType: ContentType; + }) { + super([]); + this.titleLocKey = titleLocKey; + this.contentType = contentType; + } + + titleLocKey?: string; + + // Used to indicate which type of content the page needs to show, used to pull in the proper + // LOC keys when rendering + contentType: ContentType; + + // Used in our type guards to narrow a `Page` down to a `StaticMessagePage` + pageType: string = 'staticMessagePage'; +} diff --git a/src/jet/svelte.ts b/src/jet/svelte.ts new file mode 100644 index 0000000..f1870ca --- /dev/null +++ b/src/jet/svelte.ts @@ -0,0 +1,45 @@ +import { getContext } from 'svelte'; +import type { Opt } from '@jet/environment'; +import type { ActionOutcome } from '@jet/engine'; + +import type { ActionModel } from '~/jet/models'; +import type { Jet } from '~/jet/jet'; + +export const CONTEXT_NAME = 'jet'; + +/** + * Gets the current Jet instance from the Svelte context. + * + * @return jet The current instance of Jet + */ +export function getJet(): Jet { + const jet = getContext<Opt<Jet>>(CONTEXT_NAME); + + if (!jet) { + throw new Error('getJet called before Jet.load'); + } + + return jet; +} + +/** + * Jet helper to expose jet.perform in single location + * + * @return Promise<ActionOutcome> + */ +type ActionUndefined = 'noActionProvided'; + +export function getJetPerform(): ( + action: ActionModel, +) => Promise<ActionOutcome | ActionUndefined> { + const jet = getJet(); + + return (action: ActionModel) => { + if (!action) { + //TODO: rdar://73165545 (Error Handling Across App) + return Promise.resolve('noActionProvided'); + } + + return jet.perform(action); + }; +} diff --git a/src/jet/utils/app-event-formatted-date.ts b/src/jet/utils/app-event-formatted-date.ts new file mode 100644 index 0000000..c885687 --- /dev/null +++ b/src/jet/utils/app-event-formatted-date.ts @@ -0,0 +1,194 @@ +import { + type Optional, + isSome, + isNothing, +} from '@jet/environment/types/optional'; +import type { LocalizationWrapper } from '@jet-app/app-store/foundation/wrappers/localization'; +import type { + AppEventFormattedDate, + AppEventBadgeKind, +} from '@jet-app/app-store/api/models'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { formattedDatesWithKind } from '@jet-app/app-store/common/app-promotions/app-event'; + +/** + * Partial type of {@linkcode AppEventFormattedDate} with just the properties + * that are actually used + */ +export type RequiredAppEventFormattedDate = Pick< + AppEventFormattedDate, + 'displayText' | 'displayFromDate' | 'countdownToDate' | 'countdownStringKey' +>; + +/** + * Represents a client-side serialization of an {@linkcode RequiredAppEventFormattedDate} + * + * This is needed because our client-side code will receive the event object with `Date` properties + * serialized as ISO 8601-formatted strings, while the server-side code will receive the original + * `Date` values. We need to normalize this to make sure we have consistent logic in both environments + */ +type SerializedAppEventFormattedDate = Pick< + RequiredAppEventFormattedDate, + 'displayText' | 'countdownStringKey' +> & { + readonly displayFromDate?: string; + readonly countdownToDate?: string; +}; + +function deserializeDate(value: Optional<Date | string>): Date | undefined { + if (isNothing(value)) { + return undefined; + } + + return typeof value === 'string' ? new Date(value) : value; +} + +/** + * Turn {@linkcode date} in either the client- or server-side format into the + * server-side format by parsing the ISO 8601 string values into `Date` instances + */ +function deserializeDateProperties( + date: SerializedAppEventFormattedDate | RequiredAppEventFormattedDate, +): RequiredAppEventFormattedDate { + const { countdownToDate, displayFromDate, ...rest } = date; + + return { + // Normalize properties that might have been serialized as `string` to `Date` + countdownToDate: deserializeDate(countdownToDate), + displayFromDate: deserializeDate(displayFromDate), + + // Use all of the other properties with their existing values + ...rest, + }; +} + +/** + * A {@linkcode RequiredAppEventFormattedDate} with a definitely-defined `.displayFromDate` property + */ +type AppEventFormattedDateWithDisplayFromDate = + RequiredAppEventFormattedDate & { + readonly displayFromDate: Date; + }; + +function hasDisplayRequirement( + date: RequiredAppEventFormattedDate, +): date is AppEventFormattedDateWithDisplayFromDate { + return isSome(date.displayFromDate); +} + +export function chooseAppEventDate( + dates: (SerializedAppEventFormattedDate | RequiredAppEventFormattedDate)[], +): Optional<RequiredAppEventFormattedDate> { + const nowTime = Date.now(); + + // We might be passed `dates` in the expected format (server-side) or with their `Date` + // properties serialized as strings (client-side); we need to normalize them all to the + // same format + const normalizedDates = dates.map((date) => + deserializeDateProperties(date), + ); + + // A `dates` member might not have a `.displayFromDate`; if that's the case, we will + // use that as a fallback if all other options are in the future + const fallback = normalizedDates.find( + (date) => !hasDisplayRequirement(date), + ); + + // Find all of the `dates` members with a `.displayFromDate` in the past + const optionsWithPastDisplayFromDates = normalizedDates + // Ensure all `date` objects have a display requirement + .filter((date) => hasDisplayRequirement(date)) + // Filter out any `date` objects with a display requirement in the future + .filter((date) => { + const dateTime = date.displayFromDate.getTime(); + const timeDifference = nowTime - dateTime; + + return timeDifference > 0; + }); + + // If there are none, use the fallback + if (optionsWithPastDisplayFromDates.length === 0) { + return fallback; + } + + // Otherwise, find the `date` object with the most recent `.displayFromDate` + return optionsWithPastDisplayFromDates.reduce((acc, next) => { + const accTime = acc.displayFromDate.getTime(); + const nextTime = next.displayFromDate.getTime(); + + // Which time is closer to "now"? + const accTimeDiff = nowTime - accTime; + const nextTimeDiff = nowTime - nextTime; + + return accTimeDiff > nextTimeDiff ? next : acc; + }); +} + +/** + * Partial type of {@linkcode LocalizationWrapper} with just the methods that + * are actually called + * + * This partial type simplifies testing by reducing the surface area of the function's + * dependencies + */ +type RequiredLocalization = Pick<LocalizationWrapper, 'string'>; + +function msToMinutes(ms: number): number { + return ms / (1_000 * 60); +} + +export function renderDate( + localization: RequiredLocalization, + date: RequiredAppEventFormattedDate, +): Optional<string> { + if (typeof date.countdownStringKey === 'string' && date.countdownToDate) { + const nowTime = Date.now(); + const translationString = localization.string(date.countdownStringKey); + + const countdownToDateTime = date.countdownToDate.getTime(); + const diffTime = countdownToDateTime - nowTime; + + const count = Math.floor(msToMinutes(diffTime)); + + return translationString.replace('@@count@@', count.toString()); + } + + if (typeof date.displayText === 'string') { + return date.displayText; + } + + return undefined; +} + +/** + * Helper function to compute formatted dates for app events. + * Handles date conversion and error handling. + * + * @param objectGraph - objectGraph from Jet + * @param badgeKind - The badge kind from the app event + * @param startDate - The start date (string or Date) + * @param endDate - The optional end date (string or Date) + * @returns Array of formatted dates or undefined if an error occurs + */ +export function computeAppEventFormattedDates( + objectGraph: AppStoreObjectGraph, + badgeKind: AppEventBadgeKind, + startDate: string | Date, + endDate?: string | Date | null, +): RequiredAppEventFormattedDate[] | undefined { + // Use deserializeDate function to convert dates + const startDateObj = deserializeDate(startDate); + const endDateObj = deserializeDate(endDate); + + // Validate that we have a valid start date + if (!startDateObj || isNaN(startDateObj.getTime())) { + return undefined; + } + + return formattedDatesWithKind( + objectGraph, + badgeKind, + startDateObj, + endDateObj, + ); +} diff --git a/src/jet/utils/error-metadata.ts b/src/jet/utils/error-metadata.ts new file mode 100644 index 0000000..1322dfd --- /dev/null +++ b/src/jet/utils/error-metadata.ts @@ -0,0 +1,16 @@ +import type { Opt } from '@jet/environment'; +import type { Intent } from '@jet/environment/dispatching'; + +export function addRejectedIntent(error: Error, intent: Intent<unknown>) { + (error as any).rejectedIntent = intent; +} + +export function getRejectedIntent(error: Error): Opt<Intent<unknown>> { + return hasRejectedIntent(error) ? error.rejectedIntent : null; +} + +function hasRejectedIntent( + error: Error, +): error is Error & { rejectedIntent: Intent<unknown> } { + return 'rejectedIntent' in error; +} diff --git a/src/jet/utils/handle-modal-presentation.ts b/src/jet/utils/handle-modal-presentation.ts new file mode 100644 index 0000000..9040d4f --- /dev/null +++ b/src/jet/utils/handle-modal-presentation.ts @@ -0,0 +1,29 @@ +import { getModalPageStore } from '~/stores/modalPage'; +import { isGenericPage, type Page } from '../models'; +import type { Logger } from '@amp/web-apps-logger/src'; + +/** + * This function handles rendering flow action pages into a modal container. + * NOTE: Rendering a page in a modal will not update URL or history + * + * @param page page promise + * @param log app logger + */ +export const handleModalPresentation = ( + page: { promise: Promise<Page> }, + log: Logger<unknown[]>, + pageDetail?: string, +) => { + page.promise + .then((page) => { + if (isGenericPage(page)) { + const modalStore = getModalPageStore(); + modalStore.setPage({ page, pageDetail }); + } else { + throw new Error('only generic page is rendered in modal'); + } + }) + .catch((e) => { + log.error('modal presentation failed', e); + }); +}; diff --git a/src/jet/utils/with-platform.ts b/src/jet/utils/with-platform.ts new file mode 100644 index 0000000..6e11ab8 --- /dev/null +++ b/src/jet/utils/with-platform.ts @@ -0,0 +1,5 @@ +import type { WithPlatform } from 'node_modules/@jet-app/app-store/src/api/models/preview-platform'; + +export function isWithPlatform(x: unknown): x is WithPlatform { + return typeof x === 'object' && x !== null && 'platform' in x; +} diff --git a/src/sf-symbols/AgeRating-AU-15.svg b/src/sf-symbols/AgeRating-AU-15.svg new file mode 100644 index 0000000..6ff30c5 --- /dev/null +++ b/src/sf-symbols/AgeRating-AU-15.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='28px'%20height='24px'%20viewBox='0%200%2028%2024'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eAge_Rating_Gen_15+%20Outline%3c/title%3e%3cg%20id='Age_Rating_Gen_15+-Outline'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='AUS-15+'%3e%3crect%20id='Mask'%20fill='%23DDDDDE'%20x='0'%20y='0'%20width='28'%20height='24'%20rx='6'%3e%3c/rect%3e%3cg%20id='label'%20transform='translate(5.2969,%206.4312)'%20fill='%2374747B'%20fill-rule='nonzero'%3e%3cpolygon%20id='Path'%20points='2.0268631%2010.5688477%203.60271454%2010.5688477%203.60271454%200%202.03029633%200%200%201.6462326%200%203.24531555%201.98337555%201.65550232%202.0268631%201.65550232'%3e%3c/polygon%3e%3cpath%20d='M8.1775086,10.7278061%20C8.77542425,10.7278061%209.29273535,10.5772972%209.72944189,10.2762794%20C10.1661484,9.97526169%2010.5028718,9.5514679%2010.7396119,9.00489807%20C10.976352,8.45832825%2011.094722,7.81330109%2011.094722,7.06981659%20L11.094722,7.05516815%20C11.094722,6.36295319%2010.9934037,5.75984955%2010.790767,5.24585724%20C10.5881302,4.73186493%2010.3027718,4.33383942%209.93469168,4.0517807%20C9.56661154,3.76972198%209.13598944,3.62869263%208.64282537,3.62869263%20C8.38579107,3.62869263%208.14973761,3.66607666%207.93466497,3.74084473%20C7.71959234,3.81561279%207.52782751,3.92505646%207.35937048,4.06917572%20C7.19091345,4.21329498%207.051677,4.3875885%206.94166113,4.59205627%20L6.89256598,4.59205627%20L7.08837439,1.33403778%20L10.6257432,1.33403778%20L10.6257432,0%20L5.82220007,0%20L5.50359655,6.21116638%20L6.87665869,6.21116638%20C6.90252234,6.08360291%206.93666388,5.96191406%206.97908331,5.84609985%20C7.02150274,5.73028564%207.06540991,5.62900543%207.11080481,5.54225922%20C7.23417212,5.3194046%207.38708426,5.1524353%207.56954123,5.04135132%20C7.7519982,4.93026733%207.962703,4.87472534%208.20165564,4.87472534%20C8.48371435,4.87472534%208.72545172,4.96448517%208.92686773,5.14400482%20C9.12828375,5.32352448%209.28289343,5.57857513%209.39069677,5.9091568%20C9.49850012,6.23973846%209.55240179,6.63223267%209.55240179,7.0866394%20L9.55240179,7.09568024%20C9.55240179,7.57419586%209.49878622,7.98578262%209.39155508,8.33044052%20C9.28432394,8.67509842%209.12906576,8.9402771%208.92578054,9.12597656%20C8.72249533,9.31167603%208.47738195,9.40452576%208.19044043,9.40452576%20C7.91929174,9.40452576%207.6870339,9.33286667%207.4936669,9.18954849%20C7.30029989,9.04623032%207.14790274,8.84937286%207.03647543,8.59897614%20C6.92504812,8.34857941%206.85583044,8.05850983%206.82882238,7.7287674%20L6.81589056,7.54657745%20L5.36100317,7.54657745%20L5.370044,7.78049469%20C5.40346075,8.38161469%205.53844381,8.90184402%205.77499319,9.34118271%20C6.01154257,9.78052139%206.33342672,10.12146%206.74064566,10.3639984%20C7.14786459,10.6065369%207.6268189,10.7278061%208.1775086,10.7278061%20Z'%20id='Path'%3e%3c/path%3e%3cpath%20d='M12.0326825,6.98318481%20L17.9403516,6.98318481%20L17.9403516,5.58963776%20L12.0326825,5.58963776%20L12.0326825,6.98318481%20Z%20M14.2917462,9.71820831%20L15.6796857,9.71820831%20L15.6796857,2.85621643%20L14.2917462,2.85621643%20L14.2917462,9.71820831%20Z'%20id='Shape'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/AgeRating-AU-18.svg b/src/sf-symbols/AgeRating-AU-18.svg new file mode 100644 index 0000000..d316f67 --- /dev/null +++ b/src/sf-symbols/AgeRating-AU-18.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__Dxnbu0ML__"
\ No newline at end of file diff --git a/src/sf-symbols/accessibility.svg b/src/sf-symbols/accessibility.svg new file mode 100644 index 0000000..47f958a --- /dev/null +++ b/src/sf-symbols/accessibility.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.2832%2019.9316'%3e%3cg%3e%3crect%20height='19.9316'%20opacity='0'%20width='20.2832'%20x='0'%20y='0'/%3e%3cpath%20d='M9.96094%2019.9219C15.459%2019.9219%2019.9219%2015.459%2019.9219%209.96094C19.9219%204.46289%2015.459%200%209.96094%200C4.46289%200%200%204.46289%200%209.96094C0%2015.459%204.46289%2019.9219%209.96094%2019.9219ZM9.96094%2018.2617C5.37109%2018.2617%201.66016%2014.5508%201.66016%209.96094C1.66016%205.37109%205.37109%201.66016%209.96094%201.66016C14.5508%201.66016%2018.2617%205.37109%2018.2617%209.96094C18.2617%2014.5508%2014.5508%2018.2617%209.96094%2018.2617Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M9.95117%206.66016C8.56445%206.66016%206.5918%206.42578%205.64453%206.2793C5.55664%206.26953%205.47852%206.24023%205.38086%206.24023C5.13672%206.24023%204.88281%206.44531%204.88281%206.79688C4.88281%207.06055%205.04883%207.28516%205.29297%207.35352C5.61523%207.45117%207.83203%207.70508%208.11523%207.75391C8.38867%207.8125%208.55469%208.04688%208.56445%208.37891C8.57422%208.88672%208.56445%2010.5078%208.4375%2011.25C8.32031%2011.9824%207.4707%2015.5078%207.43164%2015.6641C7.32422%2016.0449%207.55859%2016.377%207.93945%2016.377C8.21289%2016.377%208.39844%2016.2305%208.50586%2015.8594C8.67188%2015.1465%209.28711%2012.7051%209.48242%2012.1191C9.59961%2011.6895%209.70703%2011.543%209.95117%2011.543C10.1855%2011.543%2010.293%2011.6895%2010.4297%2012.1191C10.5957%2012.7051%2011.2305%2015.1465%2011.3965%2015.8594C11.4941%2016.2305%2011.6895%2016.377%2011.9629%2016.377C12.3438%2016.377%2012.5684%2016.0449%2012.4609%2015.6641C12.4316%2015.5078%2011.5723%2011.9824%2011.4551%2011.25C11.3477%2010.5078%2011.3477%208.88672%2011.3477%208.37891C11.3477%208.04688%2011.5039%207.8125%2011.7871%207.75391C12.0605%207.70508%2014.2871%207.45117%2014.5996%207.35352C14.8535%207.28516%2015.0195%207.06055%2015.0195%206.79688C15.0195%206.44531%2014.7559%206.24023%2014.5117%206.24023C14.4238%206.24023%2014.3359%206.26953%2014.248%206.2793C13.3105%206.42578%2011.3477%206.66016%209.95117%206.66016ZM9.95117%206.01562C10.6543%206.01562%2011.2207%205.43945%2011.2207%204.74609C11.2207%204.04297%2010.6543%203.47656%209.95117%203.47656C9.24805%203.47656%208.68164%204.04297%208.68164%204.74609C8.68164%205.43945%209.24805%206.01562%209.95117%206.01562Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/app.3.stack.3d.fill.svg b/src/sf-symbols/app.3.stack.3d.fill.svg new file mode 100644 index 0000000..5ff740e --- /dev/null +++ b/src/sf-symbols/app.3.stack.3d.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2024.8047%2023.4863'%3e%3cg%3e%3crect%20height='23.4863'%20opacity='0'%20width='24.8047'%20x='0'%20y='0'/%3e%3cpath%20d='M2.48047%2015.1367L8.51562%2017.8906C9.96094%2018.5547%2011.0938%2018.8672%2012.2168%2018.8672C13.3496%2018.8672%2014.4824%2018.5547%2015.9277%2017.8906L21.9629%2015.1367C22.0067%2015.1165%2022.0499%2015.0961%2022.0896%2015.0742C22.5477%2015.4686%2022.7148%2015.9089%2022.7148%2016.3672C22.7148%2017.041%2022.3633%2017.666%2021.2402%2018.1738L15.2148%2020.9277C13.9551%2021.5039%2013.0566%2021.748%2012.2168%2021.748C11.3867%2021.748%2010.4883%2021.5039%209.22852%2020.9277L3.20312%2018.1738C2.08008%2017.666%201.72852%2017.041%201.72852%2016.3672C1.72852%2015.9083%201.89155%2015.4676%202.35092%2015.0728Z'%20fill='currentColor'%20/%3e%3cpath%20d='M2.48047%2010.4785L8.51562%2013.2422C9.96094%2013.8965%2011.0938%2014.2188%2012.2168%2014.2188C13.3496%2014.2188%2014.4824%2013.8965%2015.9277%2013.2422L21.9629%2010.4785C21.9986%2010.4623%2022.0338%2010.4459%2022.0661%2010.4282C22.5419%2010.8246%2022.7148%2011.2721%2022.7148%2011.7383C22.7148%2012.4121%2022.3633%2013.0371%2021.2402%2013.5547L15.2148%2016.2988C13.9551%2016.875%2013.0566%2017.1191%2012.2168%2017.1191C11.3867%2017.1191%2010.4883%2016.875%209.22852%2016.2988L3.20312%2013.5547C2.08008%2013.0371%201.72852%2012.4121%201.72852%2011.7383C1.72852%2011.2716%201.89718%2010.8236%202.37464%2010.4269Z'%20fill='currentColor'%20/%3e%3cpath%20d='M12.2168%2012.4902C13.0566%2012.4902%2013.9551%2012.2461%2015.2148%2011.6699L21.2402%208.91602C22.3633%208.4082%2022.7148%207.7832%2022.7148%207.10938C22.7148%206.42578%2022.3535%205.80078%2021.2402%205.29297L15.1953%202.54883C13.9648%201.99219%2013.0664%201.72852%2012.2168%201.72852C11.377%201.72852%2010.4785%201.99219%209.23828%202.54883L3.20312%205.29297C2.08008%205.80078%201.72852%206.42578%201.72852%207.10938C1.72852%207.7832%202.08008%208.4082%203.20312%208.91602L9.22852%2011.6699C10.4883%2012.2461%2011.3867%2012.4902%2012.2168%2012.4902Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/app.3.stack.3d.svg b/src/sf-symbols/app.3.stack.3d.svg new file mode 100644 index 0000000..7b18932 --- /dev/null +++ b/src/sf-symbols/app.3.stack.3d.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20129.864%20120'%3e%3cpath%20d='M51.446,106.57l-31.584,-14.452c-5.479,-2.491%20-7.401,-5.573%20-7.401,-9.031c0,-3.509%201.922,-6.591%207.401,-9.092l5.46098,-2.48692l9.09647,4.14444l-11.44944,5.22248c-1.315,0.533%20-1.942,1.305%20-1.942,2.264c0,0.794%200.627,1.628%201.942,2.202l31.584,14.401c4.555,2.024%207.63,2.823%2010.403,2.823c2.773,0%205.797,-0.799%2010.351,-2.823l31.637,-14.401c1.263,-0.574%201.879,-1.408%201.879,-2.202c0,-0.959%20-0.616,-1.731%20-1.879,-2.264l-11.45166,-5.22168l9.09403,-4.1432l5.46563,2.48488c5.427,2.501%207.349,5.583%207.349,9.092c0,3.458%20-1.87,6.54%20-7.349,9.031l-31.585,14.452c-5.319,2.406%20-9.358,3.486%20-13.511,3.486c-4.204,0%20-8.243,-1.08%20-13.511,-3.486z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M51.446,83.41l-31.584,-14.39c-5.479,-2.553%20-7.401,-5.584%20-7.401,-9.093c0,-3.509%201.922,-6.592%207.401,-9.082l5.43712,-2.46884l9.08875,4.15589l-11.41786,5.19295c-1.315,0.574%20-1.942,1.346%20-1.942,2.254c0,0.856%200.627,1.68%201.942,2.254l31.584,14.349c4.555,2.075%207.63,2.875%2010.403,2.875c2.773,0%205.797,-0.8%2010.351,-2.875l31.637,-14.349c1.263,-0.574%201.879,-1.398%201.879,-2.254c0,-0.908%20-0.616,-1.68%20-1.879,-2.254l-11.42007,-5.19215l9.08633,-4.15466l5.44174,2.46681c5.427,2.49%207.349,5.573%207.349,9.082c0,3.509%20-1.87,6.54%20-7.349,9.093l-31.585,14.39c-5.319,2.406%20-9.358,3.486%20-13.511,3.486c-4.204,0%20-8.243,-1.08%20-13.511,-3.486z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M64.957,63.818c4.153,0%208.192,-1.132%2013.511,-3.486l31.585,-14.442c5.479,-2.501%207.349,-5.583%207.349,-9.092c0,-3.458%20-1.922,-6.592%20-7.349,-9.031l-31.7,-14.422c-5.194,-2.322%20-9.233,-3.454%20-13.396,-3.454c-4.214,0%20-8.254,1.132%20-13.448,3.454l-31.647,14.422c-5.479,2.439%20-7.401,5.573%20-7.401,9.031c0,3.509%201.922,6.591%207.401,9.092l31.584,14.442c5.268,2.354%209.307,3.486%2013.511,3.486zM64.957,56.327c-2.773,0%20-5.848,-0.799%20-10.403,-2.875l-31.584,-14.338c-1.315,-0.585%20-1.942,-1.409%20-1.942,-2.265c0,-0.897%200.627,-1.628%201.942,-2.202l31.71,-14.474c4.429,-1.981%207.515,-2.739%2010.277,-2.739c2.763,0%205.797,0.758%2010.267,2.739l31.721,14.474c1.263,0.574%201.879,1.305%201.879,2.202c0,0.856%20-0.616,1.68%20-1.879,2.265l-31.637,14.338c-4.554,2.076%20-7.578,2.875%20-10.351,2.875z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/appearance.darkmode.svg b/src/sf-symbols/appearance.darkmode.svg new file mode 100644 index 0000000..738defc --- /dev/null +++ b/src/sf-symbols/appearance.darkmode.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.2832%2019.9316'%3e%3cg%3e%3crect%20height='19.9316'%20opacity='0'%20width='20.2832'%20x='0'%20y='0'/%3e%3cpath%20d='M19.9219%209.96094C19.9219%2015.459%2015.459%2019.9219%209.96094%2019.9219C4.46289%2019.9219%200%2015.459%200%209.96094C0%204.46289%204.46289%200%209.96094%200C15.459%200%2019.9219%204.46289%2019.9219%209.96094ZM0.546875%209.96094C0.546875%2015.166%204.75586%2019.375%209.96094%2019.375L9.96094%2018.2617C14.5508%2018.2617%2018.2617%2014.5508%2018.2617%209.96094C18.2617%205.37109%2014.5508%201.66016%209.96094%201.66016L9.96094%200.537109C4.75586%200.537109%200.546875%204.75586%200.546875%209.96094Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M9.96094%206.07422C7.82227%206.07422%206.07422%207.8125%206.07422%209.96094C6.07422%2012.0996%207.82227%2013.8477%209.96094%2013.8477L9.96094%2019.375C4.75586%2019.375%200.546875%2015.166%200.546875%209.96094C0.546875%204.75586%204.75586%200.537109%209.96094%200.537109Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M9.96094%2013.8477C12.0996%2013.8477%2013.8477%2012.0996%2013.8477%209.96094C13.8477%207.8125%2012.0996%206.07422%209.96094%206.07422Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/applewatch.svg b/src/sf-symbols/applewatch.svg new file mode 100644 index 0000000..1f3b670 --- /dev/null +++ b/src/sf-symbols/applewatch.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2070.762%20104.469'%3e%3cpath%20d='M0%2071.965C0%2079.535%202.937%2085.087%208.492%2088.13%2011.19%2089.572%2012.724%2091.386%2013.752%2094.613L15.27%2099.873C16.204%20102.996%2018.372%20104.47%2021.66%20104.47H44.214C47.617%20104.469%2049.63%20103.047%2050.605%2099.874L52.185%2094.613C53.15%2091.386%2054.737%2089.573%2057.383%2088.13%2062.938%2085.087%2065.875%2079.535%2065.875%2071.965V32.503C65.875%2024.934%2062.938%2019.381%2057.383%2016.34%2054.737%2014.896%2053.15%2013.083%2052.185%209.856L50.605%204.595C49.733%201.525%2047.565%200%2044.215%200H21.66C18.372%200%2016.204%201.473%2015.27%204.595L13.752%209.855C12.776%2013.032%2011.242%2014.949%208.492%2016.34%202.989%2019.226%200%2024.83%200%2032.503ZM64.824%2048.795H66.591C69.09%2048.794%2070.762%2047.05%2070.762%2044.321V37.695C70.762%2034.915%2069.092%2033.171%2066.591%2033.171H64.824ZM7.129%2070.92V33.56C7.129%2026.263%2011.39%2021.887%2018.48%2021.887H47.405C54.546%2021.887%2058.745%2026.263%2058.745%2033.56V70.92C58.746%2078.205%2054.547%2082.58%2047.406%2082.58H18.48C11.39%2082.581%207.13%2078.206%207.13%2070.92Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/appstore-ribbon-bar-fallback-icon.svg b/src/sf-symbols/appstore-ribbon-bar-fallback-icon.svg new file mode 100644 index 0000000..12cde33 --- /dev/null +++ b/src/sf-symbols/appstore-ribbon-bar-fallback-icon.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20123.272%20120'%3e%3cpath%20d='M63.709,71.877h-15.616l27.337,-47.427c1.786,-3.083%201.163,-7.278%20-2.159,-9.126c-3.395,-1.983%20-7.257,-0.426%20-8.95,2.647l-2.71,4.444l-2.658,-4.444c-1.744,-3.073%20-5.554,-4.682%20-8.949,-2.647c-3.323,1.91%20-3.883,6.043%20-2.098,9.126l6.417,10.86l-21.149,36.567h-16.415c-3.52,0%20-6.79,2.596%20-6.79,6.479c0,3.82%203.27,6.406%206.79,6.406h56.387c1.412,-5.731%20-1.609,-12.885%20-9.437,-12.885zM106.515,71.877h-16.352l-17.878,-30.898c-5.368,3.883%20-6.105,15.055%20-2.16,21.897l22.571,39.1c1.786,3.083%205.493,4.63%208.95,2.71c3.333,-1.911%204.008,-6.043%202.16,-9.189l-6.24,-10.735h8.949c3.52,0%206.791,-2.586%206.791,-6.406c0,-3.883%20-3.271,-6.479%20-6.791,-6.479zM23.061,89.33l-3.53,6.167c-1.786,3.146%20-1.236,7.216%202.046,9.189c3.457,1.972%207.215,0.311%209.001,-2.71l5.233,-8.877c-1.236,-2.284%20-6.219,-5.503%20-12.75,-3.769z'%20fill='%238e8e93'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/appstore.svg b/src/sf-symbols/appstore.svg new file mode 100644 index 0000000..50366f4 --- /dev/null +++ b/src/sf-symbols/appstore.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20123.272%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='123.272'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M63.709%2057.458L48.093%2057.458L75.43%2010.083C77.216%206.999%2076.593%202.805%2073.271%200.946C69.876-1.026%2065.993%200.521%2064.321%203.604L61.611%208.048L58.953%203.604C57.282%200.521%2053.399-1.089%2050.004%200.946C46.681%202.867%2046.121%206.999%2047.906%2010.083L54.323%2020.943L33.174%2057.458L16.759%2057.458C13.239%2057.458%209.969%2060.054%209.969%2063.937C9.969%2067.757%2013.239%2070.343%2016.759%2070.343L73.146%2070.343C74.558%2064.612%2071.537%2057.458%2063.709%2057.458ZM106.515%2057.458L90.163%2057.458L72.285%2026.601C66.917%2030.495%2066.169%2041.677%2070.125%2048.508L92.696%2087.557C94.471%2090.64%2098.189%2092.187%20101.646%2090.267C104.979%2088.356%20105.643%2084.224%20103.806%2081.078L97.566%2070.343L106.515%2070.343C110.035%2070.343%20113.306%2067.757%20113.306%2063.937C113.306%2060.054%20110.035%2057.458%20106.515%2057.458ZM23.061%2074.911L19.531%2081.078C17.745%2084.224%2018.295%2088.294%2021.577%2090.267C25.034%2092.239%2028.803%2090.589%2030.578%2087.557L35.811%2078.68C34.575%2076.396%2029.592%2073.177%2023.061%2074.911Z'%20fill='currentColor'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/arkit.svg b/src/sf-symbols/arkit.svg new file mode 100644 index 0000000..b87210b --- /dev/null +++ b/src/sf-symbols/arkit.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20123.97%20120'%3e%3cpath%20d='M57.796,112.739c2.894,1.65%205.499,1.65%208.393,0l13.512,-7.7c2.404,-1.357%202.953,-3.719%201.855,-5.833c-1.098,-2.02%20-3.74,-2.559%20-6.03,-1.253l-9.446,5.423v-9.939c0,-2.622%20-1.741,-4.591%20-4.093,-4.591c-2.341,0%20-4.092,1.969%20-4.092,4.591v9.939l-9.447,-5.423c-2.29,-1.306%20-4.931,-0.767%20-6.03,1.253c-1.087,2.114%20-0.497,4.476%201.866,5.833zM25.785,94.571c2.341,1.305%204.962,0.819%206.05,-1.316c1.099,-2.061%200.259,-4.558%20-1.875,-5.771l-5.264,-3l7.53,-4.274c2.342,-1.346%203.233,-3.968%201.928,-5.926c-1.295,-1.979%20-3.71,-2.61%20-5.896,-1.367l-7.901,4.469v-6.382c0,-2.632%20-1.659,-4.59%20-3.948,-4.59c-2.3,0%20-3.948,1.958%20-3.948,4.59v7.64c0,7.258%202.393,9.736%206.99,12.329zM16.409,55.89c2.352,0%203.948,-1.958%203.948,-4.59v-9.26l9.04,5.184c2.134,1.202%204.59,0.612%205.947,-1.357c1.295,-1.969%200.362,-4.579%20-2.093,-5.937l-8.463,-4.77l8.252,-4.682c2.487,-1.43%203.264,-4.093%202.031,-6.133c-1.295,-1.948%20-3.781,-2.363%20-5.792,-1.192l-10.702,6.057c-3.949,2.332%20-6.116,5.4%20-6.116,10.789v11.301c0,2.632%201.71,4.59%203.948,4.59zM61.987,29.893c2.352,0%204.093,-1.958%204.093,-4.58v-9.027l6.868,3.902c2.208,1.264%204.911,0.756%205.989,-1.243c1.139,-2.041%200.508,-4.496%20-1.814,-5.823l-10.618,-6.04c-3.253,-1.819%20-5.783,-1.819%20-9.035,0l-10.608,6.04c-2.332,1.327%20-2.953,3.782%20-1.824,5.823c1.088,1.999%203.833,2.507%205.988,1.243l6.869,-3.964v9.089c0,2.622%201.751,4.58%204.092,4.58zM107.576,55.89c2.238,0%203.937,-1.958%203.937,-4.59v-11.301c0,-5.854%20-1.535,-8.146%20-6.105,-10.789l-10.713,-6.057c-2.011,-1.171%20-4.445,-0.756%20-5.729,1.192c-1.295,2.04%20-0.508,4.703%201.968,6.133l8.252,4.682l-8.462,4.77c-2.445,1.358%20-3.388,3.968%20-2.083,5.937c1.347,1.969%203.802,2.559%205.947,1.357l9.04,-5.184v9.26c0,2.632%201.637,4.59%203.948,4.59zM98.19,94.571l6.344,-3.598c4.586,-2.593%206.979,-5.071%206.979,-12.329v-7.64c0,-2.632%20-1.637,-4.59%20-3.937,-4.59c-2.248,0%20-3.948,1.958%20-3.948,4.59v6.393l-7.912,-4.48c-2.186,-1.243%20-4.59,-0.612%20-5.895,1.367c-1.295,1.958%20-0.414,4.58%201.938,5.926l7.498,4.274l-5.232,3c-2.093,1.213%20-2.984,3.71%20-1.886,5.771c1.098,2.135%203.761,2.621%206.051,1.316zM61.987,78.789c2.352,0%204.093,-1.906%204.093,-4.58v-10.77l10.009,-5.747c2.352,-1.346%203.264,-3.968%201.969,-5.936c-1.306,-1.958%20-3.688,-2.601%20-5.937,-1.347l-10.134,5.747l-10.134,-5.747c-2.186,-1.254%20-4.631,-0.611%20-5.926,1.347c-1.305,1.968%20-0.383,4.59%201.958,5.936l10.01,5.747v10.77c0,2.674%201.751,4.58%204.092,4.58z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/bag.fill.svg b/src/sf-symbols/bag.fill.svg new file mode 100644 index 0000000..2d95528 --- /dev/null +++ b/src/sf-symbols/bag.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20111.559%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='111.559'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M27.779%2093.877L85.221%2093.877C94.014%2093.877%2099.1%2088.77%2099.1%2078.715L99.1%2028.2C99.1%2018.145%2093.963%2013.038%2083.782%2013.038L27.779%2013.038C17.599%2013.038%2012.461%2018.124%2012.461%2028.2L12.461%2078.715C12.461%2088.791%2017.599%2093.877%2027.779%2093.877ZM36.512%2014.948L44.417%2015C44.417%208.019%2048.981%203.082%2055.755%203.082C62.57%203.082%2067.144%208.019%2067.144%2015L74.998%2014.948C74.998%204.174%2066.725-4.357%2055.755-4.357C44.836-4.357%2036.512%204.174%2036.512%2014.948Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.10.official.svg b/src/sf-symbols/br.10.official.svg new file mode 100644 index 0000000..54d4703 --- /dev/null +++ b/src/sf-symbols/br.10.official.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e10%3c/title%3e%3cdesc%3e10%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/10'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%230095D9'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M4.09273752,5.20290217%20C4.27137064,5.11579879%204.49873561,4.94710839%204.77483242,4.69683099%20C4.99292401,4.471661%205.15432914,4.22841554%205.2590478,3.96709461%20L6.62259677,3.96709461%20L6.62259677,12.125072%20L5.0648255,12.125072%20L5.0648255,6.03001358%20C4.89350954,6.18226182%204.73764603,6.30913186%204.59723499,6.41062371%20C4.45682394,6.51211556%204.28865812,6.59320487%204.09273752,6.65389166%20L4.09273752,5.20290217%20Z%20M11.9072625,6.71055955%20L11.9072625,9.35519001%20C11.9072625,9.95594557%2011.8790107,10.3934523%2011.8225071,10.6677103%20C11.7660035,10.9419683%2011.6628615,11.2587291%2011.4848753,11.4970723%20C11.306889,11.7354155%2011.1073279,11.8814592%2010.855887,11.9859384%20C10.6044461,12.0904176%2010.1987357,12.1075647%209.88796601,12.1075647%20C9.4783151,12.1075647%209.20835844,12.0953151%208.93714128,11.9859384%20C8.66592413,11.8765617%208.43834952,11.7288856%208.27731433,11.4970723%20C8.11627915,11.265259%208.03818025,10.9558444%207.97037597,10.6995438%20C7.90257168,10.4432432%207.86866953,10.0359375%207.86866953,9.4776266%20L7.86866953,6.71055955%20C7.86866953,5.98246992%207.92305422,5.43721895%208.0318236,5.07480663%20C8.14059298,4.71239431%208.25994623,4.46344396%208.58038754,4.21526928%20C8.90082885,3.96709461%209.39567862,3.874928%209.85618275,3.874928%20C10.4565529,3.874928%2010.8897883,4.00502057%2011.2698551,4.2724843%20C11.6499218,4.53994803%2011.776598,5.12378127%2011.8288638,5.39803924%20C11.8811296,5.67229722%2011.9072625,6.10980398%2011.9072625,6.71055955%20Z%20M10.2474521,5.90136398%20C10.2474521,5.45009119%2010.226792,5.16760842%2010.1854717,5.05391566%20C10.1441515,4.9402229%2010.0474621,4.88337652%209.89540365,4.88337652%20C9.74665079,4.88337652%209.64830861,4.94372114%209.60037714,5.06441037%20C9.55244566,5.18509961%209.52847992,5.46408415%209.52847992,5.90136398%20L9.52847992,10.0467768%20C9.52847992,10.5400285%209.55079285,10.8373788%209.59541871,10.9388277%20C9.64004457,11.0402766%209.73673393,11.0910011%209.88548679,11.0910011%20C10.0342397,11.0910011%2010.1317554,11.031531%2010.1780341,10.9125909%20C10.2243128,10.7936508%2010.2474521,10.5260355%2010.2474521,10.1097451%20L10.2474521,5.90136398%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.10.svg b/src/sf-symbols/br.10.svg new file mode 100644 index 0000000..9f5a416 --- /dev/null +++ b/src/sf-symbols/br.10.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA10%3c/title%3e%3cdesc%3eA10%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A10'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%230283CA'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M6.77976115,5.19366546%20C6.95715179,5.10716793%207.18293532,4.93965086%207.45711175,4.69111425%20C7.67368641,4.46751043%207.83396889,4.22595686%207.93795917,3.96645354%20L9.292024,3.96645354%20L9.292024,12.0676882%20L7.74508779,12.0676882%20L7.74508779,6.01502391%20C7.57496341,6.16621319%207.42018401,6.29220079%207.28074959,6.39298671%20C7.14131518,6.49377263%206.97431903,6.57429793%206.77976115,6.63456261%20L6.77976115,5.19366546%20Z%20M14.5399323,6.69083634%20L14.5399323,9.31707211%20C14.5399323,9.91364912%2014.511877,10.3481128%2014.4557664,10.6204632%20C14.3996559,10.8928136%2014.2972312,11.2073711%2014.120483,11.4440566%20C13.9437347,11.680742%2013.7455616,11.8257699%2013.4958696,11.9295224%20C13.2461776,12.0332749%2012.8432891,12.0503027%2012.534681,12.0503027%20C12.1278794,12.0503027%2011.8598004,12.0381383%2011.5904697,11.9295224%20C11.321139,11.8209065%2011.0951473,11.6742575%2010.9352322,11.4440566%20C10.7753171,11.2138557%2010.6977614,10.9065932%2010.6304287,10.6520753%20C10.5630961,10.3975574%2010.5294297,9.99308465%2010.5294297,9.4386571%20L10.5294297,6.69083634%20C10.5294297,5.96781094%2010.5834361,5.42635246%2010.691449,5.06646089%20C10.7994618,4.70656932%2010.9179849,4.45935054%2011.2361974,4.21290204%20C11.5544099,3.96645354%2012.0458177,3.874928%2012.5031188,3.874928%20C13.0993131,3.874928%2013.5295352,4.00411571%2013.9069583,4.2697191%20C14.2843815,4.53532249%2014.4101766,5.11509488%2014.4620789,5.38744526%20C14.5139811,5.65979564%2014.5399323,6.09425933%2014.5399323,6.69083634%20Z%20M12.8916667,5.88726913%20C12.8916667,5.43913516%2012.8711503,5.15861719%2012.8301174,5.04571522%20C12.7890846,4.93281325%2012.6930678,4.87636227%2012.5420669,4.87636227%20C12.3943487,4.87636227%2012.2966905,4.93628716%2012.2490924,5.05613694%20C12.2014944,5.17598673%2012.1776953,5.45303079%2012.1776953,5.88726913%20L12.1776953,10.0038486%20C12.1776953,10.4936695%2012.199853,10.7889516%2012.2441685,10.8896948%20C12.288484,10.9904381%2012.3845008,11.0408098%2012.532219,11.0408098%20C12.6799372,11.0408098%2012.7767747,10.9817534%2012.8227315,10.8636405%20C12.8686883,10.7455277%2012.8916667,10.4797738%2012.8916667,10.0663789%20L12.8916667,5.88726913%20Z%20M5.50180611,4.03412044%20L6.75931556,12.0676882%20L4.57337821,12.0676882%20L4.46041707,10.6171204%20L3.70141707,10.6171204%20L3.584216,12.0676882%20L1.37941707,12.0676882%20L2.46560111,4.03412044%20L5.50180611,4.03412044%20Z%20M4.09305594,5.84858991%20L3.68673127,9.1892807%20L3.68941707,9.19012044%20L4.35041707,9.19012044%20L4.09305594,5.84858991%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.12.official.svg b/src/sf-symbols/br.12.official.svg new file mode 100644 index 0000000..e8e0b54 --- /dev/null +++ b/src/sf-symbols/br.12.official.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e12%3c/title%3e%3cdesc%3e12%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/12'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23FFCC03'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M4.07768806,5.24555806%20C4.25359702,5.15978302%204.47749466,4.99366516%204.74938098,4.7472045%20C4.96414667,4.52546837%205.12309036,4.28593241%205.22621205,4.02859664%20L6.56896682,4.02859664%20L6.56896682,12.0621644%20L5.03495165,12.0621644%20L5.03495165,6.06005599%20C4.86624826,6.20998243%204.71276168,6.3349177%204.57449192,6.43486179%20C4.43622215,6.53480588%204.27062086,6.61465858%204.07768806,6.67441988%20L4.07768806,5.24555806%20Z%20M11.9223119,10.8174254%20L11.9223119,11.9944762%20L7.649851,11.9944762%20L7.649851,10.6468199%20C8.93565829,8.54395537%209.56601712,7.87585348%209.80853014,7.376179%20C10.0510432,6.87650453%2010.186746,6.35354574%2010.186746,6.07359783%20C10.186746,5.85875408%2010.1622996,5.34143621%2010.1183271,5.194608%20C10.0743545,5.04777979%209.91577513,4.92448122%209.76603555,4.92448122%20C9.61629596,4.92448122%209.52321367,5.03068994%209.45573454,5.194608%20C9.41074844,5.30388671%209.38730837,5.64585019%209.3854143,6.22049844%20L7.78185994,6.22049844%20C7.766021,5.63561132%207.82149491,5.18824269%207.94828164,4.87839256%20C8.13846175,4.41361735%208.39854891,4.25521839%208.68500725,4.12826526%20C8.97146558,4.00131214%209.31489006,3.93783557%209.71528069,3.93783557%20C10.4997859,3.93783557%2010.9614643,4.07010925%2011.3634826,4.45910665%20C11.7655008,4.84810404%2011.7587377,5.40267932%2011.7587377,5.99838245%20C11.7587377,6.45085641%2011.7711856,7.0028398%2011.5449486,7.50739709%20C11.3187116,8.01195438%2010.4163731,9.18005565%209.30960228,10.8174254%20L11.9223119,10.8174254%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.12.svg b/src/sf-symbols/br.12.svg new file mode 100644 index 0000000..51a2a6a --- /dev/null +++ b/src/sf-symbols/br.12.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA12%3c/title%3e%3cdesc%3eA12%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A12'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23FECB17'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M5.52652131,4.03412044%20L6.78403075,12.0676882%20L4.5980934,12.0676882%20L4.48513226,10.6171204%20L3.72613226,10.6171204%20L3.60893119,12.0676882%20L1.40413226,12.0676882%20L2.4903163,4.03412044%20L5.52652131,4.03412044%20Z%20M9.27530951,4.03412044%20L9.27530951,12.0676882%20L7.74129434,12.0676882%20L7.74129434,6.06557979%20C7.57259095,6.21550623%207.41910437,6.3404415%207.2808346,6.44038559%20C7.14256483,6.54032968%206.97696355,6.62018238%206.78403075,6.67994368%20L6.78403075,5.25108186%20C6.95993971,5.16530682%207.18383734,4.99918896%207.45572366,4.7527283%20C7.67048935,4.53099217%207.82943304,4.29145622%207.93255473,4.03412044%20L9.27530951,4.03412044%20Z%20M4.11777113,5.84858991%20L3.71144646,9.1892807%20L3.71413226,9.19012044%20L4.37513226,9.19012044%20L4.11777113,5.84858991%20Z%20M14.6286546,10.8229492%20L14.6286546,12%20L10.3561937,12%20L10.3561937,10.6523438%20C11.642001,8.54947917%2012.2723598,7.88137728%2012.5148728,7.38170281%20C12.7573858,6.88202833%2012.8930887,6.35906955%2012.8930887,6.07912163%20C12.8930887,5.86427788%2012.8686423,5.34696002%2012.8246698,5.2001318%20C12.7806972,5.05330359%2012.6221178,4.93000502%2012.4723782,4.93000502%20C12.3226386,4.93000502%2012.2295564,5.03621375%2012.1620772,5.2001318%20C12.1170911,5.30941051%2012.0936511,5.65137399%2012.091757,6.22602224%20L10.4882026,6.22602224%20C10.4723637,5.64113512%2010.5278376,5.19376649%2010.6546243,4.88391636%20C10.8448044,4.41914116%2011.1048916,4.26074219%2011.3913499,4.13378906%20C11.6778083,4.00683594%2012.0212327,3.94335938%2012.4216234,3.94335938%20C13.2061286,3.94335938%2013.667807,4.07563305%2014.0698253,4.46463045%20C14.4718435,4.85362785%2014.4650804,5.40820313%2014.4650804,6.00390625%20C14.4650804,6.45638021%2014.4775283,7.0083636%2014.2512913,7.51292089%20C14.0250543,8.01747818%2013.1227158,9.18557945%2012.015945,10.8229492%20L14.6286546,10.8229492%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.14.official.svg b/src/sf-symbols/br.14.official.svg new file mode 100644 index 0000000..e2fdad7 --- /dev/null +++ b/src/sf-symbols/br.14.official.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e14%3c/title%3e%3cdesc%3e14%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/14'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23F5821F'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M3.90988528,5.20017753%20C4.08579424,5.11440248%204.30969187,4.94828463%204.58157819,4.70182396%20C4.79634388,4.48008783%204.95528757,4.24055188%205.05840926,3.98321611%20L6.40116403,3.98321611%20L6.40116403,12.0167839%20L4.86714887,12.0167839%20L4.86714887,6.01467545%20C4.69844548,6.16460189%204.5449589,6.28953716%204.40668913,6.38948125%20C4.26841936,6.48942535%204.10281808,6.56927804%203.90988528,6.62903935%20L3.90988528,5.20017753%20Z%20M11.1129635,3.98321611%20L11.1129635,9.00245547%20L12.0901147,9.00245547%20L12.0901147,10.2018294%20L11.1129635,10.2018294%20L11.1129635,11.9547345%20L9.59811451,11.9547345%20L9.59811451,10.2018294%20L7.41855117,10.2018294%20L7.41855117,8.91521973%20L8.8770918,3.98321611%20L11.1129635,3.98321611%20Z%20M9.64833266,8.97521103%20L9.69733561,5.12030612%20L8.62646361,8.97521103%20L9.64833266,8.97521103%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.14.svg b/src/sf-symbols/br.14.svg new file mode 100644 index 0000000..2d5749c --- /dev/null +++ b/src/sf-symbols/br.14.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA14%3c/title%3e%3cdesc%3eA14%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A14'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23ED6A13'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M5.36080741,4.03412044%20L6.61831686,12.0676882%20L4.43237951,12.0676882%20L4.31941837,10.6171204%20L3.56041837,10.6171204%20L3.4432173,12.0676882%20L1.23841837,12.0676882%20L2.32460241,4.03412044%20L5.36080741,4.03412044%20Z%20M9.10959561,4.03412044%20L9.10959561,12.0676882%20L7.57558045,12.0676882%20L7.57558045,6.06557979%20C7.40687706,6.21550623%207.25339048,6.3404415%207.11512071,6.44038559%20C6.97685094,6.54032968%206.81124966,6.62018238%206.61831686,6.67994368%20L6.61831686,5.25108186%20C6.79422582,5.16530682%207.01812345,4.99918896%207.29000977,4.7527283%20C7.50477546,4.53099217%207.66371915,4.29145622%207.76684084,4.03412044%20L9.10959561,4.03412044%20Z%20M3.95205724,5.84858991%20L3.54573257,9.1892807%20L3.54841837,9.19012044%20L4.20941837,9.19012044%20L3.95205724,5.84858991%20Z%20M13.8213951,4.03412044%20L13.8213951,9.05335981%20L14.7985463,9.05335981%20L14.7985463,10.2527337%20L13.8213951,10.2527337%20L13.8213951,12.0056388%20L12.3065461,12.0056388%20L12.3065461,10.2527337%20L10.1269828,10.2527337%20L10.1269828,8.96612407%20L11.5855234,4.03412044%20L13.8213951,4.03412044%20Z%20M12.3567642,9.02611537%20L12.4057672,5.17121046%20L11.3348952,9.02611537%20L12.3567642,9.02611537%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.16.official.svg b/src/sf-symbols/br.16.official.svg new file mode 100644 index 0000000..0dbb2f1 --- /dev/null +++ b/src/sf-symbols/br.16.official.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e16%3c/title%3e%3cdesc%3e16%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/16'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23EB1A25'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M11.8519926,5.86097341%20L10.1976942,5.86097341%20C10.1976942,5.49636184%2010.1942819,5.34649709%2010.1874572,5.25131088%20C10.1806325,5.15612467%2010.1478742,4.97949788%2010.0891821,4.91496486%20C10.03049,4.85043183%209.9506414,4.81816532%209.84963638,4.81816532%20C9.76501056,4.81816532%209.695399,4.84881851%209.64080169,4.91012488%20C9.58620439,4.97143125%209.55481093,5.14725138%209.54662134,5.24405092%20C9.53843174,5.34085045%209.53433695,5.70878031%209.53433695,6.00240557%20L9.53433695,7.33405522%20C9.59337476,7.22917217%209.73493322,7.10372254%209.89872513,6.99885637%20C10.062517,6.89399021%2010.2750591,6.83785659%2010.5152872,6.83785659%20C10.8183023,6.83785659%2011.1147325,6.86171678%2011.3467711,7.05531585%20C11.5788096,7.24891492%2011.677464,7.42821654%2011.7402509,7.69925523%20C11.8030378,7.97029392%2011.8519926,8.24654141%2011.8519926,8.70795252%20L11.8519926,9.33230951%20C11.8519926,9.8808402%2011.8342484,10.2890116%2011.7987602,10.5568236%20C11.7632719,10.8246356%2011.6684091,11.0722811%2011.5141717,11.29976%20C11.3599343,11.5272389%2011.1483698,11.7038981%2010.8794781,11.8297375%20C10.6105863,11.9555769%2010.2973343,12.0184966%209.93972194,12.0184966%20C9.4947539,12.0184966%208.99713197,11.8967332%208.71322598,11.7515339%20C8.42932,11.6063346%208.34286742,11.487862%208.18863004,11.21521%20C8.03439265,10.942558%207.94487289,10.6149033%207.91893917,10.3148248%20C7.89300545,10.0147462%207.88003859,9.43233569%207.88003859,8.5675932%20L7.88003859,7.48343842%20C7.88003859,6.55093625%207.89164052,5.92738593%207.91484437,5.61278744%20C7.93804823,5.29818896%208.08417058,4.81307074%208.25069236,4.5323521%20C8.41721414,4.25163345%208.59321589,4.15297913%208.88121668,4.01261981%20C9.16921747,3.87226048%209.48929417,3.85220028%209.86601558,3.85220028%20C10.3300927,3.85220028%2010.7907565,3.87444143%2011.0883118,4.05190724%20C11.3858671,4.22937306%2011.5566647,4.39714664%2011.6849684,4.71981175%20C11.813272,5.04247686%2011.8519926,5.37052244%2011.8519926,5.86097341%20Z%20M10.2551658,8.71865495%20C10.2551658,8.4343801%2010.2247384,8.23935434%2010.1638837,8.13357765%20C10.1030289,8.02780096%2010.0037395,7.97491262%209.86601558,7.97491262%20C9.73149451,7.97491262%209.63300587,8.02532182%209.57054965,8.12614023%20C9.50809344,8.22695863%209.47686534,8.42446354%209.47686534,8.71865495%20L9.47686534,10.1714316%20C9.47686534,10.5251224%209.506492,10.7565089%209.56574533,10.8655912%20C9.62499866,10.9746734%209.7234873,11.0292145%209.86121125,11.0292145%20C9.9444862,11.0292145%2010.030964,10.9870691%2010.1206447,10.9027783%20C10.2103255,10.8184875%2010.2551658,10.5912329%2010.2551658,10.2210145%20L10.2551658,8.71865495%20Z%20M4.10542164,5.20542637%20C4.27850296,5.12098829%204.49880156,4.95745971%204.76631745,4.71484062%20C4.9776309,4.49656068%205.13401965,4.26075836%205.23548372,4.00743367%20L6.55665444,4.00743367%20L6.55665444,11.9157826%20L5.04729773,11.9157826%20L5.04729773,6.00722874%20C4.88130616,6.15481829%204.7302868,6.27780619%204.59423964,6.37619246%20C4.45819248,6.47457873%204.29525315,6.55318677%204.10542164,6.61201658%20L4.10542164,5.20542637%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%20fill-rule='nonzero'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.16.svg b/src/sf-symbols/br.16.svg new file mode 100644 index 0000000..4b74086 --- /dev/null +++ b/src/sf-symbols/br.16.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eA16%3c/title%3e%3cdesc%3eA16%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/A16'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23DC061D'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M14.6274878,5.89277984%20L12.9461631,5.89277984%20C12.9461631,5.52239509%2012.942695,5.37015742%2012.9357588,5.27346406%20C12.9288227,5.1767707%2012.8955291,4.99734724%2012.8358782,4.93179241%20C12.7762272,4.86623759%2012.6950741,4.83346018%2012.592419,4.83346018%20C12.5064106,4.83346018%2012.4356618,4.86459872%2012.3801726,4.9268758%20C12.3246833,4.98915288%2012.292777,5.16775691%2012.2844536,5.26608914%20C12.2761302,5.36442138%2012.2719685,5.73817695%2012.2719685,6.0364514%20L12.2719685,7.38918607%20C12.3319708,7.28264233%2012.4758419,7.15520635%2012.6423097,7.04867977%20C12.8087775,6.94215318%2013.0247919,6.88513075%2013.2689446,6.88513075%20C13.57691,6.88513075%2013.8781831,6.90936874%2014.1140125,7.10603321%20C14.3498419,7.30269768%2014.4501079,7.48483831%2014.5139206,7.76016857%20C14.5777332,8.03549882%2014.6274878,8.31612035%2014.6274878,8.78483733%20L14.6274878,9.41908023%20C14.6274878,9.97629623%2014.6094538,10.3909305%2014.5733858,10.662983%20C14.5373178,10.9350355%2014.4409052,11.1866021%2014.284148,11.4176829%20C14.1273908,11.6487636%2013.9123699,11.82822%2013.6390853,11.9560519%20C13.3658006,12.0838838%2013.047431,12.1477997%2012.6839763,12.1477997%20C12.2317388,12.1477997%2011.7259872,12.0241084%2011.437443,11.8766101%20C11.1488988,11.7291117%2011.0610339,11.6087633%2010.9042767,11.3317941%20C10.7475195,11.054825%2010.6565373,10.7219823%2010.6301799,10.4171524%20C10.6038225,10.1123225%2010.5906438,9.52069021%2010.5906438,8.64225559%20L10.5906438,7.54093457%20C10.5906438,6.59366739%2010.6024352,5.96024392%2010.6260182,5.64066416%20C10.6496011,5.3210844%2010.7981107,4.82828493%2010.9673529,4.54312146%20C11.1365952,4.25795798%2011.3154723,4.15774159%2011.6081782,4.01515985%20C11.900884,3.87257811%2012.2261899,3.85220028%2012.6090658,3.85220028%20C13.0807245,3.85220028%2013.5489143,3.87479359%2013.8513308,4.05506936%20C14.1537473,4.23534512%2014.3273352,4.40577519%2014.4577349,4.7335493%20C14.5881347,5.06132341%2014.6274878,5.39456319%2014.6274878,5.89277984%20Z%20M13.0045736,8.79570922%20C13.0045736,8.50693323%2012.9736491,8.30881947%2012.9118002,8.20136794%20C12.8499512,8.09391641%2012.7490397,8.04019065%2012.6090658,8.04019065%20C12.472347,8.04019065%2012.3722494,8.09139802%2012.3087728,8.19381276%20C12.2452963,8.2962275%2012.213558,8.49685965%2012.213558,8.79570922%20L12.213558,10.2714888%20C12.213558,10.6307799%2012.2436686,10.8658301%2012.30389,10.9766395%20C12.3641114,11.0874489%2012.464209,11.1428536%2012.604183,11.1428536%20C12.6888184,11.1428536%2012.776709,11.1000409%2012.8678548,11.0144154%20C12.9590007,10.92879%2013.0045736,10.6979371%2013.0045736,10.3218567%20L13.0045736,8.79570922%20Z%20M5.36518653,4.0098916%20L6.62269598,12.0434594%20L4.43675863,12.0434594%20L4.32379749,10.5928916%20L3.56479749,10.5928916%20L3.44759642,12.0434594%20L1.24279749,12.0434594%20L2.32898153,4.0098916%20L5.36518653,4.0098916%20Z%20M3.95643636,5.82436107%20L3.55011169,9.16505185%20L3.55279749,9.1658916%20L4.21379749,9.1658916%20L3.95643636,5.82436107%20Z%20M6.75436062,5.22685302%20C6.93026958,5.14107797%207.15416722,4.97496012%207.42605353,4.72849946%20C7.64081922,4.50676332%207.79976291,4.26722737%207.9028846,4.0098916%20L9.24563938,4.0098916%20L9.24563938,12.0434594%20L7.71162421,12.0434594%20L7.71162421,6.04135094%20C7.54292082,6.19127739%207.38943424,6.31621265%207.25116447,6.41615674%20C7.11289471,6.51610084%206.94729342,6.59595354%206.75436062,6.65571484%20L6.75436062,5.22685302%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%20fill-rule='nonzero'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.18.official.svg b/src/sf-symbols/br.18.official.svg new file mode 100644 index 0000000..8c71b5f --- /dev/null +++ b/src/sf-symbols/br.18.official.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3e18%3c/title%3e%3cdesc%3e18%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/18'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%23000000'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M9.86034765,3.95527548%20L9.876648,3.95527548%20L9.876648,4.92127548%20L9.80903388,4.92714217%20C9.70875828,4.94537019%209.6384501,5.00005426%209.59810934,5.09119436%20C9.5476834,5.2051195%209.52247042,5.41797961%209.52247042,5.72977472%20L9.52247042,6.46729006%20C9.52247042,6.75909829%209.55128525,6.96096563%209.6089149,7.07289208%20C9.66299812,7.17793075%209.75197541,7.23368204%209.87584677,7.24014596%20L9.877573,8.40279572%20C9.87409042,8.40273335%209.87058445,8.40270217%209.86705511,8.40270217%20C9.7276029,8.40270217%209.62737163,8.45217369%209.5663613,8.55111674%20C9.50535096,8.65005978%209.47484579,8.85884637%209.47484579,9.17747652%20L9.47484579,10.233987%20C9.47484579,10.5861571%209.50883726,10.8125522%209.57682021,10.9131723%20C9.64136306,11.0087007%209.74125792,11.0588819%209.8765048,11.0637159%20L9.87707939,12.0447245%20L9.87707939,12.0447245%20C9.375127,12.0447245%208.94201772,11.9412604%208.64921216,11.7583936%20C8.3564066,11.5755269%208.22903181,11.4566445%208.1021494,11.1550747%20C7.97526699,10.8535049%207.923648,10.3363638%207.923648,9.63377035%20C7.923648,9.16216653%207.92852809,8.74535394%208.2374232,8.32126591%20C8.54631831,7.89717787%208.66225725,7.98475532%208.99410356,7.77622303%20C8.46326742,7.62051075%208.15543999,7.25533326%208.0554105,6.92445457%20C7.95538102,6.59357589%207.923648,6.35500099%207.923648,5.9668101%20C7.923648,5.29629856%208.09116301,4.76393329%208.41464344,4.42867752%20C8.73812387,4.09342175%209.21617541,3.95527548%209.86034765,3.95527548%20Z%20M9.89479836,3.95527548%20L9.87849801,3.95527548%20L9.87849801,4.92127548%20L9.94611213,4.92714217%20C10.0463877,4.94537019%2010.1166959,5.00005426%2010.1570367,5.09119436%20C10.2074626,5.2051195%2010.2326756,5.41797961%2010.2326756,5.72977472%20L10.2326756,6.46729006%20C10.2326756,6.75909829%2010.2038608,6.96096563%2010.1462311,7.07289208%20C10.0921479,7.17793075%2010.0031706,7.23368204%209.87929924,7.24014596%20L9.877573,8.40279572%20C9.88105559,8.40273335%209.88456156,8.40270217%209.8880909,8.40270217%20C10.0275431,8.40270217%2010.1277744,8.45217369%2010.1887847,8.55111674%20C10.2497951,8.65005978%2010.2803002,8.85884637%2010.2803002,9.17747652%20L10.2803002,10.233987%20C10.2803002,10.5861571%2010.2463087,10.8125522%2010.1783258,10.9131723%20C10.113783,11.0087007%2010.0138881,11.0588819%209.87864121,11.0637159%20L9.87806662,12.0447245%20L9.87806662,12.0447245%20C10.380019,12.0447245%2010.8131283,11.9412604%2011.1059338,11.7583936%20C11.3987394,11.5755269%2011.5261142,11.4566445%2011.6529966,11.1550747%20C11.779879,10.8535049%2011.831498,10.3363638%2011.831498,9.63377035%20C11.831498,9.16216653%2011.8266179,8.74535394%2011.5177228,8.32126591%20C11.2088277,7.89717787%2011.0928888,7.98475532%2010.7610425,7.77622303%20C11.2918786,7.62051075%2011.599706,7.25533326%2011.6997355,6.92445457%20C11.799765,6.59357589%2011.831498,6.35500099%2011.831498,5.9668101%20C11.831498,5.29629856%2011.663983,4.76393329%2011.3405026,4.42867752%20C11.0170221,4.09342175%2010.5389706,3.95527548%209.89479836,3.95527548%20Z%20M4.16850199,5.31425825%20C4.34218167,5.22957023%204.56324187,5.06555757%204.8316826,4.82222028%20C5.04372659,4.60329419%205.200656,4.36679385%205.30247084,4.11271927%20L6.628209,4.11271927%20L6.628209,12.0444784%20L5.11363427,12.0444784%20L5.11363427,6.11843412%20C4.94706884,6.26646056%204.79552738,6.38981253%204.6590099,6.48849004%20C4.52249241,6.58716755%204.35898977,6.66600828%204.16850199,6.72501224%20L4.16850199,5.31425825%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%20fill-rule='nonzero'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.18.svg b/src/sf-symbols/br.18.svg new file mode 100644 index 0000000..faa47f6 --- /dev/null +++ b/src/sf-symbols/br.18.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__DRMmrmK5__"
\ No newline at end of file diff --git a/src/sf-symbols/br.l.official.svg b/src/sf-symbols/br.l.official.svg new file mode 100644 index 0000000..fc8d788 --- /dev/null +++ b/src/sf-symbols/br.l.official.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eL%3c/title%3e%3cdesc%3eL%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/L'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%2300A54F'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M5.8268717,11.5906405%20L5.8268717,4.48614501%20L7.75215401,4.48614501%20L7.7518717,10.205145%20L10.9518537,10.2056233%20L10.9518537,11.5906405%20L5.8268717,11.5906405%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/br.l.svg b/src/sf-symbols/br.l.svg new file mode 100644 index 0000000..0f22cae --- /dev/null +++ b/src/sf-symbols/br.l.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3csvg%20width='16px'%20height='16px'%20viewBox='0%200%2016%2016'%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%3e%3ctitle%3eAL%3c/title%3e%3cdesc%3eA%20L%3c/desc%3e%3cg%20id='_Assets/Badge/badge_Ratings_Brazil/AL'%20stroke='none'%20stroke-width='1'%20fill='none'%20fill-rule='evenodd'%3e%3cg%20id='Vector-Trace'%3e%3crect%20id='Plate'%20fill='%232D973D'%20x='0'%20y='0'%20width='16'%20height='16'%20rx='1.3'%3e%3c/rect%3e%3cpath%20d='M6.40808555,4.60711452%20L7.47319531,11.4115417%20L5.62170765,11.4115417%20L5.52642444,10.1831145%20L4.88342444,10.1831145%20L4.78388784,11.4115417%20L2.91642444,11.4115417%20L3.83642168,4.60711452%20L6.40808555,4.60711452%20Z%20M5.21487497,6.1439691%20L4.87071821,8.97353224%20L4.87342444,8.97411452%20L5.43342444,8.97411452%20L5.21487497,6.1439691%20Z%20M8.28190748,11.4115417%20L8.28190748,4.60711452%20L9.865707,4.60711452%20L9.86490748,9.80711452%20L13.0998906,9.80754771%20L13.0998906,11.4115417%20L8.28190748,11.4115417%20Z'%20id='Combined-Shape'%20fill='%23FFFFFF'%3e%3c/path%3e%3c/g%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/captions.bubble.fill.svg b/src/sf-symbols/captions.bubble.fill.svg new file mode 100644 index 0000000..7e7a9da --- /dev/null +++ b/src/sf-symbols/captions.bubble.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2021.8848%2021.5723'%3e%3cg%3e%3crect%20height='21.5723'%20opacity='0'%20width='21.8848'%20x='0'%20y='0'/%3e%3cpath%20d='M21.5234%205.78125L21.5234%2013.2617C21.5234%2016.123%2019.9609%2017.7246%2017.0508%2017.7246L10.4492%2017.7246L6.92383%2020.9473C6.46484%2021.377%206.18164%2021.5723%205.80078%2021.5723C5.24414%2021.5723%204.93164%2021.1719%204.93164%2020.5664L4.93164%2017.7246L4.47266%2017.7246C1.5625%2017.7246%200%2016.1328%200%2013.2617L0%205.78125C0%202.91016%201.5625%201.30859%204.47266%201.30859L17.0508%201.30859C19.9609%201.30859%2021.5234%202.91992%2021.5234%205.78125ZM4.4043%2012.0801C4.08203%2012.0801%203.81836%2012.3438%203.81836%2012.6758C3.81836%2012.998%204.08203%2013.252%204.4043%2013.252L6.11328%2013.252C6.43555%2013.252%206.68945%2012.998%206.68945%2012.6758C6.68945%2012.3438%206.43555%2012.0801%206.11328%2012.0801ZM8.18359%2012.0801C7.86133%2012.0801%207.60742%2012.3438%207.60742%2012.6758C7.60742%2012.998%207.86133%2013.252%208.18359%2013.252L13.2324%2013.252C13.5547%2013.252%2013.8184%2012.998%2013.8184%2012.6758C13.8184%2012.3438%2013.5547%2012.0801%2013.2324%2012.0801ZM15.3125%2012.0801C14.9902%2012.0801%2014.7266%2012.3438%2014.7266%2012.6758C14.7266%2012.998%2014.9902%2013.252%2015.3125%2013.252L17.1387%2013.252C17.4609%2013.252%2017.7148%2012.998%2017.7148%2012.6758C17.7148%2012.3438%2017.4609%2012.0801%2017.1387%2012.0801ZM4.4043%209.45312C4.08203%209.45312%203.81836%209.7168%203.81836%2010.0293C3.81836%2010.3613%204.08203%2010.625%204.4043%2010.625L8.54492%2010.625C8.86719%2010.625%209.13086%2010.3613%209.13086%2010.0293C9.13086%209.7168%208.86719%209.45312%208.54492%209.45312ZM10.625%209.45312C10.3027%209.45312%2010.0391%209.7168%2010.0391%2010.0293C10.0391%2010.3613%2010.3027%2010.625%2010.625%2010.625L17.1387%2010.625C17.4609%2010.625%2017.7148%2010.3613%2017.7148%2010.0293C17.7148%209.7168%2017.4609%209.45312%2017.1387%209.45312Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/chart.bar.fill.svg b/src/sf-symbols/chart.bar.fill.svg new file mode 100644 index 0000000..0f081f8 --- /dev/null +++ b/src/sf-symbols/chart.bar.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20147.559%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='147.559'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M110.387%2090.086L124.947%2090.086C131.66%2090.086%20135.105%2086.796%20135.105%2080.403L135.105%208.423C135.105%202.019%20131.66-1.218%20124.947-1.218L110.387-1.218C103.715-1.218%20100.28%202.019%20100.28%208.423L100.28%2080.403C100.28%2086.796%20103.715%2090.086%20110.387%2090.086Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3cpath%20d='M66.498%2090.086L81.068%2090.086C87.782%2090.086%2091.227%2086.796%2091.227%2080.403L91.227%2022.701C91.227%2016.297%2087.782%2013.059%2081.068%2013.059L66.498%2013.059C59.836%2013.059%2056.34%2016.297%2056.34%2022.701L56.34%2080.403C56.34%2086.796%2059.836%2090.086%2066.498%2090.086Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3cpath%20d='M22.567%2090.086L37.128%2090.086C43.851%2090.086%2047.286%2086.796%2047.286%2080.403L47.286%2036.863C47.286%2030.47%2043.851%2027.232%2037.128%2027.232L22.567%2027.232C15.906%2027.232%2012.461%2030.47%2012.461%2036.863L12.461%2080.403C12.461%2086.796%2015.906%2090.086%2022.567%2090.086Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/checkmark.circle.svg b/src/sf-symbols/checkmark.circle.svg new file mode 100644 index 0000000..f92c0a9 --- /dev/null +++ b/src/sf-symbols/checkmark.circle.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2099.598%2099.547'%3e%3cpath%20d='M49.773%2099.547C76.997%2099.547%2099.598%2076.997%2099.598%2049.773%2099.598%2022.55%2076.945%200%2049.722%200%2022.54%200%200%2022.55%200%2049.773%200%2076.997%2022.591%2099.547%2049.773%2099.547ZM49.773%2091.279C26.788%2091.28%208.36%2072.811%208.36%2049.773%208.36%2026.736%2026.736%208.267%2049.722%208.267%2072.759%208.267%2091.279%2026.736%2091.279%2049.773A41.36%2041.36%200%200%201%2049.773%2091.28Z'%20fill='%23000000'%3e%3c/path%3e%3cpath%20d='M44.447%2072.95C46.032%2072.95%2047.379%2072.185%2048.374%2070.683L70.672%2035.575C71.252%2034.612%2071.842%2033.513%2071.842%2032.436%2071.842%2030.271%2069.936%2028.872%2067.863%2028.872%2066.61%2028.872%2065.397%2029.597%2064.465%2031.058L44.229%2063.563%2034.62%2051.098C33.426%2049.534%2032.338%2049.14%2031.012%2049.14%2028.899%2049.14%2027.24%2050.83%2027.24%2053.015%2027.241%2054.062%2027.687%2055.11%2028.351%2056.041L40.26%2070.682C41.515%2072.298%2042.8%2072.951%2044.447%2072.951Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/checkmark.svg b/src/sf-symbols/checkmark.svg new file mode 100644 index 0000000..11f6a18 --- /dev/null +++ b/src/sf-symbols/checkmark.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2017.1875%2017.2363'%3e%3cg%3e%3crect%20height='17.2363'%20opacity='0'%20width='17.1875'%20x='0'%20y='0'/%3e%3cpath%20d='M6.36719%2017.2363C6.78711%2017.2363%207.11914%2017.0508%207.35352%2016.6895L16.582%202.1582C16.7578%201.875%2016.8262%201.66016%2016.8262%201.43555C16.8262%200.898438%2016.4746%200.546875%2015.9375%200.546875C15.5469%200.546875%2015.332%200.673828%2015.0977%201.04492L6.32812%2015.0195L1.77734%209.0625C1.5332%208.7207%201.28906%208.58398%200.9375%208.58398C0.380859%208.58398%200%208.96484%200%209.50195C0%209.72656%200.0976562%209.98047%200.283203%2010.2148L5.35156%2016.6699C5.64453%2017.0508%205.94727%2017.2363%206.36719%2017.2363Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/chevron.down.svg b/src/sf-symbols/chevron.down.svg new file mode 100644 index 0000000..d31473d --- /dev/null +++ b/src/sf-symbols/chevron.down.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20109.73%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='109.73'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M54.884%2070.758C56.118%2070.758%2057.319%2070.281%2058.17%2069.327L95.95%2030.654C96.768%2029.825%2097.266%2028.769%2097.266%2027.515C97.266%2024.977%2095.359%2023.019%2092.822%2023.019C91.63%2023.019%2090.439%2023.547%2089.62%2024.314L52.251%2062.483L57.476%2062.483L20.096%2024.314C19.288%2023.547%2018.189%2023.019%2016.957%2023.019C14.408%2023.019%2012.461%2024.977%2012.461%2027.515C12.461%2028.769%2012.969%2029.835%2013.787%2030.665L51.609%2069.338C52.5%2070.291%2053.599%2070.758%2054.884%2070.758Z'%20fill='%23000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/chevron.forward.svg b/src/sf-symbols/chevron.forward.svg new file mode 100644 index 0000000..9fda468 --- /dev/null +++ b/src/sf-symbols/chevron.forward.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2051.108%2087.687'%3e%3cpath%20d='M51.108%2043.834C51.09%2041.837%2050.39%2040.196%2048.774%2038.604L11.214%201.877C9.974%200.6%208.453%200%206.64%200%202.96%200%200.001%202.897%200.001%206.55%200%208.326%200.765%2010.004%202.068%2011.344L35.466%2043.816%202.07%2076.325C0.784%2077.665%200%2079.305%200%2081.137%200%2084.79%202.96%2087.687%206.641%2087.687%208.437%2087.687%209.975%2087.086%2011.213%2085.81L48.774%2049.064C50.41%2047.473%2051.108%2045.814%2051.108%2043.834Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/chevron.right.svg b/src/sf-symbols/chevron.right.svg new file mode 100644 index 0000000..6b40bb0 --- /dev/null +++ b/src/sf-symbols/chevron.right.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2072.648%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='72.648'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M66.42%2044.739C66.42%2043.516%2065.974%2042.439%2065.031%2041.506L26.337%203.684C25.518%202.814%2024.43%202.379%2023.187%202.379C20.649%202.379%2018.691%204.274%2018.691%206.823C18.691%208.014%2019.23%209.143%2019.986%2010.003L55.521%2044.739L19.986%2079.476C19.23%2080.335%2018.691%2081.413%2018.691%2082.656C18.691%2085.204%2020.649%2087.1%2023.187%2087.1C24.43%2087.1%2025.518%2086.665%2026.337%2085.795L65.031%2047.972C65.974%2047.04%2066.42%2045.962%2066.42%2044.739Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/circle.dotted.and.circle.svg b/src/sf-symbols/circle.dotted.and.circle.svg new file mode 100644 index 0000000..81e81b1 --- /dev/null +++ b/src/sf-symbols/circle.dotted.and.circle.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__BFr3gotS__"
\ No newline at end of file diff --git a/src/sf-symbols/circle.lefthalf.filled.inverse.svg b/src/sf-symbols/circle.lefthalf.filled.inverse.svg new file mode 100644 index 0000000..b10207d --- /dev/null +++ b/src/sf-symbols/circle.lefthalf.filled.inverse.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.2832%2019.9316'%3e%3cg%3e%3crect%20height='19.9316'%20opacity='0'%20width='20.2832'%20x='0'%20y='0'/%3e%3cpath%20d='M9.96094%2018.8867C15.0391%2018.8867%2018.8965%2015.0391%2018.8965%209.96094C18.8965%204.88281%2015.0391%201.02539%209.96094%201.02539ZM9.96094%2019.9219C15.459%2019.9219%2019.9219%2015.459%2019.9219%209.96094C19.9219%204.46289%2015.459%200%209.96094%200C4.46289%200%200%204.46289%200%209.96094C0%2015.459%204.46289%2019.9219%209.96094%2019.9219ZM9.96094%2018.2617C5.37109%2018.2617%201.66016%2014.5508%201.66016%209.96094C1.66016%205.37109%205.37109%201.66016%209.96094%201.66016C14.5508%201.66016%2018.2617%205.37109%2018.2617%209.96094C18.2617%2014.5508%2014.5508%2018.2617%209.96094%2018.2617Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/clock.fill.svg b/src/sf-symbols/clock.fill.svg new file mode 100644 index 0000000..be42d66 --- /dev/null +++ b/src/sf-symbols/clock.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20120'%3e%3cpath%20d='M32.962,65.218c-1.938,0%20-3.357,-1.482%20-3.357,-3.368c0,-1.906%201.419,-3.399%203.357,-3.399h22.135v-29.49c0,-1.927%201.493,-3.398%203.347,-3.398c1.928,0%203.42,1.471%203.42,3.398v32.889c0,1.886%20-1.492,3.368%20-3.42,3.368zM58.496,109.742c27.482,0%2049.825,-22.344%2049.825,-49.774c0,-27.43%20-22.343,-49.773%20-49.825,-49.773c-27.43,0%20-49.773,22.343%20-49.773,49.773c0,27.43%2022.343,49.774%2049.773,49.774z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/creditcard.fill.svg b/src/sf-symbols/creditcard.fill.svg new file mode 100644 index 0000000..25a044d --- /dev/null +++ b/src/sf-symbols/creditcard.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20139.624%20120'%3e%3cpath%20d='M32.404,86.568c-2.866,0%20-4.797,-1.931%20-4.797,-4.672v-9.045c0,-2.742%201.931,-4.673%204.797,-4.673h11.963c2.866,0%204.797,1.931%204.797,4.673v9.045c0,2.741%20-1.931,4.672%20-4.797,4.672zM12.461,49.113v-11.099h114.711v11.099zM27.779,101.725h84.086c10.232,0%2015.307,-5.056%2015.307,-15.111v-53.145c0,-10.056%20-5.075,-15.111%20-15.307,-15.111h-84.086c-10.18,0%20-15.318,5.034%20-15.318,15.111v53.145c0,10.076%205.138,15.111%2015.318,15.111z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/ellipsis.circle.fill.svg b/src/sf-symbols/ellipsis.circle.fill.svg new file mode 100644 index 0000000..57de095 --- /dev/null +++ b/src/sf-symbols/ellipsis.circle.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M32.09%2061.568c16.185%200%2029.586-13.43%2029.586-29.587%200-16.186-13.43-29.587-29.616-29.587-16.157%200-29.558%2013.4-29.558%2029.587%200%2016.156%2013.43%2029.587%2029.587%2029.587zM18.078%2036.332c-2.379%200-4.351-1.944-4.351-4.38%200-2.408%201.972-4.351%204.35-4.351%202.408%200%204.381%201.943%204.381%204.35a4.358%204.358%200%2001-4.38%204.38zm13.981%200a4.376%204.376%200%2001-4.38-4.38c0-2.408%201.973-4.351%204.38-4.351a4.345%204.345%200%20014.351%204.35%204.352%204.352%200%2001-4.35%204.38zm13.981%200a4.358%204.358%200%2001-4.38-4.38%204.352%204.352%200%20014.38-4.351c2.38%200%204.352%201.943%204.352%204.35%200%202.437-1.973%204.38-4.352%204.38z'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/eye.fill.svg b/src/sf-symbols/eye.fill.svg new file mode 100644 index 0000000..fbedd51 --- /dev/null +++ b/src/sf-symbols/eye.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20152.271%20120'%3e%3cpath%20d='M76.158,101.46c39.098,0%2066.137,-31.607%2066.137,-41.492c0,-9.936%20-27.092,-41.492%20-66.137,-41.492c-38.601,0%20-66.189,31.556%20-66.189,41.492c0,9.885%2027.557,41.492%2066.189,41.492zM76.158,87.225c-15.077,0%20-27.309,-12.18%20-27.309,-27.257c0,-15.076%2012.232,-27.257%2027.309,-27.257c15.077,0%2027.257,12.181%2027.257,27.257c0,15.077%20-12.18,27.257%20-27.257,27.257zM76.158,69.921c5.51,0%209.952,-4.432%209.952,-9.953c0,-5.521%20-4.442,-9.952%20-9.952,-9.952c-5.521,0%20-9.952,4.431%20-9.952,9.952c0,5.521%204.431,9.953%209.952,9.953z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/figure.svg b/src/sf-symbols/figure.svg new file mode 100644 index 0000000..470fb29 --- /dev/null +++ b/src/sf-symbols/figure.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20109.728%20120'%3e%3cpath%20d='M54.817,27.328c5.917,0%2010.63,-4.713%2010.63,-10.641c0,-5.865%20-4.713,-10.63%20-10.63,-10.63c-5.876,0%20-10.64,4.765%20-10.64,10.63c0,5.928%204.764,10.641%2010.64,10.641zM54.817,73.163c2.046,0%202.939,1.278%204.008,4.87c1.556,4.817%206.778,25.362%208.233,31.373c0.685,3.062%202.304,4.308%204.619,4.308c3.145,0%204.971,-2.844%204.131,-5.886c-0.364,-1.287%20-7.465,-30.916%20-8.409,-37.051c-0.944,-6.209%20-0.956,-19.84%20-0.946,-24.054c0.011,-2.919%201.35,-4.787%203.749,-5.275c2.326,-0.54%2020.95,-2.576%2023.493,-3.364c2.118,-0.684%203.571,-2.366%203.571,-4.619c0,-2.823%20-2.189,-4.535%20-4.183,-4.535c-0.841,0%20-1.569,0.186%20-2.515,0.384c-7.586,1.391%20-24.062,3.155%20-35.751,3.155c-11.596,0%20-28.124,-1.661%20-35.761,-3.155c-0.843,-0.198%20-1.622,-0.384%20-2.464,-0.384c-2.045,0%20-4.131,1.712%20-4.131,4.535c0,2.253%201.349,4.142%203.52,4.619c2.646,0.581%2021.167,2.928%2023.492,3.364c2.399,0.436%203.79,2.356%203.852,5.275c0.063,4.214%20-0.104,17.845%20-1.048,24.054c-0.996,6.135%20-8.098,35.764%20-8.461,37.051c-0.841,3.042%201.038,5.886%204.183,5.886c2.263,0%203.83,-1.246%204.671,-4.308c1.661,-5.959%206.624,-26.452%208.232,-31.373c1.121,-3.488%201.921,-4.87%203.915,-4.87z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/gamecontroller.fill.svg b/src/sf-symbols/gamecontroller.fill.svg new file mode 100644 index 0000000..28ebb03 --- /dev/null +++ b/src/sf-symbols/gamecontroller.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20165.869%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='165.869'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M42.211%2034.999C42.211%2032.461%2043.755%2030.865%2046.407%2030.865L55.392%2030.865L55.392%2022.126C55.392%2019.526%2056.884%2017.93%2059.422%2017.93C61.908%2017.93%2063.401%2019.526%2063.401%2022.126L63.401%2030.865L71.879%2030.865C74.737%2030.865%2076.385%2032.461%2076.385%2034.999C76.385%2037.64%2074.737%2039.236%2071.879%2039.236L63.401%2039.236L63.401%2048.026C63.401%2050.627%2061.908%2052.212%2059.422%2052.212C56.884%2052.212%2055.392%2050.627%2055.392%2048.026L55.392%2039.236L46.407%2039.236C43.755%2039.236%2042.211%2037.64%2042.211%2034.999ZM114.254%2034.475C110.248%2034.475%20106.866%2031.217%20106.866%2027.149C106.866%2023.071%20110.248%2019.813%20114.254%2019.813C118.322%2019.813%20121.601%2023.071%20121.601%2027.149C121.601%2031.217%20118.322%2034.475%20114.254%2034.475ZM99.463%2049.428C95.447%2049.428%2092.075%2046.119%2092.075%2042.051C92.075%2038.024%2095.447%2034.714%2099.463%2034.714C103.531%2034.714%20106.8%2038.024%20106.8%2042.051C106.8%2046.119%20103.531%2049.428%2099.463%2049.428ZM31.05%2089.019C37.752%2089.019%2042.301%2086.58%2046.454%2081.514L55.303%2070.775C56.54%2069.278%2058.014%2068.541%2059.469%2068.541L106.349%2068.541C107.845%2068.541%20109.32%2069.278%20110.567%2070.775L119.416%2081.514C123.517%2086.58%20128.065%2089.019%20134.768%2089.019C145.956%2089.019%20153.409%2081.598%20153.409%2070.16C153.409%2065.332%20152.277%2059.696%20150.397%2053.371C147.406%2043.392%20142.184%2029.779%20137.157%2019.216C132.994%2010.424%20130.824%206.409%20120.518%204.094C111.295%202.006%2098.553%200.564%2082.909%200.564C67.317%200.564%2054.564%202.006%2045.352%204.094C35.045%206.409%2032.876%2010.424%2028.661%2019.216C23.686%2029.779%2018.463%2043.392%2015.473%2053.371C13.593%2059.696%2012.461%2065.332%2012.461%2070.16C12.461%2081.598%2019.913%2089.019%2031.05%2089.019Z'%20fill='currentColor'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/gearshape.fill.svg b/src/sf-symbols/gearshape.fill.svg new file mode 100644 index 0000000..9d41961 --- /dev/null +++ b/src/sf-symbols/gearshape.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20119.426%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='119.426'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M55.228%2095.763L64.203%2095.763C66.747%2095.763%2068.606%2094.268%2069.188%2091.743L71.7%2081.019C73.609%2080.365%2075.434%2079.67%2077.095%2078.861L86.479%2084.655C88.558%2085.964%2090.977%2085.766%2092.712%2084.032L99.007%2077.737C100.752%2075.991%20101.001%2073.479%2099.589%2071.328L93.847%2062.016C94.656%2060.345%2095.403%2058.53%2095.953%2056.807L106.791%2054.243C109.264%2053.713%20110.707%2051.855%20110.707%2049.258L110.707%2040.449C110.707%2037.956%20109.264%2036.097%20106.791%2035.567L96.056%2032.951C95.403%2030.97%2094.604%2029.206%2093.951%2027.691L99.692%2018.224C101.053%2016.083%20100.907%2013.716%2099.11%2011.918L92.712%205.624C90.925%203.992%2088.765%203.691%2086.635%204.897L77.095%2010.794C75.486%209.985%2073.66%209.29%2071.7%208.636L69.188-2.243C68.606-4.768%2066.747-6.263%2064.203-6.263L55.228-6.263C52.683-6.263%2050.824-4.768%2050.294-2.243L47.73%208.533C45.852%209.187%2043.944%209.881%2042.325%2010.742L32.837%204.897C30.655%203.691%2028.494%203.94%2026.708%205.624L20.309%2011.918C18.512%2013.716%2018.366%2016.083%2019.728%2018.224L25.469%2027.691C24.815%2029.206%2024.017%2030.97%2023.415%2032.951L12.691%2035.567C10.166%2036.097%208.723%2037.956%208.723%2040.449L8.723%2049.258C8.723%2051.855%2010.166%2053.713%2012.691%2054.243L23.519%2056.807C24.017%2058.53%2024.764%2060.345%2025.573%2062.016L19.831%2071.328C18.418%2073.479%2018.667%2075.991%2020.413%2077.737L26.708%2084.032C28.443%2085.766%2030.862%2085.964%2032.992%2084.655L42.377%2078.861C43.996%2079.67%2045.852%2080.365%2047.73%2081.019L50.294%2091.743C50.824%2094.268%2052.683%2095.763%2055.228%2095.763ZM59.746%2062C50.223%2062%2042.486%2054.159%2042.486%2044.688C42.486%2035.289%2050.234%2027.5%2059.746%2027.5C69.217%2027.5%2076.955%2035.289%2076.955%2044.688C76.955%2054.159%2069.217%2062%2059.746%2062Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/hammer.fill.svg b/src/sf-symbols/hammer.fill.svg new file mode 100644 index 0000000..9950551 --- /dev/null +++ b/src/sf-symbols/hammer.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2024.132%2022.5342'%3e%3cg%3e%3crect%20height='22.5342'%20opacity='0'%20width='24.132'%20x='0'%20y='0'/%3e%3cpath%20d='M20.108%2011.2329C20.44%2011.565%2020.8306%2011.5943%2021.1627%2011.2622L23.5552%208.87942C23.858%208.56692%2023.8384%208.15676%2023.5064%207.8345L22.9205%207.25833C22.6275%206.94583%2022.4224%206.90676%2022.1002%206.93606L21.1627%207.03372L20.5572%206.44778L20.8306%205.32473C20.9771%204.76809%2020.7623%204.19192%2020.1763%203.60598L18.233%201.68215C16.2603-0.25144%2011.8072-0.134253%2010.0201%201.85793C9.65875%202.25833%209.71734%202.66848%209.95172%202.93215C10.1275%203.13723%2010.4498%203.25442%2010.7134%203.12747C12.1295%202.4634%2013.6627%202.23879%2014.9517%202.81497L14.1509%204.86575C13.8873%205.54934%2014.024%206.02786%2014.4634%206.46731L16.2408%208.24465C16.563%208.55715%2016.9048%208.65481%2017.4224%208.54739L18.7017%208.29348L19.2974%208.89895L19.2095%209.81692C19.1705%2010.1782%2019.2095%2010.3736%2019.522%2010.6763ZM0.566952%2020.94L1.58258%2021.9654C2.40289%2022.7857%203.41851%2022.7173%204.24859%2021.77L15.4107%209.0259C15.3521%208.97708%2015.3033%208.92825%2015.2545%208.87942L13.692%207.31692C13.6431%207.25833%2013.5845%207.19973%2013.5259%207.1509L0.781796%2018.2935C-0.185001%2019.1236-0.25336%2020.1294%200.566952%2020.94Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/hammer.svg b/src/sf-symbols/hammer.svg new file mode 100644 index 0000000..33ee2bd --- /dev/null +++ b/src/sf-symbols/hammer.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20142.092%20120'%3e%3cpath%20d='M14.204,104.517l8.523,8.574c4.266,4.37%209.215,4.058%2013.844,-1.079l53.435,-58.905l-4.829,-4.891l-53.061,58.377c-1.749,2.008%20-3.434,2.464%20-5.792,0.158l-5.831,-5.831c-2.358,-2.306%20-1.799,-3.991%200.147,-5.792l57.404,-53.993l-4.891,-4.828l-57.973,54.417c-4.889,4.578%20-5.304,9.465%20-0.976,13.793zM46.222,13.609c-2.045,2.055%20-2.2,4.918%20-1.007,6.899c1.11,1.774%203.423,2.904%206.658,2.137c7.296,-1.713%2014.909,-2.002%2022.085,2.715l-2.942,7.232c-1.681,4.17%20-0.8,7.077%201.847,9.827l11.464,11.578c2.427,2.437%204.511,2.531%207.325,2.033l5.289,-0.947l3.349,3.298l-0.217,2.793c-0.166,2.484%200.507,4.403%202.937,6.77l3.78,3.77c2.426,2.374%205.514,2.53%207.795,0.186l14.582,-14.571c2.333,-2.395%202.229,-5.328%20-0.187,-7.743l-3.831,-3.822c-2.379,-2.367%20-4.225,-3.165%20-6.656,-2.999l-2.897,0.269l-3.194,-3.194l1.214,-5.586c0.623,-2.815%20-0.155,-5.048%20-3.051,-7.933l-10.974,-10.963c-16.678,-16.627%20-38.891,-16.227%20-53.369,-1.749zM53.711,15.457c12.159,-8.868%2028.665,-7.388%2039.734,3.733l12.136,12.085c1.227,1.175%201.415,2.09%201.072,3.796l-1.619,7.391l7.557,7.443l4.868,-0.25c1.258,-0.062%201.696,0.043%202.642,0.989l2.913,2.912l-12.264,12.191l-2.84,-2.86c-0.956,-0.998%20-1.164,-1.436%20-1.102,-2.735l0.353,-4.827l-7.433,-7.454l-7.65,1.309c-1.602,0.291%20-2.321,0.155%20-3.548,-1.02l-9.998,-9.999c-1.269,-1.217%20-1.384,-2.041%20-0.614,-3.902l4.392,-10.413c-7.849,-7.275%20-17.989,-10.366%20-28.141,-7.409c-0.801,0.197%20-1.062,-0.479%20-0.458,-0.98z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/heart.circle.fill.svg b/src/sf-symbols/heart.circle.fill.svg new file mode 100644 index 0000000..9f58fb3 --- /dev/null +++ b/src/sf-symbols/heart.circle.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20120'%3e%3cpath%20d='M8.723,59.968c0,-27.43%2022.343,-49.773%2049.773,-49.773c27.482,0%2049.825,22.343%2049.825,49.773c0,27.43%20-22.343,49.774%20-49.825,49.774c-27.43,0%20-49.773,-22.344%20-49.773,-49.774zM31.74,53.112c0,13.14%2013.952,25.362%2024.226,31.92c0.912,0.56%202.053,1.182%202.634,1.182c0.684,0%201.669,-0.612%202.427,-1.182c10.24,-6.674%2024.267,-18.78%2024.267,-31.92c0,-8.928%20-6.168,-15.396%20-14.671,-15.396c-5.367,0%20-9.584,3.106%20-12.127,7.693c-2.491,-4.587%20-6.645,-7.693%20-12.075,-7.693c-8.555,0%20-14.681,6.468%20-14.681,15.396z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/house.svg b/src/sf-symbols/house.svg new file mode 100644 index 0000000..14d0829 --- /dev/null +++ b/src/sf-symbols/house.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20116.513%20102.535'%3e%3cpath%20d='M25.102%20102.535H91.142C98.085%20102.535%20102.13%2098.572%20102.13%2091.755V37.778L94.288%2032.473V89.755C94.288%2092.957%2092.563%2094.682%2089.464%2094.682H26.73C23.64%2094.682%2021.968%2092.957%2021.968%2089.755V32.495L14.062%2037.778V91.754C14.062%2098.583%2018.108%20102.535%2025.102%20102.535ZM0%2048.207C0%2050.217%201.585%2052.112%204.237%2052.112%205.594%2052.112%206.713%2051.367%207.697%2050.548L56.562%209.502C57.685%208.566%2058.962%208.576%2060.045%209.502L108.867%2050.548C109.903%2051.367%20111.022%2052.112%20112.38%2052.112%20114.68%2052.112%20116.513%2050.682%20116.513%2048.362%20116.513%2046.88%20116.067%2045.896%20115%2045.004L64.211%202.304C60.57-0.769%2056.11-0.769%2052.448%202.303L1.565%2045.003C0.497%2045.896%200%2047.087%200%2048.207ZM44.189%2097.522H72.324V61.982C72.324%2059.75%2070.851%2058.276%2068.621%2058.276H47.892C45.662%2058.277%2044.19%2059.751%2044.19%2061.981ZM89.874%2026.197%20102.13%2036.577V13.981C102.13%2011.846%20100.721%2010.488%2098.637%2010.488H93.42C91.284%2010.488%2089.874%2011.846%2089.874%2013.982Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/info.circle.fill.svg b/src/sf-symbols/info.circle.fill.svg new file mode 100644 index 0000000..26aa918 --- /dev/null +++ b/src/sf-symbols/info.circle.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='117.045'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M58.496%2094.513C85.719%2094.513%20108.321%2071.963%20108.321%2044.739C108.321%2017.516%2085.667-5.034%2058.444-5.034C31.262-5.034%208.723%2017.516%208.723%2044.739C8.723%2071.963%2031.314%2094.513%2058.496%2094.513Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M49.71%2073.019C47.606%2073.019%2046.032%2071.516%2046.032%2069.392C46.032%2067.475%2047.606%2065.869%2049.71%2065.869L55.929%2065.869L55.929%2042.893L50.529%2042.893C48.528%2042.893%2046.902%2041.391%2046.902%2039.267C46.902%2037.339%2048.528%2035.743%2050.529%2035.743L60.052%2035.743C62.611%2035.743%2063.989%2037.598%2063.989%2040.291L63.989%2065.869L70.207%2065.869C72.249%2065.869%2073.886%2067.475%2073.886%2069.392C73.886%2071.516%2072.249%2073.019%2070.207%2073.019ZM58.082%2027.439C54.412%2027.439%2051.468%2024.454%2051.468%2020.784C51.468%2017.062%2054.412%2014.159%2058.082%2014.159C61.752%2014.159%2064.603%2017.062%2064.603%2020.784C64.603%2024.454%2061.752%2027.439%2058.082%2027.439Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/ipad.gen2.landscape.svg b/src/sf-symbols/ipad.gen2.landscape.svg new file mode 100644 index 0000000..01f1456 --- /dev/null +++ b/src/sf-symbols/ipad.gen2.landscape.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20115.09%2089.912'%3e%3cpath%20d='M15.318%2089.912H99.772C110.004%2089.912%20115.09%2084.805%20115.09%2074.749V15.163C115.09%205.107%20110.004%200%2099.772%200H15.318C5.138%200%200%205.086%200%2015.163V74.749C0%2084.826%205.138%2089.912%2015.318%2089.912ZM15.443%2082.007C10.566%2082.007%207.853%2079.449%207.853%2074.366V15.546C7.853%2010.514%2010.566%207.906%2015.443%207.906H99.647C104.481%207.905%20107.237%2010.513%20107.237%2015.545V74.366C107.236%2079.449%20104.48%2082.006%2099.646%2082.006ZM38.696%2078.268H76.446C77.751%2078.268%2078.674%2077.408%2078.674%2076.05%2078.675%2074.64%2077.752%2073.77%2076.445%2073.77H38.696C37.39%2073.768%2036.416%2074.638%2036.416%2076.048%2036.415%2077.408%2037.39%2078.27%2038.695%2078.27Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/ipad.gen2.svg b/src/sf-symbols/ipad.gen2.svg new file mode 100644 index 0000000..dcde7ea --- /dev/null +++ b/src/sf-symbols/ipad.gen2.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2016.6797%2022.0215'%3e%3cg%3e%3crect%20height='22.0215'%20opacity='0'%20width='16.6797'%20x='0'%20y='0'%20/%3e%3cpath%20d='M5.39062%2019.6875L10.9277%2019.6875C11.2012%2019.6875%2011.3867%2019.502%2011.3867%2019.2285C11.3867%2018.9551%2011.2012%2018.7793%2010.9277%2018.7793L5.39062%2018.7793C5.12695%2018.7793%204.94141%2018.9551%204.94141%2019.2285C4.94141%2019.502%205.12695%2019.6875%205.39062%2019.6875ZM0%2019.4238C0%2020.9668%201.08398%2022.002%202.70508%2022.002L13.6133%2022.002C15.2344%2022.002%2016.3184%2020.9668%2016.3184%2019.4238L16.3184%202.58789C16.3184%201.04492%2015.2344%200%2013.6133%200L2.70508%200C1.08398%200%200%201.04492%200%202.58789ZM1.57227%2019.1602L1.57227%202.85156C1.57227%202.05078%202.06055%201.57227%202.90039%201.57227L13.418%201.57227C14.248%201.57227%2014.7461%202.05078%2014.7461%202.85156L14.7461%2019.1602C14.7461%2019.9609%2014.248%2020.4297%2013.418%2020.4297L2.90039%2020.4297C2.06055%2020.4297%201.57227%2019.9609%201.57227%2019.1602Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/iphone.gen2.svg b/src/sf-symbols/iphone.gen2.svg new file mode 100644 index 0000000..9d98651 --- /dev/null +++ b/src/sf-symbols/iphone.gen2.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2062.771%20103.335'%3e%3cpath%20d='M13.275%20103.335H49.444C57.407%20103.335%2062.77%2098.282%2062.77%2090.67V12.665C62.771%205.053%2057.407%200%2049.444%200H13.275C5.301%200%200%205.053%200%2012.665V90.67C0%2098.282%205.301%20103.335%2013.275%20103.335ZM14.28%2095.493C10.19%2095.493%207.853%2093.31%207.853%2089.377V13.959C7.853%2010.026%2010.191%207.853%2014.28%207.853H20.55C21.316%207.853%2021.731%208.258%2021.731%209.026V10.135C21.732%2012.137%2023.081%2013.548%2025.083%2013.548H37.688C39.742%2013.548%2041.028%2012.137%2041.028%2010.135V9.025C41.028%208.259%2041.443%207.854%2042.211%207.854H48.439C52.58%207.853%2054.866%2010.026%2054.866%2013.96V89.377C54.866%2093.31%2052.58%2095.493%2048.439%2095.493ZM21.055%2091.912H41.767C43.063%2091.912%2044.037%2090.988%2044.037%2089.63S43.064%2087.36%2041.768%2087.36H21.055C19.697%2087.36%2018.785%2088.272%2018.785%2089.63S19.697%2091.912%2021.055%2091.912Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/joystickcontroller.fill.svg b/src/sf-symbols/joystickcontroller.fill.svg new file mode 100644 index 0000000..97cc598 --- /dev/null +++ b/src/sf-symbols/joystickcontroller.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2023.623%2021.3965'%3e%3cg%3e%3crect%20height='21.3965'%20opacity='0'%20width='23.623'%20x='0'%20y='0'/%3e%3cpath%20d='M11.6309%2021.3965C12.9883%2021.3965%2014.0039%2021.0938%2014.9121%2020.6836L21.6895%2017.6367C22.7441%2017.1582%2023.2617%2016.7676%2023.2617%2015.9375L23.2617%2015.1953C23.2617%2014.873%2022.9199%2014.8047%2022.7344%2014.8926L15.459%2018.1934C14.2383%2018.75%2012.9102%2019.0137%2011.6406%2019.0137C10.332%2019.0137%209.26758%2018.8379%207.82227%2018.1738L0.537109%2014.873C0.351562%2014.7852%200%2014.873%200%2015.1953L0%2015.9375C0%2016.7676%200.517578%2017.1582%201.58203%2017.6367L8.34961%2020.6836C9.26758%2021.0938%2010.2734%2021.3965%2011.6309%2021.3965ZM11.6406%2017.6758C12.7441%2017.6758%2013.8574%2017.4609%2014.9414%2016.9727L21.8359%2013.8379C22.4316%2013.5645%2023.2617%2013.1055%2023.2617%2012.4219C23.2617%2011.7383%2022.4219%2011.2793%2021.8164%2011.0059L14.9414%207.87109C14.1211%207.50977%2013.3496%207.30469%2012.4902%207.2168L12.4902%2012.6465C12.4902%2012.9297%2012.1582%2013.1934%2011.6406%2013.1934C11.1328%2013.1934%2010.8008%2012.9297%2010.8008%2012.6465L10.8008%207.2168C9.90234%207.31445%209.10156%207.53906%208.34961%207.87109L1.04492%2011.2109C0.341797%2011.5234%200.0292969%2011.9629%200.0292969%2012.4219C0.0292969%2012.8809%200.341797%2013.3203%201.02539%2013.6328L8.34961%2016.9727C9.41406%2017.4609%2010.5273%2017.6758%2011.6406%2017.6758ZM4.55078%2013.3887C3.69141%2013.3887%202.99805%2012.9688%202.99805%2012.4316C2.99805%2011.9043%203.69141%2011.4844%204.55078%2011.4844C5.41016%2011.4844%206.09375%2011.9043%206.09375%2012.4316C6.09375%2012.9688%205.41016%2013.3887%204.55078%2013.3887ZM11.6406%206.41602C9.88281%206.41602%208.44727%204.99023%208.44727%203.23242C8.44727%201.47461%209.88281%200.0585938%2011.6406%200.0585938C13.3984%200.0585938%2014.8145%201.47461%2014.8145%203.23242C14.8145%204.99023%2013.3984%206.41602%2011.6406%206.41602Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/joystickcontroller.svg b/src/sf-symbols/joystickcontroller.svg new file mode 100644 index 0000000..de2c57a --- /dev/null +++ b/src/sf-symbols/joystickcontroller.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20140.886%20120'%3e%3cpath%20d='M70.446,97.424c6.801,0%2011.919,-1.476%2016.488,-3.583l35.696,-16.313c3.591,-1.67%205.727,-4.763%205.727,-8.206c0,-3.569%20-2.106,-6.827%20-5.654,-8.425l-35.376,-16.115c-2.247,-1.062%20-4.681,-1.852%20-7.012,-2.402v7.522c1.191,0.343%202.589,0.802%203.862,1.376l34.849,15.919c2.072,0.927%201.854,3.229%20-0.136,4.145l-34.713,15.795c-4.228,1.974%20-8.684,2.929%20-13.731,2.929c-5.047,0%20-9.565,-0.955%20-13.742,-2.929l-34.713,-15.795c-2.02,-0.905%20-2.229,-3.207%20-0.135,-4.145l34.848,-15.919c1.274,-0.574%202.671,-1.033%203.873,-1.376v-7.522c-2.341,0.55%20-4.816,1.34%20-7.074,2.402l-35.325,16.115c-3.548,1.598%20-5.654,4.856%20-5.654,8.425c0,3.443%202.147,6.536%205.727,8.206l35.645,16.313c4.568,2.107%209.698,3.583%2016.55,3.583zM70.446,114.321c5.535,0.052%2011.108,-1.288%2016.246,-3.634l33.869,-15.221c5.264,-2.409%207.859,-4.371%207.859,-8.514v-1.845c0,-1.764%20-1.835,-2.118%20-2.842,-1.681l-36.272,16.51c-6.073,2.762%20-12.593,4.153%20-18.757,4.153c-6.121,0%20-12.59,-1.391%20-18.859,-4.256l-36.427,-16.614c-1.442,-0.633%20-2.802,0.093%20-2.802,1.836v1.846c0,4.142%202.647,6.156%207.963,8.565l33.817,15.221c5.086,2.294%2010.619,3.582%2016.205,3.634zM41.043,74.012c4.482,0%208.134,-2.19%208.134,-5.053c0,-2.77%20-3.704,-4.959%20-8.134,-4.959c-4.523,0%20-8.185,2.189%20-8.185,4.959c0,2.863%203.61,5.053%208.185,5.053zM70.446,37.68c8.864,0%2015.985,-7.183%2015.985,-16.069c0,-8.823%20-7.172,-15.996%20-15.985,-15.996c-8.885,0%20-16.069,7.225%20-16.069,15.996c0,8.937%207.184,16.069%2016.069,16.069zM70.446,71.69c2.3,0%204.237,-1.139%204.237,-2.673v-40.787h-8.433v40.787c0,1.534%201.833,2.673%204.196,2.673z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/kr.12.svg b/src/sf-symbols/kr.12.svg new file mode 100644 index 0000000..8b1261f --- /dev/null +++ b/src/sf-symbols/kr.12.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__BpUQnQPF__"
\ No newline at end of file diff --git a/src/sf-symbols/kr.15.svg b/src/sf-symbols/kr.15.svg new file mode 100644 index 0000000..a0399e7 --- /dev/null +++ b/src/sf-symbols/kr.15.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__Ck4hDTBn__"
\ No newline at end of file diff --git a/src/sf-symbols/kr.all.svg b/src/sf-symbols/kr.all.svg new file mode 100644 index 0000000..f6cf67e --- /dev/null +++ b/src/sf-symbols/kr.all.svg @@ -0,0 +1 @@ +export default "__VITE_ASSET__CrpLBrZe__"
\ No newline at end of file diff --git a/src/sf-symbols/laurel.leading.svg b/src/sf-symbols/laurel.leading.svg new file mode 100644 index 0000000..1cb1163 --- /dev/null +++ b/src/sf-symbols/laurel.leading.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2067.9%20120'%3e%3cpath%20d='M43.092,104.881c-2.835,-0.238%20-6.354,0.561%20-8.753,2.035c-1.006,0.54%20-1.131,1.474%20-0.301,2.293c2.222,1.994%205.607,3.479%208.452,3.717c2.98,0.374%206.551,-0.55%208.898,-2.346c0.809,-0.581%200.871,-1.401%200.114,-2.106c-2.097,-1.973%20-5.493,-3.395%20-8.41,-3.593zM53.321,88.412c-1.536,2.399%20-2.46,5.919%20-2.325,8.722c0.176,2.948%201.536,6.395%203.447,8.513c0.653,0.727%201.462,0.727%202.054,-0.052c1.9,-2.284%202.938,-5.855%202.626,-8.887c-0.187,-2.834%20-1.599,-6.178%20-3.561,-8.421c-0.716,-0.892%20-1.598,-0.83%20-2.241,0.125zM29.322,91.673c-2.689,-0.986%20-6.344,-1.059%20-9.095,-0.249c-1.048,0.29%20-1.338,1.161%20-0.809,2.157c1.609,2.492%204.506,4.777%207.216,5.752c2.741,1.111%206.448,1.122%209.261,-0.041c0.882,-0.353%201.121,-1.12%200.591,-1.992c-1.557,-2.471%20-4.402,-4.703%20-7.164,-5.627zM43.352,78.381c-2.097,1.869%20-3.883,5.025%20-4.433,7.797c-0.623,2.929%20-0.197,6.573%201.101,9.085c0.415,0.934%201.234,1.059%202.043,0.519c2.336,-1.723%204.246,-4.88%204.745,-7.912c0.561,-2.834%200.062,-6.468%20-1.236,-9.126c-0.415,-1.017%20-1.338,-1.193%20-2.22,-0.363zM8.774,72.751c0.395,2.856%202.119,6.189%204.164,8.234c2.046,2.149%205.42,3.623%208.482,3.696c0.996,0.062%201.473,-0.581%201.349,-1.577c-0.488,-2.969%20-2.222,-6.177%20-4.268,-8.088c-2.086,-1.92%20-5.346,-3.519%20-8.191,-3.852c-1.162,-0.125%20-1.712,0.467%20-1.536,1.587zM36.768,68.599c-2.73,0.862%20-5.626,3.021%20-7.278,5.337c-1.734,2.284%20-2.782,5.752%20-2.658,8.774c0.063,0.965%200.747,1.473%201.702,1.286c2.959,-0.685%206.032,-2.783%207.527,-5.306c1.661,-2.481%202.71,-6.001%202.533,-8.846c0,-1.183%20-0.705,-1.66%20-1.826,-1.245zM28.597,57.124c-2.326,1.848%20-4.184,4.994%20-4.807,7.85c-0.125,0.933%200.353,1.628%201.286,1.628c3.021,0.125%206.531,-1.173%208.68,-3.156c2.211,-1.9%204.132,-5.067%204.765,-7.922c0.177,-1.1%20-0.415,-1.753%20-1.473,-1.753c-2.948,0.249%20-6.333,1.557%20-8.451,3.353zM8.847,50.758c-0.322,2.865%200.488,6.447%201.983,8.971c1.485,2.658%204.371,4.942%207.195,5.689c0.934,0.291%201.639,-0.124%201.753,-1.12c0.322,-2.824%20-0.477,-6.406%20-2.035,-8.95c-1.557,-2.43%20-4.34,-4.766%20-6.966,-5.752c-1.058,-0.467%20-1.816,-0.062%20-1.93,1.162zM31.255,40.738c-2.783,0.935%20-5.679,3.178%20-7.226,5.69c-0.478,0.82%20-0.239,1.639%200.643,2.044c2.772,1.111%206.478,1.111%209.25,-0.063c2.856,-1.069%205.753,-3.364%207.164,-5.825c0.581,-0.933%200.28,-1.753%20-0.767,-2.105c-2.772,-0.8%20-6.406,-0.727%20-9.064,0.259zM14.87,27.924c-1.298,2.679%20-1.734,6.334%20-1.174,9.157c0.488,3.001%202.399,6.095%204.808,7.829c0.757,0.591%201.576,0.363%202.043,-0.467c1.308,-2.555%201.682,-6.188%201.049,-9.064c-0.613,-2.845%20-2.399,-6.001%20-4.444,-7.922c-0.83,-0.654%20-1.764,-0.477%20-2.282,0.467zM40.133,26.647c-2.918,0.582%20-6.064,2.389%20-7.891,4.641c-0.581,0.747%20-0.415,1.566%200.353,2.044c2.564,1.547%206.249,2.034%209.167,1.235c2.866,-0.623%206.023,-2.481%207.871,-4.652c0.746,-0.871%200.529,-1.763%20-0.467,-2.23c-2.596,-1.163%20-6.188,-1.588%20-9.033,-1.038zM25.69,11.662c-1.661,2.471%20-2.648,6.053%20-2.461,8.888c0.062,2.959%201.537,6.302%203.686,8.472c0.757,0.705%201.587,0.602%202.127,-0.176c1.598,-2.503%202.574,-6.023%202.325,-8.888c-0.187,-2.793%20-1.547,-6.136%20-3.343,-8.41c-0.768,-0.83%20-1.691,-0.768%20-2.334,0.114zM50.89,6.709c-2.845,0.447%20-6.063,2.119%20-8.088,4.143c-2.086,2.035%20-3.696,5.368%20-4.018,8.223c-0.114,0.986%200.415,1.525%201.411,1.474c2.98,-0.146%206.313,-1.704%208.327,-3.977c1.972,-1.994%203.571,-5.316%204.007,-8.224c0.125,-1.161%20-0.456,-1.815%20-1.639,-1.639z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/laurel.left.svg b/src/sf-symbols/laurel.left.svg new file mode 100644 index 0000000..20d253e --- /dev/null +++ b/src/sf-symbols/laurel.left.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2021%2044'%3e%3cpath%20d='M14.71%2044a6.24%206.24%200%200%201-4.77-2.22%206.2%206.2%200%200%201%208.55.94A6.21%206.21%200%200%201%2014.7%2044m2.75-7.25a6.2%206.2%200%200%201%201.28-3.78%206.23%206.23%200%200%201%20.95%208.55%206.24%206.24%200%200%201-2.23-4.77m-9.28%201.74a6.23%206.23%200%200%201-4.04-3.39%206.22%206.22%200%200%201%208.01%203.13c-1.22.5-2.6.62-3.97.26m4.53-6.3a6.21%206.21%200%200%201%202.21-3.31%206.23%206.23%200%200%201-1.3%208.5%206.23%206.23%200%200%201-.91-5.19M2.27%2031.07A6.24%206.24%200%200%201%200%2026.32a6.21%206.21%200%200%201%206%206.17%206.21%206.21%200%200%201-3.73-1.42M9%2027.23c.9-1.1%202.11-1.8%203.4-2.1a6.23%206.23%200%200%201-4.72%207.2A6.23%206.23%200%200%201%209%2027.23M1.2%2022.3a6.24%206.24%200%200%201-.96-5.18%206.21%206.21%200%200%201%204.2%207.51A6.21%206.21%200%200%201%201.2%2022.3m7.5-1.97a6.21%206.21%200%200%201%203.82-1.15A6.23%206.23%200%200%201%206.1%2024.9a6.24%206.24%200%200%201%202.6-4.58M2.12%2013.4a6.23%206.23%200%200%201%20.73-5.22%206.21%206.21%200%200%201%201.6%208.45%206.22%206.22%200%200%201-2.33-3.23m7.74.5a6.22%206.22%200%200%201%203.98.12%206.23%206.23%200%200%201-7.9%203.4%206.24%206.24%200%200%201%203.92-3.51M5.92%206.29c-.23-1.9.42-3.7%201.63-5a6.22%206.22%200%200%201%20.1%208.6%206.21%206.21%200%200%201-1.73-3.6m7.53%201.86a6.22%206.22%200%200%201%203.9.82%206.24%206.24%200%200%201-8.38%201.96%206.24%206.24%200%200%201%204.48-2.78m.97-6.81A6.2%206.2%200%200%201%2018.17%200a6.23%206.23%200%200%201-6.13%206.04%206.23%206.23%200%200%201%202.38-4.7'%20fill='%238E8E93'%20fill-rule='evenodd'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/laurel.trailing.svg b/src/sf-symbols/laurel.trailing.svg new file mode 100644 index 0000000..f9f2ad0 --- /dev/null +++ b/src/sf-symbols/laurel.trailing.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2067.9%20120'%3e%3cpath%20d='M24.816,104.881c-2.917,0.198%20-6.261,1.62%20-8.41,3.593c-0.757,0.705%20-0.695,1.525%200.114,2.106c2.347,1.796%205.918,2.72%208.898,2.346c2.845,-0.238%206.23,-1.723%208.452,-3.717c0.871,-0.819%200.746,-1.753%20-0.301,-2.293c-2.399,-1.474%20-5.918,-2.273%20-8.753,-2.035zM14.587,88.412c-0.643,-0.955%20-1.525,-1.017%20-2.241,-0.125c-1.962,2.243%20-3.374,5.587%20-3.561,8.421c-0.312,3.032%200.727,6.603%202.626,8.887c0.592,0.779%201.401,0.779%202.054,0.052c1.911,-2.118%203.271,-5.565%203.437,-8.513c0.145,-2.803%20-0.779,-6.323%20-2.315,-8.722zM38.586,91.673c-2.71,0.924%20-5.607,3.156%20-7.164,5.627c-0.529,0.872%20-0.291,1.639%200.591,1.992c2.813,1.163%206.52,1.152%209.261,0.041c2.71,-0.975%205.596,-3.26%207.206,-5.752c0.529,-0.996%200.29,-1.867%20-0.799,-2.157c-2.751,-0.81%20-6.406,-0.737%20-9.095,0.249zM24.556,78.381c-0.882,-0.83%20-1.754,-0.654%20-2.22,0.363c-1.298,2.658%20-1.797,6.292%20-1.236,9.126c0.499,3.032%202.409,6.189%204.745,7.912c0.809,0.54%201.628,0.415%202.043,-0.519c1.288,-2.512%201.713,-6.156%201.09,-9.085c-0.55,-2.772%20-2.325,-5.928%20-4.422,-7.797zM59.175,72.751c0.176,-1.12%20-0.426,-1.712%20-1.577,-1.587c-2.845,0.333%20-6.105,1.932%20-8.14,3.852c-2.097,1.911%20-3.831,5.119%20-4.319,8.088c-0.124,0.996%200.405,1.639%201.349,1.577c3.062,-0.073%206.426,-1.547%208.471,-3.696c2.098,-2.045%203.769,-5.378%204.216,-8.234zM31.14,68.599c-1.121,-0.415%20-1.826,0.062%20-1.826,1.245c-0.176,2.845%200.872,6.365%202.533,8.846c1.547,2.523%204.569,4.621%207.528,5.306c0.954,0.187%201.628,-0.321%201.69,-1.286c0.125,-3.022%20-0.924,-6.49%20-2.647,-8.774c-1.662,-2.316%20-4.548,-4.475%20-7.278,-5.337zM39.311,57.124c-2.118,-1.796%20-5.503,-3.104%20-8.451,-3.353c-1.058,0%20-1.65,0.653%20-1.473,1.753c0.633,2.855%202.554,6.022%204.765,7.922c2.149,1.983%205.659,3.281%208.68,3.156c0.923,0%201.452,-0.695%201.276,-1.628c-0.572,-2.856%20-2.482,-6.002%20-4.797,-7.85zM59.102,50.758c-0.114,-1.224%20-0.912,-1.629%20-1.971,-1.162c-2.626,0.986%20-5.357,3.322%20-6.914,5.752c-1.558,2.544%20-2.409,6.126%20-2.087,8.95c0.114,0.996%200.819,1.411%201.753,1.12c2.824,-0.747%205.699,-3.031%207.184,-5.689c1.547,-2.524%202.357,-6.106%202.035,-8.971zM36.653,40.738c-2.658,-0.986%20-6.291,-1.059%20-9.064,-0.259c-1.047,0.352%20-1.348,1.172%20-0.767,2.105c1.463,2.461%204.308,4.756%207.164,5.825c2.772,1.174%206.478,1.174%209.25,0.063c0.872,-0.405%201.162,-1.224%200.633,-2.044c-1.547,-2.512%20-4.433,-4.755%20-7.216,-5.69zM53.028,27.924c-0.508,-0.944%20-1.442,-1.121%20-2.272,-0.467c-2.045,1.921%20-3.831,5.077%20-4.444,7.922c-0.633,2.876%20-0.208,6.509%201.101,9.064c0.415,0.83%201.234,1.058%201.992,0.467c2.408,-1.734%204.308,-4.828%204.848,-7.829c0.561,-2.823%200.073,-6.478%20-1.225,-9.157zM27.775,26.647c-2.845,-0.55%20-6.437,-0.125%20-9.033,1.038c-0.996,0.467%20-1.213,1.359%20-0.467,2.23c1.9,2.171%205.005,4.029%207.871,4.652c2.918,0.799%206.603,0.312%209.167,-1.235c0.809,-0.478%200.923,-1.297%200.342,-2.044c-1.816,-2.252%20-4.962,-4.059%20-7.88,-4.641zM42.219,11.662c-0.644,-0.882%20-1.567,-0.944%20-2.283,-0.114c-1.848,2.274%20-3.208,5.617%20-3.395,8.41c-0.249,2.865%200.727,6.385%202.326,8.888c0.539,0.778%201.369,0.881%202.126,0.176c2.139,-2.17%203.613,-5.513%203.675,-8.472c0.187,-2.835%20-0.799,-6.417%20-2.449,-8.888zM17.018,6.709c-1.131,-0.176%20-1.764,0.478%20-1.639,1.639c0.436,2.908%202.035,6.23%204.007,8.224c2.014,2.273%205.347,3.831%208.327,3.977c0.996,0.051%201.514,-0.488%201.4,-1.474c-0.322,-2.855%20-1.931,-6.188%20-4.007,-8.223c-2.025,-2.024%20-5.243,-3.696%20-8.088,-4.143z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/line.3.horizontal.svg b/src/sf-symbols/line.3.horizontal.svg new file mode 100644 index 0000000..d10ff46 --- /dev/null +++ b/src/sf-symbols/line.3.horizontal.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2098.752%2049.947'%3e%3cpath%20d='M3.668%2049.947H95.033A3.69%203.69%200%200%200%2098.753%2046.227%203.697%203.697%200%200%200%2095.032%2042.507H3.668C1.648%2042.508%200%2044.156%200%2046.228%200%2048.31%201.648%2049.947%203.668%2049.947ZM3.668%2028.693H95.033A3.697%203.697%200%200%200%2098.753%2024.973%203.697%203.697%200%200%200%2095.032%2021.254H3.668C1.648%2021.254%200%2022.902%200%2024.974%200%2027.047%201.648%2028.694%203.668%2028.694ZM3.668%207.388H95.033C97.105%207.388%2098.753%205.74%2098.753%203.72A3.697%203.697%200%200%200%2095.032%200H3.668C1.648%200%200%201.648%200%203.72A3.675%203.675%200%200%200%203.668%207.388Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/location.fill.svg b/src/sf-symbols/location.fill.svg new file mode 100644 index 0000000..a2fd10a --- /dev/null +++ b/src/sf-symbols/location.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20115.952%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='115.952'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M15%2047.917L50.547%2048.083C51.283%2048.083%2051.532%2048.332%2051.532%2049.017L51.646%2084.355C51.646%2091.58%2060.415%2093.319%2063.658%2086.253L99.725%208.66C103.011%201.57%2097.372-3.115%2090.562%200.015L12.534%2036.216C6.253%2039.077%207.539%2047.866%2015%2047.917Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/macbook.gen2.svg b/src/sf-symbols/macbook.gen2.svg new file mode 100644 index 0000000..bc02901 --- /dev/null +++ b/src/sf-symbols/macbook.gen2.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20140.769%2079.424'%3e%3cpath%20d='M0%2073.887C0%2076.936%202.478%2079.424%205.475%2079.424H135.295C138.333%2079.424%20140.77%2076.936%20140.77%2073.887%20140.77%2070.797%20138.333%2068.309%20135.295%2068.309H124.59V10.349C124.59%203.52%20120.956%200%20114.136%200H26.633C20.176%200%2016.181%203.52%2016.181%2010.35V68.308H5.475C2.478%2068.309%200%2070.797%200%2073.887ZM24.086%2068.309V12.585C24.086%209.424%2025.615%207.843%2028.786%207.843H111.984C115.155%207.843%20116.735%209.423%20116.735%2012.585V68.31ZM55.65%207.843H56.894C57.622%207.843%2058.037%208.206%2058.037%209.026V9.617C58.037%2011.62%2059.323%2013.03%2061.429%2013.03H79.465C81.457%2013.03%2082.754%2011.62%2082.754%209.617V9.026C82.754%208.206%2083.169%207.843%2083.937%207.843H85.183V3.823H55.649Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/magnifyingglass.circle.fill.svg b/src/sf-symbols/magnifyingglass.circle.fill.svg new file mode 100644 index 0000000..b2be244 --- /dev/null +++ b/src/sf-symbols/magnifyingglass.circle.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='117.045'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M58.496%2094.513C85.719%2094.513%20108.321%2071.963%20108.321%2044.739C108.321%2017.516%2085.667-5.034%2058.444-5.034C31.262-5.034%208.723%2017.516%208.723%2044.739C8.723%2071.963%2031.314%2094.513%2058.496%2094.513Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M53.075%2059.894C41.592%2059.894%2032.145%2050.468%2032.145%2038.986C32.145%2027.452%2041.592%2018.005%2053.075%2018.005C64.598%2018.005%2073.993%2027.401%2073.993%2038.986C73.993%2043.266%2072.663%2047.317%2070.407%2050.63L83.375%2063.67C84.173%2064.479%2084.733%2065.567%2084.733%2066.718C84.733%2069.246%2083.034%2071.081%2080.618%2071.081C79.167%2071.081%2078.058%2070.573%2077.042%2069.494L64.148%2056.682C60.939%2058.689%2057.168%2059.894%2053.075%2059.894ZM53.085%2053.024C60.785%2053.024%2067.082%2046.665%2067.082%2038.976C67.082%2031.224%2060.785%2024.916%2053.085%2024.916C45.344%2024.916%2039.026%2031.276%2039.026%2038.976C39.026%2046.665%2045.344%2053.024%2053.085%2053.024Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/magnifyingglass.svg b/src/sf-symbols/magnifyingglass.svg new file mode 100644 index 0000000..25be9ff --- /dev/null +++ b/src/sf-symbols/magnifyingglass.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.817%20120'%3e%3cpath%20d='M11.215,50.806c0,21.499%2017.472,38.919%2038.972,38.919c8.463,0%2016.231,-2.701%2022.617,-7.313l24.032,24.094c1.15,1.109%202.612,1.638%204.136,1.638c3.297,0%205.629,-2.499%205.629,-5.743c0,-1.555%20-0.612,-2.954%20-1.576,-4.032l-23.928,-23.981c5.038,-6.551%207.999,-14.693%207.999,-23.582c0,-21.49%20-17.42,-38.961%20-38.909,-38.961c-21.5,0%20-38.972,17.471%20-38.972,38.961zM19.575,50.806c0,-16.921%2013.681,-30.601%2030.612,-30.601c16.868,0%2030.6,13.68%2030.6,30.601c0,16.879%20-13.732,30.611%20-30.6,30.611c-16.931,0%20-30.612,-13.732%20-30.612,-30.611z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/message.svg b/src/sf-symbols/message.svg new file mode 100644 index 0000000..04a8cfd --- /dev/null +++ b/src/sf-symbols/message.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2022.5098%2020.459'%3e%3cg%3e%3crect%20height='20.459'%20opacity='0'%20width='22.5098'%20x='0'%20y='0'%20/%3e%3cpath%20d='M4.23828%2020.459C5.55664%2020.459%208.25195%2019.1309%2010.2344%2017.7148C17.041%2017.9004%2022.1484%2013.9941%2022.1484%208.87695C22.1484%203.96484%2017.2266%200%2011.0742%200C4.92188%200%200%203.96484%200%208.87695C0%2012.0801%202.05078%2014.9219%205.13672%2016.3477C4.69727%2017.1973%203.87695%2018.3496%203.4375%2018.9258C2.91992%2019.6094%203.23242%2020.459%204.23828%2020.459ZM5.26367%2018.8379C5.18555%2018.8672%205.15625%2018.8086%205.20508%2018.7402C5.75195%2018.0664%206.5332%2017.0508%206.86523%2016.4258C7.13867%2015.918%207.07031%2015.4688%206.44531%2015.1758C3.37891%2013.75%201.62109%2011.4746%201.62109%208.87695C1.62109%204.87305%205.81055%201.61133%2011.0742%201.61133C16.3477%201.61133%2020.5371%204.87305%2020.5371%208.87695C20.5371%2012.8711%2016.3477%2016.1328%2011.0742%2016.1328C10.8789%2016.1328%2010.5762%2016.123%2010.1855%2016.1133C9.77539%2016.1133%209.46289%2016.2402%209.0918%2016.5332C7.89062%2017.4023%206.15234%2018.4766%205.26367%2018.8379Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/paintbrush.fill.svg b/src/sf-symbols/paintbrush.fill.svg new file mode 100644 index 0000000..540e608 --- /dev/null +++ b/src/sf-symbols/paintbrush.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.9817%2023.3408'%3e%3cg%3e%3crect%20height='23.3408'%20opacity='0'%20width='20.9817'%20x='0'%20y='0'/%3e%3cpath%20d='M1.15977%2022.0268C2.68321%2023.56%204.65587%2023.5796%206.15977%2022.0757C7.36095%2020.8745%208.52306%2018.1596%209.41173%2016.8022L11.775%2019.1753C12.4977%2019.9077%2013.3766%2019.9077%2014.0602%2019.2046L14.9%2018.3647C15.6129%2017.6421%2015.6031%2016.8218%2014.8707%2016.0893L7.10704%208.31589C6.36485%207.58347%205.53477%207.57371%204.82188%208.2866L3.98204%209.12644C3.27891%209.82957%203.27891%2010.6792%204.01134%2011.4116L6.37462%2013.7749C5.02696%2014.6636%202.32188%2015.8257%201.11095%2017.0268C-0.383194%2018.5307-0.373429%2020.5132%201.15977%2022.0268ZM3.7672%2020.7573C3.09337%2020.7573%202.55626%2020.2104%202.55626%2019.5464C2.55626%2018.8823%203.09337%2018.3452%203.7672%2018.3452C4.43126%2018.3452%204.96837%2018.8823%204.96837%2019.5464C4.96837%2020.2104%204.43126%2020.7573%203.7672%2020.7573ZM16.0328%2015.6401L19.8219%2011.8511C20.9059%2010.7671%2020.8766%209.478%2019.7731%208.35496L19.275%207.84714C18.2594%209.1655%2015.359%2010.7085%2014.8024%2010.1518C14.7145%2010.0639%2014.7047%209.88816%2014.8316%209.75144C16.0133%208.5698%2016.7848%207.41746%2016.9606%205.54246L11.941%200.51316C11.0133-0.414575%209.40196-0.11184%208.96251%201.64597C8.34727%204.14597%207.79063%205.60105%207.18516%206.80222Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/paintbrush.svg b/src/sf-symbols/paintbrush.svg new file mode 100644 index 0000000..c0a6ecb --- /dev/null +++ b/src/sf-symbols/paintbrush.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20126.24%20120'%3e%3cpath%20d='M17.411,111.954c8.095,8.116%2017.675,8.189%2025.635,0.176c5.935,-5.882%2012.112,-19.815%2016.685,-25.237l10.371,10.423c3.848,3.889%208.432,3.878%2012.114,0.145l6.331,-6.372c3.733,-3.744%203.733,-8.163%20-0.156,-12.051l-38.116,-38.116c-3.847,-3.899%20-8.359,-3.941%20-12.102,-0.156l-6.321,6.331c-3.681,3.682%20-3.754,8.215%200.134,12.114l10.383,10.371c-5.371,4.562%20-19.304,10.75%20-25.187,16.674c-8.012,8.023%20-7.949,17.551%200.229,25.698zM38.637,51.419l3.961,-3.857c1.287,-1.288%202.638,-1.288%203.925,-0.052l35.28,35.28c1.236,1.235%201.225,2.627%20-0.062,3.925l-3.847,3.899c-1.298,1.349%20-2.742,1.339%20-4.029,0.051l-11.634,-11.726c-1.794,-1.846%20-4.127,-1.619%20-6.19,0.351c-3.858,3.774%20-10.485,19.824%20-18.248,27.546c-4.728,4.738%20-10.37,4.717%20-15.243,-0.094c-4.79,-4.852%20-4.811,-10.557%20-0.125,-15.232c7.774,-7.712%2023.875,-14.391%2027.598,-18.238c1.96,-2.074%202.197,-4.448%200.403,-6.19l-11.789,-11.686c-1.236,-1.287%20-1.236,-2.69%200,-3.977zM30.039,105.429c3.29,0%205.999,-2.72%205.999,-6.073c0,-3.29%20-2.709,-6.02%20-5.999,-6.02c-3.353,0%20-6.083,2.73%20-6.083,6.02c0,3.353%202.73,6.073%206.083,6.073zM87.833,83.895l23.161,-23.162c5.449,-5.448%205.345,-11.905%20-0.196,-17.497l-38.324,-38.376c-5.137,-5.137%20-14.006,-3.093%20-15.79,4.845c-4.535,19.728%20-4.738,22.069%20-12.251,32.539l5.636,5.574c8.539,-11.198%209.155,-16.607%2013.867,-34.084c0.645,-2.458%202.677,-3.101%204.329,-1.5l36.555,36.503c2.267,2.267%202.267,4.983%200.145,7.105l-22.582,22.593zM80.137,56.076c2.554,2.555%2016.822,-5.358%2021.744,-11.526l-10.727,-10.716c-0.924,8.66%20-5.42,14.89%20-10.893,20.373c-0.622,0.623%20-0.56,1.433%20-0.124,1.869z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/paperplane.fill.svg b/src/sf-symbols/paperplane.fill.svg new file mode 100644 index 0000000..6fed1f0 --- /dev/null +++ b/src/sf-symbols/paperplane.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2021.8262%2021.3965'%3e%3cg%3e%3crect%20height='21.3965'%20opacity='0'%20width='21.8262'%20x='0'%20y='0'/%3e%3cpath%20d='M12.2266%2021.3965C12.9297%2021.3965%2013.4277%2020.791%2013.7891%2019.8535L20.1855%203.14453C20.3613%202.69531%2020.459%202.29492%2020.459%201.96289C20.459%201.32812%2020.0684%200.9375%2019.4336%200.9375C19.1016%200.9375%2018.7012%201.03516%2018.252%201.21094L1.45508%207.64648C0.634766%207.95898%200%208.45703%200%209.16992C0%2010.0684%200.683594%2010.3711%201.62109%2010.6543L6.89453%2012.2559C7.51953%2012.4512%207.87109%2012.4316%208.29102%2012.041L19.0039%202.03125C19.1309%201.91406%2019.2773%201.93359%2019.375%202.02148C19.4727%202.11914%2019.4824%202.26562%2019.3652%202.39258L9.39453%2013.1445C9.01367%2013.5449%208.98438%2013.877%209.16992%2014.5312L10.7227%2019.6875C11.0156%2020.6738%2011.3184%2021.3965%2012.2266%2021.3965Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/paperplane.svg b/src/sf-symbols/paperplane.svg new file mode 100644 index 0000000..ad9bf5a --- /dev/null +++ b/src/sf-symbols/paperplane.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20122.203%20120'%3e%3cpath%20d='M68.558,113.947c3.546,0%206.014,-3.006%207.85,-7.694l31.956,-83.512c0.892,-2.28%201.379,-4.27%201.379,-5.938c0,-3.142%20-1.981,-5.122%20-5.122,-5.122c-1.669,0%20-3.659,0.487%20-5.887,1.378l-83.978,32.153c-4.118,1.587%20-7.279,4.107%20-7.279,7.654c0,4.499%203.43,5.993%208.12,7.414l35.232,10.356l10.262,34.777c1.411,4.938%202.957,8.534%207.467,8.534zM53.015,63.207l-33.69,-10.293c-0.805,-0.23%20-1.014,-0.47%20-1.014,-0.773c0,-0.355%200.167,-0.615%200.919,-0.897l66.024,-25.01c3.875,-1.471%207.605,-3.377%2011.26,-5.065c-3.221,2.621%20-7.275,5.814%20-9.885,8.424zM69.334,103.206c-0.355,0%20-0.543,-0.302%20-0.824,-1.097l-10.304,-33.69l33.625,-33.573c2.651,-2.651%205.896,-6.746%208.486,-10.03c-1.699,3.655%20-3.646,7.427%20-5.128,11.395l-25.009,65.982c-0.282,0.752%20-0.491,1.013%20-0.846,1.013z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/person.circle.slash.svg b/src/sf-symbols/person.circle.slash.svg new file mode 100644 index 0000000..d33aa10 --- /dev/null +++ b/src/sf-symbols/person.circle.slash.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M7.36%2017l3.208%203.208A24.23%2024.23%200%20007.39%2032.235c0%2013.44%2010.93%2024.374%2024.366%2024.374%204.374%200%208.483-1.159%2012.035-3.186L47%2056.631A28.616%2028.616%200%200131.756%2061C15.874%2061%203%2048.122%203%2032.235A28.637%2028.637%200%20017.36%2017zM9.77%206.642l47.588%2047.606a2.204%202.204%200%20010%203.11%202.206%202.206%200%2001-3.111%200L6.632%209.753c-.829-.8-.856-2.283%200-3.11.826-.828%202.253-.886%203.138%200zM32.238%203C48.123%203%2061%2015.878%2061%2031.761c0%205.597-1.599%2010.82-4.364%2015.239l-3.208-3.209a24.221%2024.221%200%20003.182-12.03c0-13.437-10.934-24.37-24.372-24.37a24.223%2024.223%200%2000-12.03%203.18L17%207.363A28.628%2028.628%200%200132.238%203zm-7.492%2031L36%2045H18.373C17.44%2045%2017%2044.418%2017%2043.572c0-2.224%202.5-7.11%207.746-9.572zm6.915-20C35.733%2014%2039%2017.634%2039%2022.002c0%202.419-.874%204.529-2.281%205.998L26%2016.923C27.343%2015.145%2029.376%2014%2031.661%2014z'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/person.circle.svg b/src/sf-symbols/person.circle.svg new file mode 100644 index 0000000..c95111f --- /dev/null +++ b/src/sf-symbols/person.circle.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20117.045%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='117.045'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M58.496%2094.513C85.719%2094.513%20108.321%2071.963%20108.321%2044.739C108.321%2017.516%2085.667-5.034%2058.444-5.034C31.262-5.034%208.723%2017.516%208.723%2044.739C8.723%2071.963%2031.314%2094.513%2058.496%2094.513ZM58.496%2086.245C35.51%2086.245%2017.083%2067.777%2017.083%2044.739C17.083%2021.702%2035.458%203.233%2058.444%203.233C81.482%203.233%20100.002%2021.702%20100.002%2044.739C100.002%2067.777%2081.534%2086.245%2058.496%2086.245Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M36.146%2069.507L80.784%2069.507C82.723%2069.507%2083.656%2068.181%2083.656%2066.397C83.656%2061.069%2075.588%2047.203%2058.444%2047.203C41.353%2047.203%2033.285%2061.069%2033.285%2066.397C33.285%2068.181%2034.217%2069.507%2036.146%2069.507ZM58.444%2043.226C65.401%2043.278%2070.988%2037.339%2070.988%2029.509C70.988%2022.189%2065.401%2016.104%2058.444%2016.104C51.539%2016.104%2045.953%2022.189%2045.953%2029.509C45.953%2037.339%2051.539%2043.174%2058.444%2043.226Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/person.crop.rectangle.line.fill.svg b/src/sf-symbols/person.crop.rectangle.line.fill.svg new file mode 100644 index 0000000..7b46498 --- /dev/null +++ b/src/sf-symbols/person.crop.rectangle.line.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%2064%2064'%3e%3cpath%20d='M52.367%2024h-14.76C36.704%2024%2036%2023.342%2036%2022.51c0-.835.705-1.51%201.606-1.51h14.761c.905%200%201.633.675%201.633%201.51%200%20.832-.728%201.49-1.633%201.49m0%2010h-14.76C36.704%2034%2036%2033.35%2036%2032.52c0-.85.705-1.52%201.606-1.52h14.761c.905%200%201.633.67%201.633%201.52%200%20.83-.728%201.48-1.633%201.48m0%209h-14.76C36.704%2043%2036%2042.33%2036%2041.484c0-.83.705-1.484%201.606-1.484h14.761c.905%200%201.633.654%201.633%201.484C54%2042.33%2053.272%2043%2052.367%2043m-24.04%200H12.66C10.7%2043%2010%2042.459%2010%2041.401%2010%2038.288%2014.028%2034%2020.493%2034%2026.973%2034%2031%2038.288%2031%2041.401%2031%2042.46%2030.305%2043%2028.328%2043m-7.321-22C23.673%2021%2026%2023.31%2026%2026.425%2026%2029.58%2023.686%2032%2021.007%2032%2018.314%2032%2016%2029.58%2016%2026.452%2015.987%2023.359%2018.327%2021%2021.007%2021m32.158-10h-42.33C5.645%2011%203%2013.566%203%2018.645V45.33C3%2050.408%205.644%2053%2010.835%2053h42.33C58.355%2053%2061%2050.408%2061%2045.329V18.645C61%2013.592%2058.356%2011%2053.165%2011'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/person.crop.square.svg b/src/sf-symbols/person.crop.square.svg new file mode 100644 index 0000000..71a5c04 --- /dev/null +++ b/src/sf-symbols/person.crop.square.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20114.778%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='114.778'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M27.779%2089.768L87.003%2089.768C97.235%2089.768%20102.321%2084.661%20102.321%2074.605L102.321%2015.019C102.321%204.963%2097.235-0.144%2087.003-0.144L27.779-0.144C17.599-0.144%2012.461%204.942%2012.461%2015.019L12.461%2074.605C12.461%2084.682%2017.599%2089.768%2027.779%2089.768ZM27.904%2081.863C23.027%2081.863%2020.314%2079.305%2020.314%2074.222L20.314%2015.402C20.314%2010.371%2023.027%207.762%2027.904%207.762L86.878%207.762C91.713%207.762%2094.468%2010.371%2094.468%2015.402L94.468%2074.222C94.468%2079.305%2091.713%2081.863%2086.878%2081.863ZM23.812%2084.751L90.99%2084.751C88.151%2071.046%2074.346%2061.089%2057.427%2061.089C40.457%2061.089%2026.652%2071.046%2023.812%2084.751ZM57.417%2052.917C66.71%2053.021%2074.082%2045.109%2074.082%2034.664C74.082%2024.821%2066.71%2016.764%2057.417%2016.764C48.072%2016.764%2040.648%2024.821%2040.7%2034.664C40.752%2045.109%2048.072%2052.866%2057.417%2052.917Z'%20fill='currentColor'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/person.fill.viewfinder.svg b/src/sf-symbols/person.fill.viewfinder.svg new file mode 100644 index 0000000..823c474 --- /dev/null +++ b/src/sf-symbols/person.fill.viewfinder.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20120.477%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='120.477'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M16.388%2028.103C18.947%2028.103%2020.314%2026.673%2020.314%2024.114L20.314%2012.55C20.314%207.518%2023.027%204.909%2027.904%204.909L39.726%204.909C42.285%204.909%2043.705%203.49%2043.705%200.982C43.705-1.577%2042.285-2.996%2039.726-2.996L27.779-2.996C17.599-2.996%2012.461%202.09%2012.461%2012.167L12.461%2024.114C12.461%2026.673%2013.88%2028.103%2016.388%2028.103ZM104.089%2028.103C106.658%2028.103%20108.015%2026.673%20108.015%2024.114L108.015%2012.167C108.015%202.111%20102.94-2.996%2092.697-2.996L80.761-2.996C78.191-2.996%2076.771-1.577%2076.771%200.982C76.771%203.49%2078.191%204.909%2080.761%204.909L92.573%204.909C97.407%204.909%20100.172%207.518%20100.172%2012.55L100.172%2024.114C100.172%2026.673%20101.581%2028.103%20104.089%2028.103ZM27.779%2092.621L39.726%2092.621C42.285%2092.621%2043.705%2091.201%2043.705%2088.694C43.705%2086.135%2042.285%2084.715%2039.726%2084.715L27.904%2084.715C23.027%2084.715%2020.314%2082.106%2020.314%2077.074L20.314%2065.562C20.314%2062.951%2018.895%2061.521%2016.388%2061.521C13.829%2061.521%2012.461%2062.951%2012.461%2065.562L12.461%2077.458C12.461%2087.535%2017.599%2092.621%2027.779%2092.621ZM80.761%2092.621L92.697%2092.621C102.94%2092.621%20108.015%2087.514%20108.015%2077.458L108.015%2065.562C108.015%2062.951%20106.606%2061.521%20104.089%2061.521C101.53%2061.521%20100.172%2062.951%20100.172%2065.562L100.172%2077.074C100.172%2082.106%2097.407%2084.715%2092.573%2084.715L80.761%2084.715C78.191%2084.715%2076.771%2086.135%2076.771%2088.694C76.771%2091.201%2078.191%2092.621%2080.761%2092.621Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M35.707%2073.469L84.77%2073.469C86.874%2073.469%2087.91%2072.081%2087.91%2070.069C87.91%2064.232%2079.084%2048.985%2060.207%2048.985C41.381%2048.985%2032.514%2064.232%2032.514%2070.069C32.514%2072.081%2033.55%2073.469%2035.707%2073.469ZM60.207%2044.582C67.849%2044.686%2073.996%2038.134%2073.996%2029.547C73.996%2021.479%2067.849%2014.771%2060.207%2014.771C52.565%2014.771%2046.428%2021.479%2046.428%2029.547C46.428%2038.134%2052.565%2044.53%2060.207%2044.582Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/photo.fill.on.rectangle.fill.svg b/src/sf-symbols/photo.fill.on.rectangle.fill.svg new file mode 100644 index 0000000..70739bd --- /dev/null +++ b/src/sf-symbols/photo.fill.on.rectangle.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20146.34%20100'%20style='overflow:visible'%3e%3crect%20x='0'%20y='-15'%20width='146.34'%20height='120'%20fill-opacity='0'/%3e%3cg%3e%3cpath%20d='M27.779%2071.603L94.487%2071.603C104.615%2071.603%20109.805%2066.548%20109.805%2056.492L109.805%2010.466C109.805%200.4%20104.615-4.696%2094.487-4.696L27.779-4.696C17.547-4.696%2012.461%200.379%2012.461%2010.466L12.461%2056.492C12.461%2066.569%2017.547%2071.603%2027.779%2071.603Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M51.902%20100.884L118.558%20100.884C132.45%20100.884%20140.554%2092.864%20140.554%2079.096L140.554%2033.018C140.554%2019.261%20132.45%2011.229%20118.558%2011.229L51.902%2011.229C38.009%2011.229%2029.906%2019.229%2029.906%2033.018L29.906%2079.096C29.906%2092.885%2038.009%20100.884%2051.902%20100.884Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3cpath%20d='M51.902%2094.207L118.558%2094.207C128.738%2094.207%20133.876%2089.11%20133.876%2079.096L133.876%2033.018C133.876%2022.962%20128.738%2017.907%20118.558%2017.907L51.902%2017.907C41.659%2017.907%2036.584%2022.941%2036.584%2033.018L36.584%2079.096C36.584%2089.183%2041.659%2094.207%2051.902%2094.207ZM52.026%2086.364C47.15%2086.364%2044.427%2083.744%2044.427%2078.671L44.427%2074.189L55.477%2064.529C57.357%2062.929%2059.184%2062.109%2061.075%2062.109C63.195%2062.109%2065.146%2062.888%2067.026%2064.591L74.875%2071.673L94.575%2054.208C96.632%2052.421%2098.781%2051.621%20101.171%2051.621C103.582%2051.621%20105.928%2052.483%20107.809%2054.27L126.022%2071.494L126.022%2078.671C126.022%2083.744%20123.267%2086.364%20118.433%2086.364Z'%20fill='%23000000'%20fill-opacity='1'%20/%3e%3cpath%20d='M71.348%2055.327C65.647%2055.327%2061.025%2050.633%2061.025%2044.88C61.025%2039.252%2065.647%2034.547%2071.348%2034.547C77.049%2034.547%2081.681%2039.252%2081.681%2044.88C81.681%2050.633%2077.049%2055.327%2071.348%2055.327Z'%20fill='%23ffffff'%20fill-opacity='1'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/plus.heavy.svg b/src/sf-symbols/plus.heavy.svg new file mode 100644 index 0000000..a81c1c2 --- /dev/null +++ b/src/sf-symbols/plus.heavy.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='278.636%20115.895%2014%2014'%20width='14'%20height='14'%3e%3cg%20fill='%23C7C7CC'%3e%3cpath%20d='M278.636%20120.895h14v4h-14z'/%3e%3cpath%20d='M287.636%20115.895v14h-4v-14'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/quote.bubble.fill.svg b/src/sf-symbols/quote.bubble.fill.svg new file mode 100644 index 0000000..ad1c656 --- /dev/null +++ b/src/sf-symbols/quote.bubble.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2021.8848%2021.5723'%3e%3cg%3e%3crect%20height='21.5723'%20opacity='0'%20width='21.8848'%20x='0'%20y='0'/%3e%3cpath%20d='M21.5234%205.78125L21.5234%2013.2617C21.5234%2016.123%2019.9609%2017.7246%2017.0508%2017.7246L10.4492%2017.7246L6.92383%2020.9473C6.46484%2021.377%206.18164%2021.5723%205.80078%2021.5723C5.24414%2021.5723%204.93164%2021.1719%204.93164%2020.5664L4.93164%2017.7246L4.47266%2017.7246C1.5625%2017.7246%200%2016.1328%200%2013.2617L0%205.78125C0%202.91016%201.5625%201.30859%204.47266%201.30859L17.0508%201.30859C19.9609%201.30859%2021.5234%202.91992%2021.5234%205.78125ZM5.89844%208.45703C5.89844%209.59961%206.61133%2010.4883%207.75391%2010.4883C8.17383%2010.4883%208.59375%2010.4199%208.85742%2010.0879L8.93555%2010.0879C8.56445%2010.918%207.79297%2011.4551%207.13867%2011.6309C6.75781%2011.7285%206.65039%2011.8848%206.65039%2012.1289C6.65039%2012.3828%206.86523%2012.5977%207.14844%2012.5977C8.16406%2012.5977%2010.2051%2011.3867%2010.2051%208.82812C10.2051%207.46094%209.32617%206.41602%208.01758%206.41602C6.80664%206.41602%205.89844%207.25586%205.89844%208.45703ZM11.3379%208.45703C11.3379%209.59961%2012.0508%2010.4883%2013.1836%2010.4883C13.6133%2010.4883%2014.0332%2010.4199%2014.2969%2010.0879L14.375%2010.0879C14.0039%2010.918%2013.2324%2011.4551%2012.5684%2011.6309C12.207%2011.7285%2012.0898%2011.8848%2012.0898%2012.1289C12.0898%2012.3828%2012.3047%2012.5977%2012.5879%2012.5977C13.6035%2012.5977%2015.6445%2011.3867%2015.6445%208.82812C15.6445%207.46094%2014.7559%206.41602%2013.4473%206.41602C12.2363%206.41602%2011.3379%207.25586%2011.3379%208.45703Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/rocket.fill.svg b/src/sf-symbols/rocket.fill.svg new file mode 100644 index 0000000..b47fdeb --- /dev/null +++ b/src/sf-symbols/rocket.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2020.9044%2020.6543'%3e%3cg%3e%3crect%20height='20.6543'%20opacity='0'%20width='20.9044'%20x='0'%20y='0'/%3e%3cpath%20d='M8.11824%2020.5078C9.14363%2020.2832%2010.96%2019.6484%2011.8585%2018.9551C13.1573%2017.9492%2013.7823%2016.8457%2013.6846%2015.0586L13.6553%2014.1602C14.4854%2013.5938%2015.2862%2012.9004%2016.0577%2012.1094C18.7725%209.32617%2020.5401%204.90234%2020.5401%201.04492C20.5401%200.458984%2020.0714%200%2019.4854%200C15.6378%200%2011.2139%201.76758%208.43074%204.47266C7.61042%205.2832%206.92683%206.06445%206.37019%206.88477L5.48152%206.85547C3.76277%206.77734%202.62019%207.31445%201.58503%208.67188C0.891674%209.58984%200.247143%2011.3867%200.0225333%2012.4219C-0.123951%2013.1348%200.471752%2013.457%200.999096%2013.3203C2.15144%2013.0957%203.39167%2012.5977%204.39753%2012.6758L4.39753%2013.3105C4.378%2013.7598%204.4366%2014.043%204.77839%2014.3945L6.14558%2015.752C6.50691%2016.1035%206.78035%2016.1719%207.22956%2016.1523L7.85456%2016.1328C7.96199%2017.168%207.48347%2018.3789%207.2198%2019.5312C7.03425%2020.1953%207.503%2020.6445%208.11824%2020.5078ZM13.8897%208.71094C12.7471%208.71094%2011.8194%207.79297%2011.8194%206.64062C11.8194%205.48828%2012.7374%204.56055%2013.8897%204.56055C15.0421%204.56055%2015.9698%205.48828%2015.9698%206.64062C15.9698%207.7832%2015.0421%208.71094%2013.8897%208.71094ZM2.59089%2019.1016L4.24128%2019.0527C4.77839%2019.043%205.20808%2018.877%205.55964%2018.5254C5.9991%2018.0859%206.11628%2017.4609%206.03816%2017.0312C5.97956%2016.6797%205.628%2016.582%205.47175%2016.8652C5.40339%2016.9629%205.3448%2017.0605%205.22761%2017.168C4.98347%2017.4219%204.79792%2017.4805%204.48542%2017.5L3.51863%2017.5586C3.36238%2017.5586%203.24519%2017.4414%203.24519%2017.2949L3.30378%2016.3184C3.32331%2015.9961%203.39167%2015.8105%203.63581%2015.5859C3.753%2015.4688%203.85066%2015.4004%203.94831%2015.3418C4.22175%2015.2148%204.1241%2014.8145%203.7823%2014.7656C3.33308%2014.6973%202.72761%2014.8145%202.28816%2015.2539C1.92683%2015.625%201.76081%2016.0352%201.75105%2016.5625L1.70222%2018.2129C1.68269%2018.7598%202.05378%2019.1211%202.59089%2019.1016Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/rocket.svg b/src/sf-symbols/rocket.svg new file mode 100644 index 0000000..a8f45a2 --- /dev/null +++ b/src/sf-symbols/rocket.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20129.904%20120'%3e%3cpath%20d='M19.837,77.795c5.799,-1.154%2011.113,-3.155%2015.215,-3.114l0.176,-7.274c-5.013,0.062%20-11.872,2.073%20-15.238,3.011c-1.013,0.345%20-1.41,-0.105%20-1.18,-0.918c1.392,-4.814%204.135,-12.167%206.566,-15.346c4.383,-5.693%208.83,-7.739%2015.863,-7.48l1.284,0.104l4.91,-7.046l-6.039,-0.217c-9.312,-0.426%20-15.904,2.657%20-21.78,10.142c-3.208,4.287%20-6.841,13.123%20-8.119,19.356c-1.472,6.936%203.204,9.895%208.342,8.782zM53.611,28.754c-14.432,14.007%20-20.797,28.2%20-20.797,46.391c0,3.03%200.757,4.856%202.843,7.014l6.739,6.74c2.086,2.075%203.974,2.802%207.015,2.78c18.118,-0.073%2032.405,-6.302%2046.369,-20.735c13.685,-14.121%2022.916,-36.632%2022.916,-55.853c0,-5.631%20-3.662,-9.283%20-9.242,-9.283c-19.221,0%20-41.743,9.261%20-55.843,22.946zM58.647,33.925c12.816,-12.515%2033.451,-20.895%2051.221,-20.947c1.001,0%201.699,0.709%201.699,1.647c0,17.77%20-8.494,38.447%20-20.936,51.263c-12.774,13.054%20-25.071,18.548%20-40.93,18.548c-1.216,0%20-1.78,-0.25%20-2.716,-1.155l-5.732,-5.732c-0.894,-0.946%20-1.185,-1.519%20-1.185,-2.705c0,-15.797%205.546,-28.146%2018.579,-40.919zM77.799,82.064l0.103,2.135c0.208,6.14%20-1.87,10.587%20-7.511,14.919c-3.178,2.472%20-10.543,5.141%20-15.251,6.534c-0.919,0.334%20-1.347,-0.137%20-1.065,-1.097c0.876,-3.366%203.158,-10.225%203.053,-15.249l-7.315,0.177c0.177,4.112%20-1.888,9.436%20-3.052,15.174c-1.197,5.179%201.846,9.814%208.761,8.383c6.243,-1.288%2015.079,-4.912%2019.304,-8.161c7.496,-5.658%2010.578,-12.312%2010.153,-21.79l-0.166,-5.987zM22.585,85.715c-1.795,1.909%20-2.646,3.944%20-2.656,6.549l-0.25,8.266c-0.01,2.749%201.784,4.543%204.492,4.439l8.213,-0.208c2.647,-0.01%204.734,-0.851%206.602,-2.646c2.231,-2.253%202.772,-5.356%202.418,-7.487c-0.26,-1.745%20-2.128,-2.304%20-2.885,-0.883c-0.282,0.522%20-0.645,1.009%20-1.185,1.56c-1.234,1.234%20-2.116,1.546%20-3.735,1.66l-4.838,0.249c-0.725,0%20-1.367,-0.58%20-1.367,-1.275l0.29,-4.837c0.114,-1.568%200.488,-2.553%201.712,-3.673c0.55,-0.561%201.079,-0.945%201.57,-1.248c1.359,-0.726%200.851,-2.646%20-0.883,-2.885c-2.173,-0.384%20-5.266,0.25%20-7.498,2.419zM82.779,52.273c5.804,0%2010.622,-4.755%2010.622,-10.684c0,-5.803%20-4.777,-10.58%20-10.622,-10.58c-5.876,0%20-10.642,4.725%20-10.642,10.58c0,5.991%204.766,10.684%2010.642,10.684z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/square.and.arrow.up.svg b/src/sf-symbols/square.and.arrow.up.svg new file mode 100644 index 0000000..1499d56 --- /dev/null +++ b/src/sf-symbols/square.and.arrow.up.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2086.639%20110.16'%3e%3cpath%20d='M15.318%20110.16H71.32C81.553%20110.16%2086.64%20105.053%2086.64%2094.997V46.347C86.64%2036.28%2081.553%2031.182%2071.321%2031.182H57.736V39.078H71.196C75.98%2039.078%2078.787%2041.698%2078.787%2046.73V94.614C78.786%2099.697%2075.979%20102.254%2071.196%20102.254H15.444C10.566%20102.255%207.853%2099.698%207.853%2094.615V46.73C7.853%2041.698%2010.566%2039.078%2015.443%2039.078H28.924V31.183H15.318C5.138%2031.183%200%2036.26%200%2046.346V94.997C0%20105.074%205.138%20110.16%2015.318%20110.16Z'%20fill='%23000000'%3e%3c/path%3e%3cpath%20d='M43.294%2071.932C45.418%2071.932%2047.22%2070.18%2047.22%2068.109V18.013L46.9%2010.673%2050.17%2014.162%2057.573%2022.03A3.662%203.662%200%200%200%2060.215%2023.19C62.266%2023.19%2063.81%2021.75%2063.81%2019.792%2063.81%2018.704%2063.375%2017.948%2062.608%2017.232L46.122%201.306C45.148%200.332%2044.33%200%2043.294%200%2042.309%200%2041.48%200.332%2040.465%201.305L23.97%2017.233C23.254%2017.948%2022.819%2018.704%2022.819%2019.792%2022.819%2021.75%2024.3%2023.19%2026.362%2023.19%2027.295%2023.19%2028.342%2022.796%2029.046%2022.03L36.458%2014.162%2039.74%2010.662%2039.419%2018.013V68.11C39.419%2070.18%2041.222%2071.932%2043.294%2071.932Z'%20fill='%23000000'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/square.grid.2x2.fill.svg b/src/sf-symbols/square.grid.2x2.fill.svg new file mode 100644 index 0000000..1f535c9 --- /dev/null +++ b/src/sf-symbols/square.grid.2x2.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20114.778%20120'%3e%3cpath%20d='M70.783,104.997h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.442%20-3.123,-9.502%20-9.346,-9.502h-22.192c-6.223,0%20-9.346,3.06%20-9.346,9.502v21.829c0,6.452%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M21.807,104.997h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.442%20-3.123,-9.502%20-9.346,-9.502h-22.192c-6.223,0%20-9.346,3.06%20-9.346,9.502v21.829c0,6.452%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M70.783,56.021h22.192c6.223,0%209.346,-3.111%209.346,-9.553v-21.829c0,-6.453%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.554v21.829c0,6.442%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M21.807,56.021h22.192c6.223,0%209.346,-3.111%209.346,-9.553v-21.829c0,-6.453%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.554v21.829c0,6.442%203.123,9.553%209.346,9.553z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/square.grid.2x2.svg b/src/sf-symbols/square.grid.2x2.svg new file mode 100644 index 0000000..c6e9045 --- /dev/null +++ b/src/sf-symbols/square.grid.2x2.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20114.778%20120'%3e%3cpath%20d='M70.783,104.976h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.443%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.111%20-9.346,9.554v21.829c0,6.452%203.123,9.553%209.346,9.553zM70.908,98.054c-1.738,0%20-2.601,-0.863%20-2.601,-2.601v-21.838c0,-1.79%200.863,-2.653%202.601,-2.653h21.994c1.738,0%202.549,0.863%202.549,2.653v21.838c0,1.738%20-0.811,2.601%20-2.549,2.601z'%20fill='currentColor'%20opacity='1'/%3e%3cpath%20d='M21.807,104.976h22.192c6.223,0%209.346,-3.101%209.346,-9.553v-21.829c0,-6.443%20-3.123,-9.554%20-9.346,-9.554h-22.192c-6.223,0%20-9.346,3.111%20-9.346,9.554v21.829c0,6.452%203.123,9.553%209.346,9.553zM21.88,98.054c-1.686,0%20-2.549,-0.863%20-2.549,-2.601v-21.838c0,-1.79%200.863,-2.653%202.549,-2.653h21.994c1.738,0%202.601,0.863%202.601,2.653v21.838c0,1.738%20-0.863,2.601%20-2.601,2.601z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M70.783,55.948h22.192c6.223,0%209.346,-3.059%209.346,-9.501v-21.83c0,-6.452%20-3.123,-9.553%20-9.346,-9.553h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.553v21.83c0,6.442%203.123,9.501%209.346,9.501zM70.908,49.078c-1.738,0%20-2.601,-0.863%20-2.601,-2.652v-21.839c0,-1.738%200.863,-2.601%202.601,-2.601h21.994c1.738,0%202.549,0.863%202.549,2.601v21.839c0,1.789%20-0.811,2.652%20-2.549,2.652z'%20fill='%23000000'%20opacity='1'/%3e%3cpath%20d='M21.807,55.948h22.192c6.223,0%209.346,-3.059%209.346,-9.501v-21.83c0,-6.452%20-3.123,-9.553%20-9.346,-9.553h-22.192c-6.223,0%20-9.346,3.101%20-9.346,9.553v21.83c0,6.442%203.123,9.501%209.346,9.501zM21.88,49.078c-1.686,0%20-2.549,-0.863%20-2.549,-2.652v-21.839c0,-1.738%200.863,-2.601%202.549,-2.601h21.994c1.738,0%202.601,0.863%202.601,2.601v21.839c0,1.789%20-0.863,2.652%20-2.601,2.652z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/star.fill.svg b/src/sf-symbols/star.fill.svg new file mode 100644 index 0000000..029c0da --- /dev/null +++ b/src/sf-symbols/star.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20341--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2022.0527%2022.1191'%3e%3cg%3e%3crect%20height='22.1191'%20opacity='0'%20width='22.0527'%20x='0'%20y='0'/%3e%3cpath%20d='M4.16109%2020.5469C4.56149%2020.8594%205.0693%2020.752%205.67477%2020.3125L10.8408%2016.5137L16.0166%2020.3125C16.622%2020.752%2017.1201%2020.8594%2017.5302%2020.5469C17.9306%2020.2441%2018.0185%2019.7461%2017.7744%2019.0332L15.7334%2012.959L20.9482%209.20898C21.5537%208.7793%2021.7978%208.33008%2021.6416%207.8418C21.4853%207.37305%2021.0263%207.14844%2020.2744%207.14844L13.8779%207.14844L11.9345%201.08398C11.7002%200.361328%2011.3486%200%2010.8408%200C10.3427%200%209.99117%200.361328%209.7568%201.08398L7.81344%207.14844L1.41695%207.14844C0.665001%207.14844%200.206017%207.37305%200.0497668%207.8418C-0.116249%208.33008%200.137657%208.7793%200.743126%209.20898L5.95797%2012.959L3.91695%2019.0332C3.67281%2019.7461%203.7607%2020.2441%204.16109%2020.5469Z'%20fill='currentColor'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/star.svg b/src/sf-symbols/star.svg new file mode 100644 index 0000000..b6a5670 --- /dev/null +++ b/src/sf-symbols/star.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20128.346%20120'%3e%3cpath%20d='M30.733,107.427c2.063,1.555%204.561,0.985%207.609,-1.223l25.822,-18.946l25.874,18.946c3.007,2.208%205.504,2.778%207.567,1.223c2,-1.503%202.426,-4.042%201.182,-7.567l-10.158,-30.382l26.05,-18.728c3.048,-2.105%204.291,-4.396%203.462,-6.831c-0.777,-2.343%20-3.088,-3.545%20-6.831,-3.494l-31.971,0.218l-9.733,-30.548c-1.141,-3.566%20-2.882,-5.38%20-5.442,-5.38c-2.498,0%20-4.25,1.814%20-5.432,5.38l-9.733,30.548l-31.971,-0.218c-3.743,-0.051%20-6.013,1.151%20-6.831,3.494c-0.777,2.435%200.455,4.726%203.462,6.831l26.05,18.728l-10.157,30.382c-1.245,3.525%20-0.819,6.064%201.181,7.567zM37.777,97.794c-0.084,-0.125%20-0.063,-0.208%200.01,-0.489l9.733,-27.908c0.633,-1.887%200.27,-3.391%20-1.441,-4.594l-24.3,-16.771c-0.219,-0.197%20-0.302,-0.28%20-0.24,-0.416c0.062,-0.135%200.156,-0.135%200.437,-0.135l29.559,0.539c2.001,0.052%203.267,-0.768%203.899,-2.759l8.449,-28.28c0.073,-0.281%200.146,-0.375%200.281,-0.375c0.135,0%200.208,0.094%200.291,0.375l8.449,28.28c0.633,1.991%201.95,2.811%203.951,2.759l29.507,-0.539c0.281,0%200.375,0%200.437,0.135c0.063,0.136%20-0.021,0.219%20-0.239,0.416l-24.301,16.771c-1.711,1.203%20-2.074,2.707%20-1.4,4.594l9.692,27.908c0.073,0.281%200.094,0.364%200.01,0.489c-0.083,0.124%20-0.218,0%20-0.437,-0.146l-23.492,-17.889c-1.556,-1.255%20-3.318,-1.255%20-4.926,0l-23.44,17.889c-0.219,0.146%20-0.354,0.27%20-0.489,0.146z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/text.rectangle.page.fill.svg b/src/sf-symbols/text.rectangle.page.fill.svg new file mode 100644 index 0000000..12144f0 --- /dev/null +++ b/src/sf-symbols/text.rectangle.page.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20107.046%20120'%3e%3cpath%20d='M34.149,31.913c-1.824,0%20-3.161,-1.389%20-3.161,-3.151c0,-1.72%201.337,-3.098%203.161,-3.098h40.601c1.783,0%203.12,1.378%203.12,3.098c0,1.762%20-1.337,3.151%20-3.12,3.151zM34.149,46.539c-1.824,0%20-3.161,-1.379%20-3.161,-3.151c0,-1.71%201.337,-3.047%203.161,-3.047h23.795c1.835,0%203.161,1.337%203.161,3.047c0,1.772%20-1.326,3.151%20-3.161,3.151zM34.97,96.294c-4.299,0%20-6.728,-2.314%20-6.728,-6.717v-27.944c0,-4.454%202.429,-6.717%206.728,-6.717h37.107c4.454,0%206.728,2.263%206.728,6.717v27.944c0,4.403%20-2.274,6.717%20-6.728,6.717zM12.461,97.004c0,10.232%205.034,15.318%2015.111,15.318h51.851c10.087,0%2015.163,-5.086%2015.163,-15.318v-74.03c0,-10.18%20-5.076,-15.317%20-15.163,-15.317h-51.851c-10.077,0%20-15.111,5.137%20-15.111,15.317z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/text.rectangle.page.svg b/src/sf-symbols/text.rectangle.page.svg new file mode 100644 index 0000000..f1192ef --- /dev/null +++ b/src/sf-symbols/text.rectangle.page.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20107.046%20120'%3e%3cpath%20d='M34.77,32.741h39.37c1.72,0%203.005,-1.337%203.005,-3.058c0,-1.658%20-1.285,-2.943%20-3.005,-2.943h-39.37c-1.783,0%20-3.109,1.285%20-3.109,2.943c0,1.721%201.326,3.058%203.109,3.058zM34.77,46.901h23.071c1.731,0%203.057,-1.337%203.057,-3.047c0,-1.669%20-1.326,-2.943%20-3.057,-2.943h-23.071c-1.783,0%20-3.109,1.274%20-3.109,2.943c0,1.71%201.326,3.047%203.109,3.047zM35.539,95.208h36.02c4.299,0%206.521,-2.212%206.521,-6.511v-27.116c0,-4.299%20-2.222,-6.51%20-6.521,-6.51h-36.02c-4.144,0%20-6.573,2.211%20-6.573,6.51v27.116c0,4.299%202.429,6.511%206.573,6.511zM12.461,97.004c0,10.232%205.034,15.318%2015.111,15.318h51.851c10.087,0%2015.163,-5.086%2015.163,-15.318v-74.03c0,-10.18%20-5.076,-15.317%20-15.163,-15.317h-51.851c-10.077,0%20-15.111,5.137%20-15.111,15.317zM20.314,96.88v-73.781c0,-4.876%202.61,-7.589%207.693,-7.589h51.033c5.031,0%207.651,2.713%207.651,7.589v73.781c0,4.876%20-2.62,7.589%20-7.651,7.589h-51.033c-5.083,0%20-7.693,-2.713%20-7.693,-7.589z'%20fill='%23000000'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/textformat.size.svg b/src/sf-symbols/textformat.size.svg new file mode 100644 index 0000000..b2851a7 --- /dev/null +++ b/src/sf-symbols/textformat.size.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2022.6074%2014.4434'%3e%3cg%3e%3crect%20height='14.4434'%20opacity='0'%20width='22.6074'%20x='0'%20y='0'/%3e%3cpath%20d='M11.2109%2014.4238C11.7188%2014.4238%2011.9824%2014.2285%2012.168%2013.6816L13.4277%2010.2344L19.1895%2010.2344L20.4492%2013.6816C20.6348%2014.2285%2020.8887%2014.4238%2021.3965%2014.4238C21.9141%2014.4238%2022.2461%2014.1113%2022.2461%2013.623C22.2461%2013.457%2022.2168%2013.3008%2022.1387%2013.0957L17.5586%200.898438C17.334%200.302734%2016.9336%200%2016.3086%200C15.7031%200%2015.293%200.292969%2015.0781%200.888672L10.498%2013.1055C10.4199%2013.3105%2010.3906%2013.4668%2010.3906%2013.6328C10.3906%2014.1211%2010.7031%2014.4238%2011.2109%2014.4238ZM13.9062%208.75L16.2793%202.17773L16.3281%202.17773L18.7012%208.75Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M0.810547%2014.4238C1.21094%2014.4238%201.47461%2014.2188%201.63086%2013.7402L2.44141%2011.3477L6.25977%2011.3477L7.07031%2013.7402C7.22656%2014.2383%207.49023%2014.4238%207.90039%2014.4238C8.38867%2014.4238%208.7207%2014.1211%208.7207%2013.6816C8.7207%2013.4863%208.67188%2013.3008%208.59375%2013.0957L5.55664%205.0293C5.33203%204.43359%204.92188%204.12109%204.3457%204.12109C3.7793%204.12109%203.36914%204.41406%203.14453%205.0293L0.107422%2013.0957C0.0292969%2013.2812%200%2013.4766%200%2013.6816C0%2014.1309%200.3125%2014.4238%200.810547%2014.4238ZM2.87109%2010.0781L4.23828%206.00586L4.46289%206.00586L5.83008%2010.0781Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/tv.svg b/src/sf-symbols/tv.svg new file mode 100644 index 0000000..81cb112 --- /dev/null +++ b/src/sf-symbols/tv.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%20122.045%2097.575'%3e%3cpath%20d='M12.523%2081.04H109.523C117.75%2081.04%20122.044%2076.733%20122.044%2068.516V12.575C122.045%204.305%20117.75%200%20109.522%200H12.522C4.296%200%200%204.306%200%2012.575V68.517C0%2076.734%204.295%2081.039%2012.523%2081.039ZM36.49%2097.574H85.555A3.93%203.93%200%200%200%2089.492%2093.66C89.492%2091.411%2087.752%2089.681%2085.555%2089.681H36.49C34.293%2089.68%2032.553%2091.41%2032.553%2093.659A3.93%203.93%200%200%200%2036.49%2097.575ZM12.658%2073.186C9.486%2073.186%207.853%2071.564%207.853%2068.392V12.699C7.853%209.475%209.486%207.854%2012.658%207.854H109.388C112.558%207.854%20114.19%209.475%20114.19%2012.699V68.392C114.191%2071.564%20112.56%2073.186%20109.387%2073.186Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/visionpro.svg b/src/sf-symbols/visionpro.svg new file mode 100644 index 0000000..ecc7268 --- /dev/null +++ b/src/sf-symbols/visionpro.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20171.057%20120'%3e%3cpath%20d='M45.735,99.548c-19.145,0%20-33.274,-16.993%20-33.274,-39.752c0,-37.652%2033.058,-41.401%2073.067,-41.401c40.01,0%2073.068,3.719%2073.068,41.401c0,22.759%20-14.122,39.752%20-33.238,39.752c-19.916,0%20-29.459,-16.541%20-39.83,-16.541c-10.378,0%20-19.906,16.541%20-39.793,16.541zM125.467,88.987c13.801,0%2022.622,-11.339%2022.622,-29.191c0,-28.302%20-23.431,-30.902%20-62.561,-30.902c-39.13,0%20-62.561,2.637%20-62.561,30.902c0,17.852%208.821,29.191%2022.659,29.191c17.367,0%2023.469,-16.523%2039.902,-16.523c16.426,0%2022.573,16.523%2039.939,16.523z'%20fill='currentColor'%20opacity='1'/%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/voice.control.svg b/src/sf-symbols/voice.control.svg new file mode 100644 index 0000000..81b68a3 --- /dev/null +++ b/src/sf-symbols/voice.control.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2024.8364%2018.0957'%3e%3cg%3e%3crect%20height='18.0957'%20opacity='0'%20width='24.8364'%20x='0'%20y='0'/%3e%3cpath%20d='M19.7962%2011.2207C19.7962%2011.748%2020.2161%2011.9727%2020.6751%2011.6895L24.1419%209.58008C24.5911%209.30664%2024.5813%208.78906%2024.1419%208.51562L20.6751%206.38672C20.2161%206.10352%2019.7962%206.34766%2019.7962%206.86523Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M16.427%2014.5898C16.8567%2014.5898%2017.1887%2014.248%2017.1887%2013.7988L17.1887%204.29688C17.1887%203.83789%2016.8567%203.48633%2016.427%203.48633C15.9778%203.48633%2015.6165%203.83789%2015.6165%204.29688L15.6165%2013.7988C15.6165%2014.248%2015.9778%2014.5898%2016.427%2014.5898Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M12.2376%2018.0859C12.677%2018.0859%2013.0188%2017.7539%2013.0188%2017.3145L13.0188%200.820312C13.0188%200.351562%2012.677%200%2012.2376%200C11.7981%200%2011.4563%200.351562%2011.4563%200.820312L11.4563%2017.3145C11.4563%2017.7539%2011.7981%2018.0859%2012.2376%2018.0859Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M8.04811%2014.5898C8.50709%2014.5898%208.85865%2014.248%208.85865%2013.7988L8.85865%204.29688C8.85865%203.83789%208.50709%203.48633%208.04811%203.48633C7.61842%203.48633%207.28639%203.83789%207.28639%204.29688L7.28639%2013.7988C7.28639%2014.248%207.61842%2014.5898%208.04811%2014.5898Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M4.67897%2011.2207L4.67897%206.86523C4.67897%206.34766%204.25904%206.10352%203.80006%206.38672L0.333262%208.51562C-0.106191%208.78906-0.115957%209.30664%200.333262%209.58008L3.80006%2011.6895C4.25904%2011.9727%204.67897%2011.748%204.67897%2011.2207Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/voiceover.svg b/src/sf-symbols/voiceover.svg new file mode 100644 index 0000000..72be6eb --- /dev/null +++ b/src/sf-symbols/voiceover.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='1.3379%201.4356%2023.623%2020.0292'%3e%3cg%3e%3cpath%20d='M24.9609%2011.3965C24.9609%2016.8945%2020.498%2021.3574%2015%2021.3574C13.6104%2021.3574%2012.287%2021.0723%2011.086%2020.5558C11.4546%2020.128%2011.7649%2019.6495%2012.0012%2019.1314C12.9283%2019.4996%2013.9407%2019.6973%2015%2019.6973C19.5898%2019.6973%2023.3008%2015.9863%2023.3008%2011.3965C23.3008%206.80664%2019.5898%203.0957%2015%203.0957C10.8072%203.0957%207.34774%206.19246%206.79024%2010.2302C6.62899%2010.2116%206.46462%2010.2051%206.29883%2010.2051C5.88883%2010.2051%205.48727%2010.2453%205.09877%2010.3246C5.63187%205.32952%209.86434%201.43555%2015%201.43555C20.498%201.43555%2024.9609%205.89844%2024.9609%2011.3965ZM20.0586%208.23242C20.0586%208.49609%2019.8926%208.7207%2019.6387%208.78906C19.3262%208.88672%2017.0996%209.14062%2016.8262%209.18945C16.543%209.24805%2016.3867%209.48242%2016.3867%209.81445C16.3867%2010.3223%2016.3867%2011.9434%2016.4941%2012.6855C16.6113%2013.418%2017.4707%2016.9434%2017.5%2017.0996C17.6074%2017.4805%2017.3828%2017.8125%2017.002%2017.8125C16.7285%2017.8125%2016.5332%2017.666%2016.4355%2017.2949C16.2695%2016.582%2015.6348%2014.1406%2015.4688%2013.5547C15.332%2013.125%2015.2246%2012.9785%2014.9902%2012.9785C14.7461%2012.9785%2014.6387%2013.125%2014.5215%2013.5547C14.3262%2014.1406%2013.7109%2016.582%2013.5449%2017.2949C13.4375%2017.666%2013.252%2017.8125%2012.9785%2017.8125C12.7583%2017.8125%2012.5871%2017.7015%2012.5032%2017.534C12.5629%2017.2283%2012.5924%2016.9134%2012.5931%2016.5925C12.8412%2015.5569%2013.3846%2013.2605%2013.4766%2012.6855C13.6035%2011.9434%2013.6133%2010.3223%2013.6035%209.81445C13.5938%209.48242%2013.4277%209.24805%2013.1543%209.18945C12.8711%209.14062%2010.6543%208.88672%2010.332%208.78906C10.0879%208.7207%209.92188%208.49609%209.92188%208.23242C9.92188%207.88086%2010.1758%207.67578%2010.4199%207.67578C10.5176%207.67578%2010.5957%207.70508%2010.6836%207.71484C11.6309%207.86133%2013.6035%208.0957%2014.9902%208.0957C16.3867%208.0957%2018.3496%207.86133%2019.2871%207.71484C19.375%207.70508%2019.4629%207.67578%2019.5508%207.67578C19.7949%207.67578%2020.0586%207.88086%2020.0586%208.23242ZM16.2598%206.18164C16.2598%206.875%2015.6934%207.45117%2014.9902%207.45117C14.2871%207.45117%2013.7207%206.875%2013.7207%206.18164C13.7207%205.47852%2014.2871%204.91211%2014.9902%204.91211C15.6934%204.91211%2016.2598%205.47852%2016.2598%206.18164Z'%20fill='black'%20fill-opacity='0.85'%20/%3e%3cpath%20d='M11.2598%2016.5039C11.2598%2019.2188%208.98438%2021.4648%206.29883%2021.4648C3.58398%2021.4648%201.33789%2019.2383%201.33789%2016.5039C1.33789%2013.7891%203.58398%2011.543%206.29883%2011.543C9.02344%2011.543%2011.2598%2013.7793%2011.2598%2016.5039ZM5.86914%2014.3848L4.77539%2015.4395C4.75586%2015.4492%204.72656%2015.4688%204.70703%2015.4688L3.97461%2015.4688C3.62305%2015.4688%203.4375%2015.6445%203.4375%2016.0254L3.4375%2016.9824C3.4375%2017.3535%203.62305%2017.5391%203.97461%2017.5391L4.70703%2017.5391C4.72656%2017.5391%204.75586%2017.5488%204.77539%2017.5684L5.86914%2018.6133C5.99609%2018.7207%206.09375%2018.7695%206.21094%2018.7695C6.38672%2018.7695%206.51367%2018.6426%206.51367%2018.4766L6.51367%2014.5215C6.51367%2014.3457%206.38672%2014.2285%206.21094%2014.2285C6.09375%2014.2285%205.99609%2014.2773%205.86914%2014.3848ZM8.35938%2014.6484C8.21289%2014.7461%208.18359%2014.9414%208.29102%2015.0977C8.56445%2015.4883%208.71094%2015.9766%208.71094%2016.4941C8.71094%2017.0215%208.55469%2017.5195%208.30078%2017.9102C8.20312%2018.0469%208.21289%2018.2422%208.34961%2018.3496C8.48633%2018.4375%208.67188%2018.4082%208.78906%2018.2715C9.13086%2017.793%209.31641%2017.1582%209.31641%2016.4941C9.31641%2015.8301%209.13086%2015.1953%208.7793%2014.7266C8.66211%2014.5898%208.49609%2014.5703%208.35938%2014.6484ZM7.36328%2015.3223C7.20703%2015.4102%207.1875%2015.6348%207.30469%2015.7715C7.42188%2015.9473%207.5%2016.2207%207.5%2016.4941C7.5%2016.7871%207.43164%2017.0312%207.30469%2017.2266C7.1875%2017.373%207.20703%2017.5586%207.35352%2017.666C7.49023%2017.7734%207.67578%2017.7441%207.7832%2017.5879C7.99805%2017.3047%208.125%2016.9043%208.125%2016.4941C8.125%2016.0938%207.99805%2015.6934%207.79297%2015.4102C7.68555%2015.2637%207.50977%2015.2344%207.36328%2015.3223Z'%20fill='black'%20fill-opacity='0.85'%20/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/xmark.svg b/src/sf-symbols/xmark.svg new file mode 100644 index 0000000..4f8e136 --- /dev/null +++ b/src/sf-symbols/xmark.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3csvg%20viewBox='0%200%2077.418%2077.399'%3e%3cpath%20d='M1.247%2076.14C2.977%2077.818%205.784%2077.818%207.473%2076.14L38.688%2044.863%2069.966%2076.14C71.592%2077.818%2074.45%2077.818%2076.14%2076.14%2077.818%2074.4%2077.818%2071.592%2076.14%2069.955L44.863%2038.688%2076.14%207.473C77.818%205.784%2077.87%202.925%2076.14%201.247%2074.4-0.39%2071.592-0.39%2069.966%201.247L38.688%2032.514%207.473%201.247C5.784-0.39%202.925-0.442%201.247%201.247-0.39%202.977-0.39%205.784%201.247%207.473L32.514%2038.688%201.247%2069.955C-0.39%2071.592-0.442%2074.451%201.247%2076.14Z'%20fill='currentColor'%3e%3c/path%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/sf-symbols/xmark.triangle.circle.square.fill.svg b/src/sf-symbols/xmark.triangle.circle.square.fill.svg new file mode 100644 index 0000000..c5ede3c --- /dev/null +++ b/src/sf-symbols/xmark.triangle.circle.square.fill.svg @@ -0,0 +1 @@ +export default "data:image/svg+xml,%3c?xml%20version='1.0'%20encoding='UTF-8'?%3e%3c!--Generator:%20Apple%20Native%20CoreSVG%20336--%3e%3c!DOCTYPE%20svg%20PUBLIC%20'-//W3C//DTD%20SVG%201.1//EN'%20'http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd'%3e%3csvg%20version='1.1'%20xmlns='http://www.w3.org/2000/svg'%20xmlns:xlink='http://www.w3.org/1999/xlink'%20viewBox='0%200%2018.5254%2017.9785'%3e%3cg%3e%3crect%20height='17.9785'%20opacity='0'%20width='18.5254'%20x='0'%20y='0'/%3e%3cpath%20d='M11.7871%2017.8418L16.2305%2017.8418C17.4707%2017.8418%2018.0957%2017.2266%2018.0957%2015.9375L18.0957%2011.5723C18.0957%2010.2832%2017.4707%209.66797%2016.2305%209.66797L11.7871%209.66797C10.5469%209.66797%209.92188%2010.2832%209.92188%2011.5723L9.92188%2015.9375C9.92188%2017.2266%2010.5469%2017.8418%2011.7871%2017.8418Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M4.20898%2017.9785C6.5332%2017.9785%208.4082%2016.0938%208.4082%2013.7695C8.4082%2011.4453%206.5332%209.57031%204.20898%209.57031C1.88477%209.57031%200%2011.4453%200%2013.7695C0%2016.0938%201.88477%2017.9785%204.20898%2017.9785Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M10.8691%207.75391L17.1484%207.75391C18.0273%207.75391%2018.3789%207.01172%2017.959%206.25977L14.8828%200.791016C14.4434%200.00976562%2013.5645%200.0195312%2013.1348%200.791016L10.0684%206.25977C9.6582%206.99219%209.98047%207.75391%2010.8691%207.75391Z'%20fill='black'%20fill-opacity='0.85'/%3e%3cpath%20d='M6.82617%207.53906C7.08984%207.8125%207.51953%207.7832%207.77344%207.51953C8.03711%207.25586%208.06641%206.83594%207.80273%206.57227L1.5625%200.341797C1.30859%200.0878906%200.878906%200.107422%200.615234%200.371094C0.361328%200.625%200.332031%201.06445%200.585938%201.31836ZM1.57227%207.53906L7.8125%201.31836C8.06641%201.06445%208.03711%200.625%207.7832%200.371094C7.5293%200.107422%207.08984%200.0878906%206.83594%200.341797L0.595703%206.57227C0.332031%206.83594%200.361328%207.25586%200.625%207.51953C0.888672%207.7832%201.30859%207.8125%201.57227%207.53906Z'%20fill='black'%20fill-opacity='0.85'/%3e%3c/g%3e%3c/svg%3e"
\ No newline at end of file diff --git a/src/stores/carousel-media-style.ts b/src/stores/carousel-media-style.ts new file mode 100644 index 0000000..dd2a642 --- /dev/null +++ b/src/stores/carousel-media-style.ts @@ -0,0 +1,5 @@ +import { writable } from 'svelte/store'; + +type Style = 'light' | 'dark' | 'white'; + +export const carouselMediaStyle = writable<Style>('light'); diff --git a/src/stores/i18n.ts b/src/stores/i18n.ts new file mode 100644 index 0000000..740d0b4 --- /dev/null +++ b/src/stores/i18n.ts @@ -0,0 +1,73 @@ +import { readable } from 'svelte/store'; +import I18N from '@amp/web-apps-localization'; +import { getContext } from 'svelte'; +import type { Readable } from 'svelte/store'; +import type { Locale, ILocaleJSON } from '@amp/web-apps-localization'; +import type { Logger, LoggerFactory } from '@amp/web-apps-logger'; +import { isEnabled } from '@amp/web-apps-featurekit'; + +export type { Locale } from '@amp/web-apps-localization'; + +import { __FF_SHOW_LOC_KEYS } from '~/utils/features/consts'; + +const CONTEXT_NAME = 'i18n'; + +export async function setup( + context: Map<string, unknown>, + loggerFactory: LoggerFactory, + locale: Locale, +): Promise<I18N> { + const log = loggerFactory.loggerFor('i18n'); + + let alwaysShowScreamers = false; + if (isEnabled(__FF_SHOW_LOC_KEYS)) { + alwaysShowScreamers = true; + } + + const translations = await getTranslations(log, locale); + const i18n = new I18N(log, locale, translations, alwaysShowScreamers); + const store = readable(i18n); + + context.set(CONTEXT_NAME, store); + + return i18n; +} + +/** + * Gets the current i18n store from the Svelte context. + * + * @return i18n The i18n store + */ +export function getI18n(): Readable<I18N> { + const i18n = getContext(CONTEXT_NAME) as Readable<I18N> | undefined; + + if (!i18n) { + throw new Error('getI18n called before setup'); + } + + return i18n; +} + +async function getTranslations( + log: Logger, + locale: Locale, +): Promise<ILocaleJSON> { + try { + // TODO: Shoebox logic here + const translations = await importLocale(locale); + return translations.default; + } catch (err) { + log.error('failed to load:', err); + throw new Error('i18n failed to load'); + } +} + +interface IDynamicImportJSON { + default: ILocaleJSON; +} + +//TODO: rdar://73157638 (Determine if we can use ES modules based on browser matrix) +// Possibly switch this to fetch instead of dynamic imports? +function importLocale(locale: Locale): Promise<IDynamicImportJSON> { + return import(`../../tmp/locales/${locale}/translations.json`); +} diff --git a/src/stores/modalPage.ts b/src/stores/modalPage.ts new file mode 100644 index 0000000..dca38a0 --- /dev/null +++ b/src/stores/modalPage.ts @@ -0,0 +1,35 @@ +import type { GenericPage } from '@jet-app/app-store/api/models'; +import { type Writable, writable, type Readable } from 'svelte/store'; + +interface Page { + page: GenericPage; + pageDetail?: string; +} + +const modalPageStore: Writable<Page | undefined> = (() => { + // prevent global store on the server + if (typeof window === 'undefined') { + return { + subscribe: () => { + return () => {}; + }, + set: () => {}, + update: () => {}, + } as unknown as Writable<Page | undefined>; + } + + return writable(); +})(); + +interface ModalPageStore extends Readable<Page | undefined> { + setPage: (page: Page) => void; + clearPage: () => void; +} + +export const getModalPageStore = (): ModalPageStore => { + return { + subscribe: modalPageStore.subscribe, + setPage: (page) => modalPageStore.set(page), + clearPage: () => modalPageStore.set(undefined), + }; +}; diff --git a/src/utils/app-platforms.ts b/src/utils/app-platforms.ts new file mode 100644 index 0000000..1adb840 --- /dev/null +++ b/src/utils/app-platforms.ts @@ -0,0 +1,25 @@ +import type { AppPlatform } from '@jet-app/app-store/api/models'; + +export const PlatformToExclusivityText: Partial<Record<AppPlatform, string>> = { + watch: 'ASE.Web.AppStore.App.OnlyForWatch', + tv: 'ASE.Web.AppStore.App.OnlyForAppleTV', + messages: 'ASE.Web.AppStore.App.OnlyForiMessage', + mac: 'ASE.Web.AppStore.App.OnlyForMac', + phone: 'ASE.Web.AppStore.App.OnlyForPhone', +}; + +export function isPlatformSupported( + platform: AppPlatform, + appPlatforms: AppPlatform[], +) { + const dedupedPlatforms = new Set(appPlatforms); + return dedupedPlatforms.has(platform); +} + +export function isPlatformExclusivelySupported( + platform: AppPlatform, + appPlatforms: AppPlatform[], +) { + const dedupedPlatforms = new Set(appPlatforms); + return dedupedPlatforms.has(platform) && dedupedPlatforms.size === 1; +} diff --git a/src/utils/array.ts b/src/utils/array.ts new file mode 100644 index 0000000..de9ef96 --- /dev/null +++ b/src/utils/array.ts @@ -0,0 +1,33 @@ +/** + * Split an array into two groups based on the result {@linkcode predicate} + * + * Items for which {@linkcode predicate} returns `true` will be in the "left" + * result, and the others in the "right" one + */ +export function partition<T>( + input: Array<T>, + predicate: (element: T) => boolean, +): [Array<T>, Array<T>] { + const left: Array<T> = []; + const right: Array<T> = []; + + for (const element of input) { + if (predicate(element)) { + left.push(element); + } else { + right.push(element); + } + } + + return [left, right]; +} + +/** + * Deduplicate the elements of {@linkcode items} by their `id` property + */ +export function uniqueById<T extends { id: string }>(items: T[]): T[] { + const entries = items.map((item) => [item.id, item] as const); + const mapById = new Map<string, T>(entries); + + return Array.from(mapById.values()); +} diff --git a/src/utils/color.ts b/src/utils/color.ts new file mode 100644 index 0000000..1d1c334 --- /dev/null +++ b/src/utils/color.ts @@ -0,0 +1,168 @@ +import { isSome } from '@jet/environment/types/optional'; +import type { + Artwork, + Color, + RGBColor, + NamedColor, +} from '@jet-app/app-store/api/models'; + +export type RGB = [number, number, number]; + +/** + * Represents a valid RGB color string, in the format "rgb(r, g, b)" or "rgb(r,g,b)". + * @example + * "rgb(255, 0, 128)" + * "rgb(255,0,128)" + */ +type RGBString = + | `rgb(${number},${number},${number})` + | `rgb(${number}, ${number}, ${number})`; + +export const isRGBColor = (value: Color): value is RGBColor => + value.type === 'rgb'; + +export const isNamedColor = (value: Color): value is NamedColor => + value.type === 'named'; + +const rgbColorAsString = ({ red, green, blue }: RGBColor): string => + `rgb(${[red, green, blue].map((color) => Math.floor(255 * color)).join()})`; + +export const colorAsString = (color: Color): string => { + switch (color.type) { + case 'named': + // `ios-appstore-app` makes use of the this `placeholderBackground` named color, + // which it leaves up to the client to manage. Ideally, we could define a CSS property + // named `--placeholderBackground`, but the media-apps shared logic to determine Artwork + // background color doesn't respect CSS properties, so we are specifying the hex value. + // https://github.pie.apple.com/amp-web/media-apps/blame/main/shared/components/src/components/Artwork/utils/validateBackground.ts + if (color.name === 'placeholderBackground') { + return '#f1f1f1'; + } + + return `var(--${color.name})`; + case 'rgb': + return rgbColorAsString(color); + case 'dynamic': + return colorAsString(color.lightColor); + } +}; + +/** + * Parses an RGB string and returns an array of red, green, and blue values. + * + * This function extracts the numeric values from an RGB string (e.g., "rgb(255, 0, 128)") + * and returns them as an array of numbers. + * + * @param {RGBString} rgbString - The RGB string to parse. + * @returns {RGB} An array of three numbers representing the red, green, and blue values, each between 0 and 255. + * + * @example + * getRGBFromString("rgb(255, 0, 128)") = [255, 0, 128] + */ +export const getRGBFromString = (rgbString: RGBString): RGB => { + const rgbValues = rgbString.match(/\d+/g) ?? []; + const rgb: RGB = [0, 0, 0]; + + for (const [index] of rgb.entries()) { + rgb[index] = parseInt(rgbValues[index]); + } + + return rgb; +}; + +/** + * Calculates the relative luminance for an RGB color. + * + * This function uses a standardized formula for luminance, which weights the red, green, and blue + * channels differently to account for human perception. + * @see {@link https://en.wikipedia.org/wiki/Relative_luminance|Wikipedia: Relative Luminance} + * + * @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255. + * @returns {number} The calculated luminance value, a number between 0 (darkest) and 255 (lightest). + */ +export const getLuminanceForRGB = ([r, g, b]: RGB): number => { + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +export function isRGBDarkerThanThreshold([r, g, b]: RGB, threshold = 10) { + return r <= threshold && g <= threshold && b <= threshold; +} + +export function isDark(rgbColor: RGBColor): boolean { + const { red, green, blue } = rgbColor; + const rgbValues = [red, green, blue].map((channel) => + Math.floor(channel * 255), + ) as RGB; + + return isRGBDarkerThanThreshold(rgbValues, 127); +} + +/** + * Determines whether an RGB color is approximately grey based on channel similarity. + * + * @param {RGB} rgb - An array containing red, green, and blue values, each between 0 and 255. + * @param {number} [threshold=10] - Maximum allowed difference between color channels to still be considered grey-ish. + * @returns {boolean} True if the RGB values are close enough to be considered grey. + */ +function isKindOfGrey([r, g, b]: RGB, threshold = 10) { + return ( + Math.abs(r - g) <= threshold && + Math.abs(r - b) <= threshold && + Math.abs(g - b) <= threshold + ); +} + +/** + * Generates CSS variables (custom properties) for a background gradient based on the background + * colors in the specified list of artworks. + * + * @param {Artwork[]} artworks - An array of Artwork, each containing a `backgroundColor` property. + * @param {Object} [options={}] - Optional configuration options. + * @param {string[]} [options.variableNames=['bottom-left', 'top-right', 'bottom-right', 'top-left']] - + * The names of the CSS variables to assign to the extracted colors. The number of colors + * used will match the length of this array. + * @param {(a: RGB, b: RGB) => number} [options.sortFn=() => 0] - + * A sorting function for ordering the colors (e.g., by luminance). Defaults to no sorting, + * which preserves input order. + * + * @returns {string} A CSS string containing custom properties, e.g., + * "--bottom-left: rgb(255, 0, 0); --top-right: rgb(0, 255, 0);". + */ +export const getBackgroundGradientCSSVarsFromArtworks = ( + artworks: Artwork[], + { + variableNames = [ + 'bottom-left', + 'top-right', + 'bottom-right', + 'top-left', + ], + sortFn = () => 0, + shouldRemoveGreys = false, + }: { + variableNames?: string[]; + sortFn?: (a: RGB, b: RGB) => number; + shouldRemoveGreys?: boolean; + } = {}, +): string => { + return artworks + .map(({ backgroundColor }) => backgroundColor) + .filter(isSome) + .filter(isRGBColor) + .map( + ({ red, green, blue }): RGB => [ + Math.floor(255 * red), + Math.floor(255 * green), + Math.floor(255 * blue), + ], + ) + .filter((rgb) => !isRGBDarkerThanThreshold(rgb, 33)) + .filter((rgb) => (shouldRemoveGreys ? !isKindOfGrey(rgb, 10) : true)) + .sort(sortFn) + .slice(0, variableNames.length) + .map( + ([red, green, blue], index) => + `--${variableNames[index]}: rgb(${red}, ${green}, ${blue})`, + ) + .join('; '); +}; diff --git a/src/utils/error.ts b/src/utils/error.ts new file mode 100644 index 0000000..40f0fc0 --- /dev/null +++ b/src/utils/error.ts @@ -0,0 +1,28 @@ +/** + * Tries to call {@linkcode fn} throwing an exception with the {@linkcode message} + * if an error occurs + * + * @example + * // Before + * let value; + * try { + * value = someMethod(); + * } catch(e) { + * throw new Error('My specific message', { cause: e }) + * } + * + * // After + * const value = mapError( + * () => someMethod(), + * 'My specific message' + * ); + */ +export function mapException<T>(fn: () => T, message: string): T { + try { + return fn(); + } catch (e) { + throw new Error(message, { + cause: e, + }); + } +} diff --git a/src/utils/features/consts.ts b/src/utils/features/consts.ts new file mode 100644 index 0000000..393fea9 --- /dev/null +++ b/src/utils/features/consts.ts @@ -0,0 +1,13 @@ +/** + * This file is a place for defining any Feature Flag Constants used throughout the app + * In order to keep the API Interface same for Build Time vs Runtime Feature Flags + * We have ensured that all the flags have to be defined in this file + */ +// Actual Feature Flag Values have to be defined in the /apps/app-store/featureFlags.external.cjs +// BUILD BASED FEATURE FLAGS DUMMY FLAG DEFINITIONS TO FIX THE NAME OF THE FEATURE FLAGS TO BE USED +// Values of the BUILD BASED FLAGS will decide if they are evaluated to true in DEV mode +export const __FF_SHOW_RADAR = 'r01234e98765'; + +export const __FF_SHOW_LOC_KEYS = 'ffShowLocKeys'; + +export const __FF_ARYA = 'asha123e7z124'; diff --git a/src/utils/features/runtime.ts b/src/utils/features/runtime.ts new file mode 100644 index 0000000..ebb83ad --- /dev/null +++ b/src/utils/features/runtime.ts @@ -0,0 +1,44 @@ +import { + buildFeatureConfig, + buildRuntimeFeatureKitConfig, + ENVIRONMENT, + loadFeatureKit, + type OnyxFeatures, +} from '@amp/web-apps-featurekit'; +import type { LoggerFactory } from '@amp/web-apps-logger'; +import { BUILD } from '~/config/build'; + +export async function setupRuntimeFeatures( + logger: LoggerFactory, +): Promise<OnyxFeatures | void> { + // load featureKit only for internal builds + if (import.meta.env.APP_SCOPE === 'internal' || import.meta.env.DEV) { + const features = await import('./consts'); + + // Build FeatureKit Config with overrides + const config = buildRuntimeFeatureKitConfig(features, { + [features.__FF_SHOW_RADAR]: buildFeatureConfig({ + [ENVIRONMENT.DEV]: true, + }), + [features.__FF_ARYA]: { + ...buildFeatureConfig({ [ENVIRONMENT.DEV]: false }), + itfe: ['y9ttlj15'], + }, + }); + // Load runtime featureKit + return loadFeatureKit( + 'com.apple.apps', + ENVIRONMENT.DEV, + config, + logger, + { + enableToolbar: true, + radarConfig: { + component: 'ASE Web', + app: 'App Store', + build: BUILD, + }, + }, + ); + } +} diff --git a/src/utils/file-size.ts b/src/utils/file-size.ts new file mode 100644 index 0000000..f71c4f4 --- /dev/null +++ b/src/utils/file-size.ts @@ -0,0 +1,23 @@ +const ROUND_TO = 10; +const SIZE_INCREMENT = 1000; +const UNITS = ['byte', 'KB', 'MB', 'GB']; + +/** + * Converts a byte count into a scaled value with a unit label (e.g. KB, MB, GB). + * + * @param {number} bytes - The number of bytes. + * @returns {{ count: number, unit: string }} Scaled value and its corresponding unit. + */ +export function getFileSizeParts(bytes: number) { + let index = 0; + + while (bytes >= SIZE_INCREMENT && index < UNITS.length - 1) { + bytes /= SIZE_INCREMENT; + index++; + } + + const count = Math.round(bytes * ROUND_TO) / ROUND_TO; + const unit = UNITS[index]; + + return { count, unit }; +} diff --git a/src/utils/launch-client.ts b/src/utils/launch-client.ts new file mode 100644 index 0000000..5202726 --- /dev/null +++ b/src/utils/launch-client.ts @@ -0,0 +1,13 @@ +import { platform } from '@amp/web-apps-utils'; + +const setupUrlForMac = (url: string) => { + const incomingUrl = new URL(url); + incomingUrl.searchParams.set('mt', '12'); + return incomingUrl.toString(); +}; + +export const launchAppOnMac = (url: string) => { + const appUrl = setupUrlForMac(url); + + platform.launchClient(appUrl, () => {}); +}; diff --git a/src/utils/locale.ts b/src/utils/locale.ts new file mode 100644 index 0000000..cd1151a --- /dev/null +++ b/src/utils/locale.ts @@ -0,0 +1,142 @@ +import type { Opt } from '@jet/environment'; +import { DEFAULT_STOREFRONT_CODE } from '~/constants/storefront'; + +import type { + NormalizedLocale, + NormalizedStorefront, + NormalizedLanguage, +} from '@jet-app/app-store/api/locale'; +import type { Locale } from '@jet-app/app-store/foundation/dependencies/locale/locale'; + +import { TEXT_DIRECTION } from '@amp/web-app-components/src/constants'; +import { getLocAttributes } from '@amp/web-apps-localization'; + +import { regions } from '~/utils/storefront-data'; +import { getJet } from '~/jet/svelte'; + +export type NormalizedLocaleWithDefault = NormalizedLocale & { + isDefaultLanguage: boolean; +}; + +type LanguageDetails = { + languages: NormalizedLanguage[]; + defaultLanguage: NormalizedLanguage; +}; + +export function normalizeStorefront(storefront: Opt<string>): { + storefront: NormalizedStorefront; + languages: NormalizedLanguage[]; + defaultLanguage: NormalizedLanguage; +} { + const storefronts: Record<NormalizedStorefront, LanguageDetails> = {}; + + for (const { locales } of regions) { + for (const { id, language, isDefault } of locales) { + if (isDefault) { + storefronts[id as NormalizedStorefront] = { + languages: [], + defaultLanguage: language as NormalizedLanguage, + }; + } + + if (id in storefronts) { + storefronts[id as NormalizedStorefront].languages.push( + language as NormalizedLanguage, + ); + } + } + } + + const normalizedStorefront = (storefront || '').toLowerCase(); + const chosenStorefront = + normalizedStorefront in storefronts + ? (normalizedStorefront as NormalizedStorefront) + : DEFAULT_STOREFRONT_CODE; + + return { + storefront: chosenStorefront, + ...storefronts[chosenStorefront], + }; +} + +export function normalizeLanguage( + language: string, + languages: NormalizedLanguage[], + defaultLanguage: NormalizedLanguage, +): { language: NormalizedLanguage; isDefaultLanguage: boolean } { + function annotateReturn(language: NormalizedLanguage): { + language: NormalizedLanguage; + isDefaultLanguage: boolean; + } { + return { + language, + isDefaultLanguage: language === defaultLanguage, + }; + } + + // Prefer an exact match (ex. en-US matches en-US) + const exactMatch = findMatch(language, languages, (a, b) => a === b); + if (exactMatch) { + return annotateReturn(exactMatch); + } + + // Try partial match (ex. fr-CA or fr matches fr-FR) + const partialMatch = findMatch( + language, + languages, + (a, b) => a.split('-')[0] === b.split('-')[0], + ); + if (partialMatch) { + return annotateReturn(partialMatch); + } + + // The only remaining choice is the storefront default + return annotateReturn(defaultLanguage); +} + +function findMatch<T extends string>( + needle: string, + haystack: T[], + matches: (a: string, b: string) => boolean, +): Opt<T> { + return haystack.find((possibility) => + matches(possibility.toLowerCase(), needle.toLowerCase()), + ); +} + +/** + * Gets the current Locale instance from the Svelte context. + * + * @return the active {@linkcode NormalizedLocale} + */ +export function getLocale(): NormalizedLocale { + let locale: Locale | undefined; + + try { + const { objectGraph } = getJet(); + + locale = objectGraph.locale; + } catch { + throw new Error('`getLocale` called before `Jet.load`'); + } + + return { + storefront: locale.activeStorefront, + language: locale.activeLanguage, + }; +} + +/** + * Returns whether or not the document is in RTL mode, first based on the document's direction, + * with a fallback to the storefronts default writing direction. + */ +export function isRtl() { + const { storefront } = getLocale(); + const { dir } = getLocAttributes(storefront); + + return ( + (typeof document !== 'undefined' && + document.dir === TEXT_DIRECTION.RTL) || + dir === TEXT_DIRECTION.RTL + ); +} diff --git a/src/utils/media-queries.ts b/src/utils/media-queries.ts new file mode 100644 index 0000000..d189b94 --- /dev/null +++ b/src/utils/media-queries.ts @@ -0,0 +1,12 @@ +import { ArtworkConfig } from '@amp/web-app-components/config/components/artwork'; +import { getMediaConditions } from '@amp/web-app-components/src/utils/getMediaConditions'; +import { buildMediaQueryStore } from '@amp/web-app-components/src/stores/media-query'; + +const { BREAKPOINTS } = ArtworkConfig.get(); + +const mediaQueryStore = buildMediaQueryStore( + 'medium', + getMediaConditions(BREAKPOINTS, { offset: 260 }), +); + +export default mediaQueryStore; diff --git a/src/utils/metrics.ts b/src/utils/metrics.ts new file mode 100644 index 0000000..9e3b015 --- /dev/null +++ b/src/utils/metrics.ts @@ -0,0 +1,4 @@ +export const APP_PRIVACY_MODAL_ID = 'ModalAppPrivacy'; +export const CUSTOMER_REVIEW_MODAL_ID = 'ModalCustomerReview'; +export const VERSION_HISTORY_MODAL_ID = 'ModalVersionHistory'; +export const LICENSE_AGREEMENT_MODAL_ID = 'LicenseAgreement'; diff --git a/src/utils/number-formatting.ts b/src/utils/number-formatting.ts new file mode 100644 index 0000000..8e9ef71 --- /dev/null +++ b/src/utils/number-formatting.ts @@ -0,0 +1,39 @@ +/** + * Normalizes and makes sure we include some unicode option for number formating. + */ +function localeWithOptionsForNumbers(locale: string) { + locale = locale.toLowerCase().replace('_', '-'); + + if (locale === 'hi-in') { + // nu-latn makes the formatter use latin numbers. + // See BCP47 Unicode extensions for number (nu): + // http://unicode.org/repos/cldr/trunk/common/bcp47/number.xml + // TL;DR -u- means the start of unicode extension. + // nu-latn means numeric (nu) extension, latn value + return 'hi-in-u-nu-latn'; + } else if (locale === 'my') { + // For the `my` locale, we want to display functional numbers as Latin numerals rather than in Burmese, + // so we are overriding the locale to give us the Latin functional numbers. See radar for more context: + // rdar://155236306 (LOC: MS-MY: ASOTW | Product Page: Functional: Numbers are not displayed in MS/EN format) + return 'my-u-nu-latn'; + } + + return locale; +} + +/** + * Abbreviate a number into a compact shorthand + * + * @example + * const abbr = abbreviateNumber(10_000, 'en-US'); // '10K' + */ +export function abbreviateNumber(value: number, locale: string): string { + const formatter = new Intl.NumberFormat( + localeWithOptionsForNumbers(locale), + { + notation: 'compact', + }, + ); + + return formatter.format(value); +} diff --git a/src/utils/portal.ts b/src/utils/portal.ts new file mode 100644 index 0000000..0c61ed0 --- /dev/null +++ b/src/utils/portal.ts @@ -0,0 +1,34 @@ +/** + * Svelte action to move an element to a different part of the DOM (as specified by the `targetId` + * provided), effectively creating a "portal." + * + * @param {HTMLElement} node - The element to be moved (provided by Svelte's `use:action` syntax). + * @param {string} targetId - The ID of the target element where `node` should be moved. + * @returns {{ destroy(): void } | void} - An object with a `destroy` method to remove `node` from the target when unmounted. + * + * @example + * ```svelte + * <div use:portal={'target-container'}> + * This content will be moved to the element with ID "target-container". + * </div> + * ``` + */ +export default function portal(node: HTMLElement, targetId: string) { + if (typeof document === 'undefined') { + return; + } + + let targetElement: HTMLElement | null = document.getElementById(targetId); + + if (!targetElement) { + return; + } + + targetElement.appendChild(node); + + return { + destroy() { + targetElement.removeChild(node); + }, + }; +} diff --git a/src/utils/seo/app-event-detail-page.ts b/src/utils/seo/app-event-detail-page.ts new file mode 100644 index 0000000..7b6c270 --- /dev/null +++ b/src/utils/seo/app-event-detail-page.ts @@ -0,0 +1,43 @@ +import type { GenericPage } from '@jet-app/app-store/api/models'; +import type I18N from '@amp/web-apps-localization'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + +import { isAppEventDetailShelf } from '~/components/jet/shelf/AppEventDetailShelf.svelte'; +import { truncateAroundLimit } from '~/utils/string-formatting'; +import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common'; + +export function seoDataForAppEventDetailPage( + page: GenericPage, + i18n: I18N, + language: string, +): SeoData { + const appEventDetailShelf = page.shelves.find(isAppEventDetailShelf); + + const { appEvent } = appEventDetailShelf?.items[0] || {}; + + if (!appEvent) { + return {}; + } + + const title = appEvent.title; + const description = truncateAroundLimit( + appEvent.detail, + MAX_DESCRIPTION_LENGTH, + language, + ); + + return { + pageTitle: title, + socialTitle: title, + appleTitle: title, + description, + socialDescription: description, + appleDescription: description, + crop: 'fo', + twitterCropCode: 'fo', + artworkUrl: appEvent?.moduleArtwork?.template, + imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', { + title: title, + }), + }; +} diff --git a/src/utils/seo/arcade-see-all-page.ts b/src/utils/seo/arcade-see-all-page.ts new file mode 100644 index 0000000..14d1474 --- /dev/null +++ b/src/utils/seo/arcade-see-all-page.ts @@ -0,0 +1,40 @@ +import type I18N from '@amp/web-apps-localization'; +import type { GenericPage } from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import { isAppTrailerLockupShelf } from '~/components/jet/shelf/AppTrailerLockupShelf.svelte'; + +export function seoDataForArcadeSeeAllPage( + page: GenericPage, + i18n: I18N, +): SeoData { + const titleWithSiteName = i18n.t( + 'ASE.Web.AppStore.Meta.TitleWithSiteName', + { + title: i18n.t('ASE.Web.AppStore.ArcadeSeeAll.Meta.Title'), + }, + ); + + const appNames = page.shelves + .filter(isAppTrailerLockupShelf) + .flatMap((shelf) => shelf.items) + .slice(0, 3) + .map((item) => item.title); + + const description = i18n.t( + 'ASE.Web.AppStore.ArcadeSeeAll.Meta.Description', + { + listing1: appNames[0], + listing2: appNames[1], + listing3: appNames[2], + }, + ); + + return { + pageTitle: titleWithSiteName, + socialTitle: titleWithSiteName, + appleTitle: titleWithSiteName, + description, + socialDescription: description, + appleDescription: description, + }; +} diff --git a/src/utils/seo/article-page.ts b/src/utils/seo/article-page.ts new file mode 100644 index 0000000..371e63e --- /dev/null +++ b/src/utils/seo/article-page.ts @@ -0,0 +1,276 @@ +import type { Opt } from '@jet/environment/types/optional'; +import type { + Article, + CollectionPage, + CreativeWork, + WithContext, +} from 'schema-dts'; + +import type { ArticlePage } from '@jet-app/app-store/api/models'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { + type DataContainer, + type Data, + dataFromDataContainer, +} from '@jet-app/app-store/foundation/media/data-structure'; +import { + attributeAsDictionary, + attributeAsString, +} from '@jet-app/app-store/foundation/media/attributes'; +import { relationshipCollection } from '@jet-app/app-store/foundation/media/relationships'; + +import type I18N from '@amp/web-apps-localization'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types'; + +import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte'; +import { isLockupOverlay } from '~/components/jet/today-card/TodayCardOverlay.svelte'; +import { isLockupListOverlay } from '~/components/jet/today-card/overlay/TodayCardLockupListOverlay.svelte'; +import { isTodayCardMediaWithArtwork } from '~/components/jet/today-card/media/TodayCardMediaWithArtwork.svelte'; +import { isTodayCardMediaVideo } from '~/components/jet/today-card/media/TodayCardMediaVideo.svelte'; +import { isTodayCardMediaRiver } from '~/components/jet/today-card/media/TodayCardMediaRiver.svelte'; +import { isTodayCardMediaBrandedSingleApp } from '~/components/jet/today-card/media/TodayCardMediaBrandedSingleApp.svelte'; +import { isTodayCardMediaAppEvent } from '~/components/jet/today-card/media/TodayCardMediaAppEvent.svelte'; + +import { AppleOrganization } from './common'; +import { buildOpenGraphImageURL } from './image-url'; +import { basicSoftwareApplicationSchema } from './product-page'; +import { stripTags, truncateAroundLimit } from '~/utils/string-formatting'; + +/// MARK: Schema Data + +/** + * SEO-related props that have already been computed, and will be re-used within the schema + */ +interface SeoProps { + title: string; + description: string | undefined; +} + +function commonSchemaForArticlePage( + data: Data, + { title, description }: SeoProps, +): WithContext<CreativeWork> { + const artwork = + attributeAsDictionary( + data, + 'editorialArtwork.storyCenteredStatic16x9', + ) ?? undefined; + const lastPublishedDate = + attributeAsString(data, 'lastPublishedDate') ?? undefined; + + return { + '@type': 'CreativeWork', + '@context': 'https://schema.org', + + description, + headline: title, + name: title, + + dateModified: lastPublishedDate, + datePublished: lastPublishedDate, + image: artwork ? buildOpenGraphImageURL(artwork) : undefined, + + author: AppleOrganization, + publisher: AppleOrganization, + }; +} + +function articleSchemaForArticlePage( + objectGraph: AppStoreObjectGraph, + data: Data, +): WithContext<Article> { + const cardContents = relationshipCollection(data, 'card-contents') ?? []; + const [app] = cardContents; + + return { + '@context': 'https://schema.org', + '@type': 'Article', + + mainEntityOfPage: app + ? basicSoftwareApplicationSchema(objectGraph, app) + : undefined, + }; +} + +function collectionPageSchemaForArticlePage( + objectGraph: AppStoreObjectGraph, + data: Data, +): WithContext<CollectionPage> { + const cardContents = relationshipCollection(data, 'card-contents') ?? []; + + return { + '@context': 'https://schema.org', + '@type': 'CollectionPage', + + mentions: cardContents.map((app) => + basicSoftwareApplicationSchema(objectGraph, app), + ), + }; +} + +/** + * + * @param objectGraph + * @param response the API response for the Article page + * @param props SEO-related props that have already been derrived for the page + */ +export function schemaDataForArticlePage( + objectGraph: AppStoreObjectGraph, + response: Opt<DataContainer>, + props: SeoProps, +): Partial<SeoData> { + if (!response) { + return {}; + } + + const articleData = dataFromDataContainer(objectGraph, response); + if (!articleData) { + return {}; + } + + let schemaContent = commonSchemaForArticlePage(articleData, props); + + const kind = attributeAsString(articleData, 'kind'); + + if (kind === 'Collection') { + schemaContent = { + ...schemaContent, + ...collectionPageSchemaForArticlePage(objectGraph, articleData), + }; + } else { + schemaContent = { + ...schemaContent, + ...articleSchemaForArticlePage(objectGraph, articleData), + }; + } + + return { + schemaName: 'article-page', + schemaContent, + }; +} + +/// MARK: Full SEO Data + +export function seoDataForArticlePage( + objectGraph: AppStoreObjectGraph, + i18n: I18N, + page: ArticlePage, + response: Opt<DataContainer>, + language: string, +): SeoData { + const { card } = page; + + if (!card) { + return {}; + } + + const storyTitle = stripTags(card.title); + const pageTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', { + title: storyTitle, + }); + + let artwork = ''; + let crop: CropCode = 'fo'; + let appNames = []; + + if (card.overlay && isLockupListOverlay(card.overlay)) { + appNames = card.overlay.lockups.slice(0, 3).map((item) => item.title); + } else { + appNames = page.shelves + .filter(isSmallLockupShelf) + .flatMap((shelf) => shelf.items) + .slice(0, 3) + .map((item) => item.title); + } + + const firstParagraphShelf = page.shelves.find( + (shelf) => shelf.contentType === 'paragraph', + ); + let description; + + // If an article has a paragraph shelf, we use that to populate the meta description, + // otherwise, we build a list of app names for the description. + if (page.shelves.length > 1 && firstParagraphShelf?.items) { + // The article paragraphs can contain HTML tags, so we strip them out here + const text = stripTags(firstParagraphShelf.items[0].text); + + const articleContent = truncateAroundLimit(text, 110, language); + + description = i18n.t( + 'ASE.Web.AppStore.Meta.Story.Description.WithArticleContent', + { articleContent }, + ); + } else if (appNames.length === 1) { + description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.One', { + storyTitle, + featuredAppName: appNames[0], + }); + } else if (appNames.length === 2) { + description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.Two', { + storyTitle, + featuredAppName: appNames[0], + featuredAppName2: appNames[1], + }); + } else if (appNames.length >= 3) { + description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.Three', { + storyTitle, + featuredAppName: appNames[0], + featuredAppName2: appNames[1], + featuredAppName3: appNames[2], + }); + } else if (card.overlay && isLockupOverlay(card.overlay)) { + const featuredAppName = card.overlay.lockup.title; + + description = i18n.t('ASE.Web.AppStore.Meta.Story.Description.One', { + storyTitle, + featuredAppName, + }); + } + + if (card.media && isTodayCardMediaWithArtwork(card.media)) { + artwork = card.media.artworks[0].template; + } else if (card.media && isTodayCardMediaVideo(card.media)) { + artwork = card.media.videos[0].preview.template; + } else if (card.media && isTodayCardMediaRiver(card.media)) { + artwork = card.media.lockups[0].icon.template; + crop = 'wa'; + } else if ( + card.media && + (isTodayCardMediaBrandedSingleApp(card.media) || + isTodayCardMediaAppEvent(card.media)) + ) { + if (card.media.artworks.length > 0) { + artwork = card.media.artworks[0].template; + } else if (card.media.videos.length > 0) { + artwork = card.media.videos[0].preview.template; + } + } + + // We are setting the `link rel="canonical"` tag for iPad, Watch and TV story pages to point to + // the iPhone page. + let canonicalUrl = page.canonicalURL?.replace( + /\/([a-z]{2})\/(ipad|watch|tv)\/story\//, + '/$1/iphone/story/', + ); + + return { + pageTitle, + crop, + canonicalUrl, + socialTitle: pageTitle, + description: description, + socialDescription: description, + appleDescription: description, + artworkUrl: artwork, + twitterCropCode: crop, + imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', { + title: storyTitle, + }), + ...schemaDataForArticlePage(objectGraph, response, { + title: pageTitle, + description, + }), + }; +} diff --git a/src/utils/seo/charts-hub-page.ts b/src/utils/seo/charts-hub-page.ts new file mode 100644 index 0000000..1b670ad --- /dev/null +++ b/src/utils/seo/charts-hub-page.ts @@ -0,0 +1,46 @@ +import type { ChartsHubPage, Lockup } from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import type I18N from '@amp/web-apps-localization'; +import { getPlatformFromPage } from '~/utils/seo/common'; +import { truncateAroundLimit } from '~/utils/string-formatting'; + +export function seoDataForChartsHubPage( + page: ChartsHubPage, + i18n: I18N, + language: string, +): SeoData { + const platform = getPlatformFromPage(page); + const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', { + title: i18n.t('ASE.Web.AppStore.Meta.ChartsHub.Title', { + platform, + }), + }); + + let description; + const items = page.charts[0].segments[0].shelves[0].items as Array<Lockup>; + + if (items) { + const appsTitles = items.map(({ title }) => title); + + description = truncateAroundLimit( + i18n.t('ASE.Web.AppStore.Meta.ChartsHub.Description', { + platform, + listing1: appsTitles[0], + listing2: appsTitles[1], + listing3: appsTitles[2], + listing4: appsTitles[3], + }), + 160, + language, + ); + } + + return { + pageTitle: title, + socialTitle: title, + appleTitle: title, + description, + socialDescription: description, + appleDescription: description, + }; +} diff --git a/src/utils/seo/charts-page.ts b/src/utils/seo/charts-page.ts new file mode 100644 index 0000000..14de925 --- /dev/null +++ b/src/utils/seo/charts-page.ts @@ -0,0 +1,58 @@ +import type { TopChartsPage, Lockup } from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import type I18N from '@amp/web-apps-localization'; +import { getPlatformFromPage } from '~/utils/seo/common'; +import { + commaSeparatedList, + truncateAroundLimit, +} from '~/utils/string-formatting'; + +export function seoDataForChartsPage( + page: TopChartsPage, + i18n: I18N, + language: string, +): SeoData { + // Genre 36 and 6014 are the "All Apps" and "All Games" genres, which we do not want to + // include in the page title, since it would then read as "Best All Games Apps - App Store". + const category = page.categoriesButtonTitle; + const isAllAppsOrGames = ['36', '6014'].includes(page.genreId); + const titleLocKey = + isAllAppsOrGames || !category + ? 'ASE.Web.AppStore.Meta.ChartsHub.Title' + : 'ASE.Web.AppStore.Meta.Charts.Title'; + const platform = getPlatformFromPage(page); + + const title = i18n.t(titleLocKey, { + category, + platform, + }); + + let description; + const items = page.segments[0].shelves[0].items as Array<Lockup>; + + if (items) { + const appTitles = items.map(({ title }) => title).slice(0, 3); + const locKey = + category && !isAllAppsOrGames + ? 'ASE.Web.AppStore.Meta.Charts.Description' + : 'ASE.Web.AppStore.Meta.Charts.DescriptionWithoutCategory'; + + description = truncateAroundLimit( + i18n.t(locKey, { + category, + platform, + listOfApps: commaSeparatedList(appTitles, language), + }), + 160, + ); + } + + return { + pageTitle: title, + socialTitle: title, + appleTitle: title, + description, + socialDescription: description, + appleDescription: description, + }; +} diff --git a/src/utils/seo/common.ts b/src/utils/seo/common.ts new file mode 100644 index 0000000..8873dbd --- /dev/null +++ b/src/utils/seo/common.ts @@ -0,0 +1,75 @@ +import type { Opt } from '@jet/environment/types/optional'; +import type { Organization } from 'schema-dts'; +import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page'; + +import type I18N from '@amp/web-apps-localization'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + +export const MAX_DESCRIPTION_LENGTH = 160; + +export const AppleOrganization: Organization = { + '@type': 'Organization', + name: 'Apple Inc', + url: 'http://www.apple.com', + logo: { + '@type': 'ImageObject', + url: 'https://www.apple.com/ac/structured-data/images/knowledge_graph_logo.png', + }, +}; + +export function updateCanonicalURL( + page: WebRenderablePage, + canonicalURL: string, +): void { + const seoData = page.seoData as Opt<SeoData>; + + if (!seoData) { + return; + } + + seoData.url = canonicalURL; +} + +export function seoDataForAnyPage( + page: WebRenderablePage, + i18n: I18N, +): SeoData { + const pageTitle = + 'title' in page + ? i18n.t('ASE.Web.AppStore.Meta.TitleWithPlatformAndSiteName', { + title: page.title, + platform: getPlatformFromPage(page), + }) + : i18n.t('ASE.Web.AppStore.Meta.SiteName'); + + const description = i18n.t('ASE.Web.AppStore.Meta.Description'); + + return { + url: page.canonicalURL ?? '', + siteName: i18n.t('ASE.Web.AppStore.Meta.SiteName'), + + pageTitle, + socialTitle: pageTitle, + appleTitle: pageTitle, + + description, + socialDescription: description, + appleDescription: description, + + width: 1200, + height: 630, + twitterWidth: 1200, + twitterHeight: 630, + twitterCropCode: 'wa', + crop: 'wa', + fileType: 'jpg', + artworkUrl: '/assets/images/share/app-store.png', + + twitterSite: '@AppStore', + }; +} + +export function getPlatformFromPage(page: WebRenderablePage): Opt<string> { + return page.webNavigation?.platforms.find((platform) => platform.isActive) + ?.action.title; +} diff --git a/src/utils/seo/developer-page.ts b/src/utils/seo/developer-page.ts new file mode 100644 index 0000000..914dd08 --- /dev/null +++ b/src/utils/seo/developer-page.ts @@ -0,0 +1,174 @@ +import { + type Opt, + unwrapOptional as unwrap, +} from '@jet/environment/types/optional'; +import type { Organization, WithContext } from 'schema-dts'; + +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { + type Data, + type DataContainer, + dataFromDataContainer, +} from '@jet-app/app-store/foundation/media/data-structure'; +import { attributeAsString } from '@jet-app/app-store/foundation/media/attributes'; +import { relationshipCollection } from '@jet-app/app-store/foundation/media/relationships'; + +import type I18N from '@amp/web-apps-localization'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + +import { uniqueById } from '~/utils/array'; +import { basicSoftwareApplicationSchema } from '~/utils/seo/product-page'; + +/** + * Generate a basic {@linkcode Person} schema for a "developer" page + * + * Note: this is appropriate to be embedded into another schema that + * needs to reference the developer + */ +export function basicDeveloperSchema(data: Data) { + return { + '@type': 'Organization', + name: attributeAsString(data, 'name') ?? undefined, + url: attributeAsString(data, 'url') ?? undefined, + } satisfies Organization; +} + +export function buildDeveloperDescription( + props: { + name: string; + }, + appData: Data[], + i18n: I18N, +) { + const { name: developerName } = props; + + switch (appData.length) { + case 0: + return i18n.t( + 'ASE.Web.AppStore.Meta.Developer.Description.ZeroApps', + { + developerName, + }, + ); + case 1: + return i18n.t( + 'ASE.Web.AppStore.Meta.Developer.Description.OneApp', + { + developerName, + listing1: attributeAsString(appData[0], 'name'), + }, + ); + case 2: + return i18n.t( + 'ASE.Web.AppStore.Meta.Developer.Description.TwoApps', + { + developerName, + listing1: attributeAsString(appData[0], 'name'), + listing2: attributeAsString(appData[1], 'name'), + }, + ); + case 3: + return i18n.t( + 'ASE.Web.AppStore.Meta.Developer.Description.ThreeApps', + { + developerName, + listing1: attributeAsString(appData[0], 'name'), + listing2: attributeAsString(appData[1], 'name'), + listing3: attributeAsString(appData[2], 'name'), + }, + ); + default: + return i18n.t( + 'ASE.Web.AppStore.Meta.Developer.Description.ManyApps', + { + developerName, + listing1: attributeAsString(appData[0], 'name'), + listing2: attributeAsString(appData[1], 'name'), + listing3: attributeAsString(appData[2], 'name'), + }, + ); + } +} + +/** + * Builds the Schema.org meta-data for a "Developer" page + * + * @param objectGraph The Object Graph + * @param developerPageData The `Data` for the Developer page + * @param appData The `Data` for all apps related to the Developer apge + * @param props Pre-formatted properties also used outside of the Schema + * @returns + */ +function developerOrganizationSchemaSeoData( + objectGraph: AppStoreObjectGraph, + developerPageData: Data, + appData: Data[], + props: { + description: string; + }, +): Opt<Partial<SeoData>> { + const { description } = props; + + const schemaContent: WithContext<Organization> = { + '@context': 'https://schema.org', + + ...basicDeveloperSchema(developerPageData), + + description, + hasOfferCatalog: { + '@type': 'OfferCatalog', + itemListElement: appData.map((app) => + basicSoftwareApplicationSchema(objectGraph, app), + ), + }, + }; + + return { + schemaName: 'developer', + schemaContent, + }; +} + +/** + * Builds the full `SeoData` requirements for a "Developer" page + */ +export function seoDataForDeveloperPage( + objectGraph: AppStoreObjectGraph, + container: Opt<DataContainer>, + i18n: I18N, +): Partial<SeoData> { + if (!container) { + return {}; + } + + const developerPageData = dataFromDataContainer(objectGraph, container); + if (!developerPageData) { + return {}; + } + + const allApps = uniqueById([ + ...unwrap(relationshipCollection(developerPageData, 'atv-apps')), + ...unwrap(relationshipCollection(developerPageData, 'app-bundles')), + ...unwrap(relationshipCollection(developerPageData, 'imessage-apps')), + ...unwrap(relationshipCollection(developerPageData, 'ios-apps')), + ...unwrap(relationshipCollection(developerPageData, 'mac-apps')), + ...unwrap(relationshipCollection(developerPageData, 'watch-apps')), + ]); + + const name = unwrap(attributeAsString(developerPageData, 'name')); + const description = buildDeveloperDescription({ name }, allApps, i18n); + + return { + description, + socialDescription: description, + appleDescription: description, + ...developerOrganizationSchemaSeoData( + objectGraph, + developerPageData, + allApps, + { + description, + }, + ), + }; +} diff --git a/src/utils/seo/editorial-shelf-collection-page.ts b/src/utils/seo/editorial-shelf-collection-page.ts new file mode 100644 index 0000000..dd152df --- /dev/null +++ b/src/utils/seo/editorial-shelf-collection-page.ts @@ -0,0 +1,51 @@ +import type I18N from '@amp/web-apps-localization'; +import type { GenericPage } from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import { isPageHeaderShelf } from '~/components/jet/shelf/PageHeaderShelf.svelte'; +import { getPlatformFromPage } from '~/utils/seo/common'; +import { commaSeparatedList } from '../string-formatting'; + +export function seoDataForEditorialShelfCollectionPage( + page: GenericPage, + i18n: I18N, +): SeoData { + let title = page.title; + let description; + const headerShelf = page.shelves.find(isPageHeaderShelf); + + if (headerShelf) { + title = headerShelf.items[0].title; + description = headerShelf.items[0].subtitle; + } + + if (!description) { + const platform = getPlatformFromPage(page); + const titles = page.shelves + .filter((shelf) => !isPageHeaderShelf(shelf)) + .flatMap(({ items }) => items) + .slice(0, 3) + .map((item) => item.title); + + description = i18n.t( + 'ASE.Web.AppStore.Meta.EditorialShelfCollection.Description', + { + platform, + listOfApps: commaSeparatedList(titles), + }, + ); + } + + const titleWithSiteName = i18n.t( + 'ASE.Web.AppStore.Meta.TitleWithSiteName', + { title }, + ); + + return { + pageTitle: titleWithSiteName, + socialTitle: titleWithSiteName, + appleTitle: titleWithSiteName, + description, + socialDescription: description, + appleDescription: description, + }; +} diff --git a/src/utils/seo/image-url.ts b/src/utils/seo/image-url.ts new file mode 100644 index 0000000..b2295f7 --- /dev/null +++ b/src/utils/seo/image-url.ts @@ -0,0 +1,71 @@ +import type { URL } from 'schema-dts'; +import type { Opt } from '@jet/environment/types/optional'; + +import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types'; +import { buildSrcSeo } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + +const RECOMMENDED_OPEN_GRAPH_IMAGE_WIDTH = 1200; +const RECOMMENDED_OPEN_GRAPH_IMAGE_HEIGHT = 630; + +const DEFAULT_OPEN_GRAPH_IMAGE_CROP = 'bb'; +const DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE = 'png'; + +/** + * Generate an OpenGraph image URL from a Media API artwork definition + * + * This overrides the default size of the image with the recommendations + * from the Open Graph documentation + */ +export function buildOpenGraphImageURL( + artworkDefinition: Opt<MapLike<JSONValue>>, + crop: CropCode = DEFAULT_OPEN_GRAPH_IMAGE_CROP, +): URL | undefined { + if (!artworkDefinition) { + return undefined; + } + + const { url } = artworkDefinition; + + if (typeof url !== 'string') { + return undefined; + } + + return ( + buildSrcSeo(url, { + crop, + width: RECOMMENDED_OPEN_GRAPH_IMAGE_WIDTH, + height: RECOMMENDED_OPEN_GRAPH_IMAGE_HEIGHT, + fileType: DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE, + }) ?? undefined + ); +} + +/** + * Construct a metadata-friendly URL for some Media API-provided artwork + */ +export function buildImageURL( + artworkDefinition: Opt<MapLike<JSONValue>>, +): URL | undefined { + if (!artworkDefinition) { + return undefined; + } + + const { url, width, height } = artworkDefinition; + + if ( + typeof url !== 'string' || + typeof width !== 'number' || + typeof height !== 'number' + ) { + return undefined; + } + + return ( + buildSrcSeo(url, { + crop: DEFAULT_OPEN_GRAPH_IMAGE_CROP, + width, + height, + fileType: DEFAULT_OPEN_GRAPH_IMAGE_FILE_TYPE, + }) ?? undefined + ); +} diff --git a/src/utils/seo/product-page.ts b/src/utils/seo/product-page.ts new file mode 100644 index 0000000..bc518ea --- /dev/null +++ b/src/utils/seo/product-page.ts @@ -0,0 +1,353 @@ +import type { Offer, SoftwareApplication, WithContext } from 'schema-dts'; + +import { + type Opt, + unwrapOptional as unwrap, +} from '@jet/environment/types/optional'; +import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models'; +import type { PreviewPlatform } from '@jet-app/app-store/api/models/preview-platform'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import { + type AttributePlatform, + type Data, + type DataContainer, + dataFromDataContainer, +} from '@jet-app/app-store/foundation/media/data-structure'; +import { + attributeAsArrayOrEmpty, + attributeAsDictionary, + attributeAsNumber, + attributeAsString, +} from '@jet-app/app-store/foundation/media/attributes'; +import { + platformAttributeAsBooleanOrFalse, + platformAttributeAsDictionary, + platformAttributeAsString, +} from '@jet-app/app-store/foundation/media/platform-attributes'; +import { + relationship, + relationshipCollection, +} from '@jet-app/app-store/foundation/media/relationships'; +import { + asString, + asNumber, +} from '@jet-app/app-store/foundation/json-parsing/server-data'; +import { bestAttributePlatformFromData } from '@jet-app/app-store/common/content/attributes'; +import { offerDataFromData } from '@jet-app/app-store/common/offers/offers'; + +import type I18N from '@amp/web-apps-localization'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import type { CropCode } from '@amp/web-app-components/src/components/Artwork/types'; + +import { basicDeveloperSchema } from './developer-page'; +import { buildOpenGraphImageURL, buildImageURL } from './image-url'; +import { truncateAroundLimit } from '~/utils/string-formatting'; +import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common'; +import { isProductBadgeShelf } from '~/components/jet/shelf/ProductBadgeShelf.svelte'; + +/// MARK: Primary Image + +/** + * Determine if the data for a product represents an app that **only** supports iMessage + */ +function isMessagesOnly(data: Data, attributePlatform: AttributePlatform) { + const hasMessagesExtension = platformAttributeAsBooleanOrFalse( + data, + attributePlatform, + 'hasMessagesExtension', + ); + const isHiddenFromSpringboard = platformAttributeAsBooleanOrFalse( + data, + attributePlatform, + 'isHiddenFromSpringboard', + ); + + return hasMessagesExtension && isHiddenFromSpringboard; +} + +function buildProductArtworkImage( + data: Data, + attributePlatform: AttributePlatform, +) { + let iconCropCode: CropCode | undefined = undefined; + + if (isMessagesOnly(data, attributePlatform)) { + iconCropCode = 'wb'; + } + + const deviceFamilies = attributeAsArrayOrEmpty(data, 'deviceFamilies'); + const hasIOSApp = deviceFamilies.includes('iphone'); + + if (hasIOSApp) { + iconCropCode = 'wa'; + } + + const artworkDefinition = + platformAttributeAsDictionary(data, attributePlatform, 'artwork') ?? + attributeAsDictionary(data, 'artwork'); + + return buildOpenGraphImageURL(artworkDefinition, iconCropCode); +} + +/// MARK: Screenshots + +const PREFERRED_SCREENSHOT_TYPE_BY_PLATFORM: Record<PreviewPlatform, string[]> = + { + iphone: [ + 'iphone_d74', + 'iphone_d73', + 'iphone_6_5', + 'iphone_5_8', + 'iphone6+', + 'iphone6', + 'iphone5', + 'iphone', + ], + ipad: ['ipadPro_2018', 'ipad_11', 'ipad', 'ipad_10_5', 'ipadPro'], + watch: [ + 'appleWatch_2024', + 'appleWatch_2022', + 'appleWatch_2021', + 'appleWatch_2018', + 'appleWatch', + ], + tv: ['appletv', 'appleTV'], + mac: [], + vision: [], + }; + +function buildProductScreenshots( + data: Data, + attributePlatform: AttributePlatform, + previewPlatform: PreviewPlatform, +) { + const screenshotsByType = platformAttributeAsDictionary( + data, + attributePlatform, + 'screenshotsByType', + ); + if (!screenshotsByType) { + return undefined; + } + + const preferredScreenshotType = PREFERRED_SCREENSHOT_TYPE_BY_PLATFORM[ + previewPlatform + ]?.find((preferredType) => preferredType in screenshotsByType); + if (!preferredScreenshotType) { + return undefined; + } + + const screenshotArtworkDefinitions = screenshotsByType[ + preferredScreenshotType + ] as Array<MapLike<JSONValue>>; + + return screenshotArtworkDefinitions + .map((screenshotArtworkDefinition) => + buildImageURL(screenshotArtworkDefinition), + ) + .filter((screenshot) => typeof screenshot !== 'undefined'); +} + +function buildOffer( + objectGraph: AppStoreObjectGraph, + data: Data, + attributePlatform: AttributePlatform, +): Offer | undefined { + const offer = offerDataFromData(objectGraph, data, attributePlatform); + if (!offer) { + return undefined; + } + + const price = asNumber(offer, 'price') ?? undefined; + const priceCurrency = asString(offer, 'currencyCode') ?? undefined; + const category = !price || price === 0 ? 'free' : undefined; + + return { + '@type': 'Offer', + price, + priceCurrency, + category, + }; +} + +function buildAvailableDevices(data: Data): string | undefined { + const deviceFamilies = attributeAsArrayOrEmpty(data, 'deviceFamilies'); + if (!deviceFamilies) { + return undefined; + } + + return deviceFamilies + .filter((device) => typeof device === 'string') + .map((device) => { + if (device === 'mac') { + return 'Mac'; + } else if (device.indexOf('ip') === 0) { + return device.replace(/^.{2}/g, 'iP'); + } else if (device === 'tvos') { + return 'Apple TV'; + } else if (device === 'watch') { + return 'Apple Watch'; + } + + return undefined; + }) + .filter((device) => !!device) + .join(', '); +} + +/** + * Produces a minimal {@linkcode SoftwareApplication} definition from a Media API `app` response + * + * Appropriate for embedding within another schema + */ +export function basicSoftwareApplicationSchema( + objectGraph: AppStoreObjectGraph, + data: Data, +) { + const allGenreData = relationshipCollection(data, 'genres'); + const firstGenreData = (allGenreData && allGenreData[0]) ?? undefined; + + const attributePlatformFromData: Opt<AttributePlatform> = + bestAttributePlatformFromData(objectGraph, data); + + if (!attributePlatformFromData) { + return null; + } + + const attributePlatform = unwrap(attributePlatformFromData); + + return { + '@type': 'SoftwareApplication', + + name: attributeAsString(data, 'name') ?? undefined, + description: + platformAttributeAsString( + data, + attributePlatform, + 'description.standard', + ) ?? undefined, + image: buildProductArtworkImage(data, attributePlatform), + availableOnDevice: buildAvailableDevices(data), + operatingSystem: + platformAttributeAsString( + data, + attributePlatform, + 'requirementsString', + ) ?? undefined, + offers: buildOffer(objectGraph, data, attributePlatform), + applicationCategory: firstGenreData + ? attributeAsString(firstGenreData, 'name') ?? undefined + : undefined, + + aggregateRating: { + '@type': 'AggregateRating', + ratingValue: + attributeAsNumber(data, 'userRating.value') ?? undefined, + reviewCount: + attributeAsNumber(data, 'userRating.ratingCount') ?? undefined, + }, + } satisfies SoftwareApplication; +} + +/// MARK: Schema Definition + +function softwareApplicationSchemaSeoData( + objectGraph: AppStoreObjectGraph, + container: Opt<DataContainer>, +): Opt<Partial<SeoData>> { + if (!container) { + return null; + } + + const productPageData = dataFromDataContainer(objectGraph, container); + if (!productPageData) { + return null; + } + + const developerDataContainer = relationship(productPageData, 'developer'); + const developerData = dataFromDataContainer( + objectGraph, + developerDataContainer, + ); + + const attributePlatform = unwrap( + bestAttributePlatformFromData(objectGraph, productPageData), + ); + + const schemaContent: WithContext<SoftwareApplication> = { + '@context': 'https://schema.org', + + ...basicSoftwareApplicationSchema(objectGraph, productPageData), + + author: developerData ? basicDeveloperSchema(developerData) : undefined, + screenshot: buildProductScreenshots( + productPageData, + attributePlatform, + unwrap(objectGraph.activeIntent?.previewPlatform), + ), + }; + + return { + schemaName: 'software-application', + schemaContent, + }; +} + +export function seoDataForProductPage( + objectGraph: AppStoreObjectGraph, + page: ShelfBasedProductPage, + data: Opt<DataContainer>, + i18n: I18N, + language: string, +): SeoData { + const artworkUrl = page.lockup.icon?.template; + const badgeShelf = Object.values(page.shelfMapping).find( + isProductBadgeShelf, + ); + const developerName = badgeShelf?.items.find( + ({ key }) => key === 'developer', + )?.caption; + + const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', { + title: i18n.t('ASE.Web.AppStore.Meta.Product.Title', { + appName: page.lockup.title, + }), + }); + + const descriptionLocKey = developerName + ? 'ASE.Web.AppStore.Meta.Product.Description' + : 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName'; + + const description = truncateAroundLimit( + i18n.t(descriptionLocKey, { + appName: page.lockup.title, + developerName, + }), + MAX_DESCRIPTION_LENGTH, + language, + ); + + // Removes all query parameters (including `platform=*`) to form the canonical version + // of the URL for the `link rel="canonical"` tag. + let url = page.canonicalURL; + if (url) { + const cleanCanonicalUrl = new URL(url); + cleanCanonicalUrl.search = ''; + url = cleanCanonicalUrl.toString(); + } + + return { + pageTitle: title, + socialTitle: title, + appleTitle: title, + canonicalUrl: url, + artworkUrl, + description, + socialDescription: description, + appleDescription: description, + imageAltTitle: i18n.t('ASE.Web.AppStore.Meta.Image.AltText', { + title: page.title, + }), + ...softwareApplicationSchemaSeoData(objectGraph, data), + }; +} diff --git a/src/utils/seo/reviews-page.ts b/src/utils/seo/reviews-page.ts new file mode 100644 index 0000000..041d7b8 --- /dev/null +++ b/src/utils/seo/reviews-page.ts @@ -0,0 +1,56 @@ +import type { + ReviewsPage, + ShelfBasedProductPage, +} from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import type { AppStoreObjectGraph } from '@jet-app/app-store/foundation/runtime/app-store-object-graph'; +import type I18N from '@amp/web-apps-localization'; + +import { truncateAroundLimit } from '~/utils/string-formatting'; +import { MAX_DESCRIPTION_LENGTH } from '~/utils/seo/common'; +import { isProductBadgeShelf } from '~/components/jet/shelf/ProductBadgeShelf.svelte'; + +export function seoDataForReviewsPage( + i18n: I18N, + page: ReviewsPage, + productPage: ShelfBasedProductPage, + objectGraph: AppStoreObjectGraph, +): SeoData { + const appName = productPage.lockup.title; + const artworkUrl = productPage.lockup.icon?.template; + const badgeShelf = Object.values(productPage.shelfMapping).find( + isProductBadgeShelf, + ); + const developerName = badgeShelf?.items.find( + ({ key }) => key === 'developer', + )?.caption; + + const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', { + title: i18n.t('ASE.Web.AppStore.Meta.Reviews.Title', { + appName, + }), + }); + + const descriptionLocKey = developerName + ? 'ASE.Web.AppStore.Meta.Product.Description' + : 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName'; + + const description = truncateAroundLimit( + i18n.t(descriptionLocKey, { + appName, + developerName, + }), + MAX_DESCRIPTION_LENGTH, + objectGraph.locale.activeLanguage, + ); + + return { + artworkUrl, + pageTitle: title, + socialTitle: title, + appleTitle: title, + description, + socialDescription: description, + appleDescription: description, + }; +} diff --git a/src/utils/seo/search-landing-page.ts b/src/utils/seo/search-landing-page.ts new file mode 100644 index 0000000..70a8bd4 --- /dev/null +++ b/src/utils/seo/search-landing-page.ts @@ -0,0 +1,18 @@ +import type { SearchResultsPage } from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import type I18N from '@amp/web-apps-localization'; + +export function seoDataForSearchLandingPage( + page: SearchResultsPage, + i18n: I18N, +): SeoData { + const title = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', { + title: i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'), + }); + + return { + pageTitle: title, + socialTitle: title, + appleTitle: title, + }; +} diff --git a/src/utils/seo/search-results-page.ts b/src/utils/seo/search-results-page.ts new file mode 100644 index 0000000..48bcdce --- /dev/null +++ b/src/utils/seo/search-results-page.ts @@ -0,0 +1,56 @@ +import type { SearchResultsPage } from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; +import type I18N from '@amp/web-apps-localization'; +import { + isSearchResultShelf, + isRenderableInSearchResultsShelf, +} from '~/components/jet/shelf/SearchResultShelf.svelte'; +import { commaSeparatedList } from '../string-formatting'; + +export function seoDataForSearchResultsPage( + page: SearchResultsPage, + i18n: I18N, + language: string, +): SeoData { + const term = page?.searchTermContext?.term; + const pageTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', { + title: page?.searchTermContext?.term, + }); + const shareTitle = i18n.t('ASE.Web.AppStore.Meta.TitleWithSiteName', { + title: i18n.t('ASE.Web.AppStore.Meta.SearchResults.Title', { + term: page?.searchTermContext?.term, + }), + }); + + const resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null; + + const renderableItems = (resultsShelf?.items ?? []).filter( + isRenderableInSearchResultsShelf, + ); + + const appNames = renderableItems + .slice(0, 3) + .map((item) => item.lockup.title); + + let description; + if (appNames.length) { + description = i18n.t( + 'ASE.Web.AppStore.Meta.SearchResults.Description', + { + term, + listOfApps: commaSeparatedList(appNames, language), + }, + ); + } + + return term + ? { + pageTitle, + socialTitle: shareTitle, + appleTitle: shareTitle, + description, + socialDescription: description, + appleDescription: description, + } + : {}; +} diff --git a/src/utils/seo/see-all-page.ts b/src/utils/seo/see-all-page.ts new file mode 100644 index 0000000..bbdf369 --- /dev/null +++ b/src/utils/seo/see-all-page.ts @@ -0,0 +1,47 @@ +import type I18N from '@amp/web-apps-localization'; +import type { SeeAllPage } from '@jet-app/app-store/api/models'; +import type { SeoData } from '@amp/web-app-components/src/components/MetaTags/types'; + +export function seoDataForSeeAllPage(page: SeeAllPage, i18n: I18N): SeoData { + let title = i18n.t('ASE.Web.AppStore.Meta.Product.Title'); + const shelfName = { + reviews: 'productRatings', + 'customers-also-bought-apps': 'similarItems', + 'developer-other-apps': 'moreByDeveloper', + }[page.seeAllType]; + + if (shelfName) { + const shelf = page.shelfMapping[shelfName]; + title = `${page.title} - ${shelf.title}`; + } + + const titleWithSiteName = i18n.t( + 'ASE.Web.AppStore.Meta.TitleWithSiteName', + { title }, + ); + + const descriptionLocKey = + { + reviews: 'ASE.Web.AppStore.SeeAll.Reviews.Meta.Description', + 'customers-also-bought-apps': + 'ASE.Web.AppStore.SeeAll.CustomersAlsoBoughtApps.Meta.Description', + 'developer-other-apps': + 'ASE.Web.AppStore.SeeAll.DeveloperOtherApps.Meta.Description', + }[page.seeAllType] || + 'ASE.Web.AppStore.Meta.Product.DescriptionWithoutDeveloperName'; + const description = i18n.t(descriptionLocKey, { + appName: page.title, + }); + + const artworkUrl = page.lockup.icon?.template; + + return { + pageTitle: titleWithSiteName, + socialTitle: titleWithSiteName, + appleTitle: titleWithSiteName, + description, + socialDescription: description, + appleDescription: description, + artworkUrl, + }; +} diff --git a/src/utils/shelves.ts b/src/utils/shelves.ts new file mode 100644 index 0000000..e144f4b --- /dev/null +++ b/src/utils/shelves.ts @@ -0,0 +1,56 @@ +import type { + ShelfBasedProductPage, + Shelf, +} from '@jet-app/app-store/api/models'; +import { isProductMediaShelf } from '~/components/jet/shelf/ProductMediaShelf.svelte'; + +type ShelfWithExpandedMedia = Shelf & { + expandedMedia?: ShelfWithExpandedMedia[]; +}; + +export const getProductPageShelvesForOrdering = ( + page: ShelfBasedProductPage, + shelfOrder: string, +): Shelf[] => { + return ( + page.shelfOrderings[shelfOrder] + ?.map((shelfIdentifier) => page.shelfMapping[shelfIdentifier]) + // The type system doesn't reflect this, but ordering identifier may be provided for + // shelves that do not exist. We should probably filter those out + .filter((shelf): shelf is Shelf => !!shelf) + ); +}; + +export const getProductPageShelvesWithExpandedMedia = ( + page: ShelfBasedProductPage, +): ShelfWithExpandedMedia[] => { + const { defaultShelfOrdering = 'notPurchasedOrdering' } = page; + + const shelves = getProductPageShelvesForOrdering( + page, + defaultShelfOrdering, + ) as ShelfWithExpandedMedia[]; + + // find the location of the product media of selected platform in shelves + const mainMediaShelfIndex = shelves.findIndex((shelf) => + isProductMediaShelf(shelf), + ); + + let expandedMedia: ShelfWithExpandedMedia[] | undefined; + + if (mainMediaShelfIndex !== -1) { + expandedMedia = getProductPageShelvesForOrdering( + page, + 'notPurchasedOrdering_ExpandedMedia', + ) + .filter((shelf) => isProductMediaShelf(shelf)) + // filter out the product media shelf of selected platform to avoid duplicate shelves + .filter(({ id }) => id !== shelves[mainMediaShelfIndex].id); + } + + if (expandedMedia) { + shelves[mainMediaShelfIndex].expandedMedia = expandedMedia; + } + + return shelves; +}; diff --git a/src/utils/storefront-data.ts b/src/utils/storefront-data.ts new file mode 100644 index 0000000..9e2d848 --- /dev/null +++ b/src/utils/storefront-data.ts @@ -0,0 +1,15 @@ +import type { + Region, + Languages, +} from '@amp/web-app-components/src/components/buttons/LocaleSwitcherButton/types'; +import type { StorefrontNames } from '@amp/web-app-components/src/components/banners/types'; +import { + regions as outputtedRegions, + languages as outputtedLanguages, +} from 'virtual:storefronts'; +import { getFormattedStorefrontNameTranslations } from '@amp/web-app-storefronts'; + +export const regions: Region[] = outputtedRegions; +export const languages: Languages = outputtedLanguages; +export const storefrontNameTranslations: StorefrontNames = + getFormattedStorefrontNameTranslations(regions); diff --git a/src/utils/string-formatting.ts b/src/utils/string-formatting.ts new file mode 100644 index 0000000..ff9f8bb --- /dev/null +++ b/src/utils/string-formatting.ts @@ -0,0 +1,126 @@ +import type I18N from '@amp/web-apps-localization'; +import he from 'he'; + +export function isString(string: unknown): string is string { + return typeof string === 'string'; +} + +export function concatWithMiddot(pieces: string[], i18n: I18N): string { + if (!pieces.length) { + return ''; + } + + return ( + pieces.reduce((memo, current) => { + return i18n.t('ASE.Web.AppStore.ContentA.Middot.ContentB', { + contentA: memo, + contentB: current, + }); + }) || '' + ); +} + +/** + * Truncates a block of text to fit within a character limit, with a bias towards ending on a + * full sentence. If no complete sentence fits within the limit, it falls back to a word-based + * truncation with an ellipsis. + * + * @param {string} text - The text to truncate. + * @param {number} limit - The maximum number of characters allowed before truncation. + * @param {string} [locale=en_US] - The locale to use when breaking the text into segments. + * @returns {string} Truncated text clipped to the limit, ideally ending on a natural stopping point. + */ +export function truncateAroundLimit( + text: string, + limit: number, + locale: string = 'en-US', +): string { + // If the text is shorter than the limit, return all the text, unaltered. + if (text.length <= limit) { + return text; + } + + const decodedText = he.decode(text); + + const isSegemnterSupported = typeof Intl.Segmenter === 'function'; + const terminatingPunctuation = '…'; + + // A very naive fallback if the browser doesn't support `Segementer`, + // which just truncates the text to the last space before the `limit`. + if (!isSegemnterSupported) { + const truncatedText = decodedText.slice(0, limit); + const indexOfLastSpace = truncatedText.lastIndexOf(' '); + if (indexOfLastSpace) { + return ( + truncatedText.slice(0, indexOfLastSpace).trim() + + terminatingPunctuation + ); + } else { + // If the text is an _exteremly_ long word or block of text, like a URL + return truncatedText.trim() + terminatingPunctuation; + } + } + + const sentences = Array.from( + new Intl.Segmenter(locale, { granularity: 'sentence' }).segment(text), + (s) => s.segment, + ); + + let result = ''; + for (const sentence of sentences) { + // If there is still room to add another sentence without going over the limit, add it. + if (result.length + sentence.length <= limit) { + result += sentence; + } else { + break; + } + } + + result = result.trim(); + + // If the result we built based on full sentences is close-enough to the desired limit + // (e.g. within the threshold of 75% of 160), we can use it. + if (result.length >= limit * 0.75) { + return result; + } + + // Otherwise, fallback to building up single words until we approach the limit. + const segments = Array.from( + new Intl.Segmenter(locale, { granularity: 'word' }).segment( + decodedText, + ), + ); + + result = ''; + for (const { segment } of segments) { + if (result.length + segment.length <= limit) { + result += segment; + } else { + break; + } + } + + return result.trim() + terminatingPunctuation; +} + +export function escapeHtml(text: string): string { + return text + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>'); +} + +export function commaSeparatedList(items: Array<string>, locale = 'en') { + return new Intl.ListFormat(locale, { + style: 'long', + type: 'conjunction', + }).format(items); +} + +export function stripTags(text: string) { + return text.replace(/(<([^>]+)>)/gi, ''); +} + +export function stripUnicodeWhitespace(text: string) { + return text.replace(/[\u0000-\u001F]/g, ''); +} diff --git a/src/utils/transition.ts b/src/utils/transition.ts new file mode 100644 index 0000000..e89b038 --- /dev/null +++ b/src/utils/transition.ts @@ -0,0 +1,45 @@ +import { cubicOut } from 'svelte/easing'; +import type { EasingFunction, TransitionConfig } from 'svelte/transition'; + +interface FlyAndBlurParams { + // Time (ms) before the animation starts. + delay?: number; + // Total animation time (ms). + duration?: number; + // Easing function (defaults to cubicOut). + easing?: EasingFunction; + // Horizontal offset in pixels at start (like `fly`). + x?: number; + // Vertical offset in pixels at start (like `fly`). + y?: number; + // Initial blur radius in pixels. + blur?: number; +} + +export function flyAndBlur( + node: Element, + { + delay = 0, + duration = 420, + easing = cubicOut, + x = 0, + y = 0, + blur = 3, + }: FlyAndBlurParams = {}, +): TransitionConfig { + const style = getComputedStyle(node); + const initialOpacity = +style.opacity; + + return { + delay, + duration, + easing, + css: (t: number, u: number) => { + return ` + transform: translate(${x * u}px, ${y * u}px); + opacity: ${initialOpacity * t}; + filter: blur(${blur * u}px); + `; + }, + }; +} diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..4cb85aa --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,17 @@ +/** + * Determine if {@linkcode input} matches the `"object"` type + */ +export function isObject(input: unknown): input is object { + return typeof input === 'object' && !!input; +} + +type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }; + +/** + * Helper type for creating an exclusive union between two types + * + * @see {@link https://stackoverflow.com/a/53229567/2250435 | StackOverflow Post} + */ +export type XOR<T, U> = T | U extends object + ? (Without<T, U> & U) | (Without<U, T> & T) + : T | U; diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..8596d89 --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,13 @@ +/** + * Removes the protcol, host and port from a URL, returning + * just the path and search portions + * + * This is useful for taking a URL that points to the production site + * and removing anything specific to the location that it is deployed, + * creating a partial URL that works both locally or when deployed + */ +export function stripHost(input: string): string { + const url = new URL(input); + + return url.pathname + url.search; +} diff --git a/src/utils/video-poster.ts b/src/utils/video-poster.ts new file mode 100644 index 0000000..e2e32ed --- /dev/null +++ b/src/utils/video-poster.ts @@ -0,0 +1,27 @@ +import type { Artwork } from '@jet-app/app-store/api/models'; +import type { Profile } from '@amp/web-app-components/src/components/Artwork/types'; +import type { Size } from '@amp/web-app-components/src/types'; +import type { NamedProfile } from 'src/config/components/artwork'; +import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; +import { getDataFromProfile } from '@amp/web-app-components/src/components/Artwork/utils/artProfile'; + +export const buildPoster = ( + preview: Artwork, + profile: NamedProfile | Profile, + mediaQuery: string, +): ReturnType<typeof buildSrc> => { + const profileData = getDataFromProfile(profile); + const imageAttributes = profileData[mediaQuery as Size] || preview; + const dpr = typeof window !== 'undefined' ? window.devicePixelRatio : 2; + + return buildSrc( + preview.template, + { + crop: 'sr', + width: imageAttributes.width * dpr, + height: imageAttributes.height * dpr, + fileType: 'webp', + }, + {}, + ); +}; |
