diff options
Diffstat (limited to 'src/components/jet/shelf')
59 files changed, 3939 insertions, 0 deletions
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> |
