diff options
| author | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
|---|---|---|
| committer | rxliuli <rxliuli@gmail.com> | 2025-11-04 05:03:50 +0800 |
| commit | bce557cc2dc767628bed6aac87301a1be7c5431b (patch) | |
| tree | b51a051228d01fe3306cd7626d4a96768aadb944 /src/components/jet/marker-shelf | |
init commit
Diffstat (limited to 'src/components/jet/marker-shelf')
| -rw-r--r-- | src/components/jet/marker-shelf/ProductTopLockup.svelte | 463 |
1 files changed, 463 insertions, 0 deletions
diff --git a/src/components/jet/marker-shelf/ProductTopLockup.svelte b/src/components/jet/marker-shelf/ProductTopLockup.svelte new file mode 100644 index 0000000..e56e5b0 --- /dev/null +++ b/src/components/jet/marker-shelf/ProductTopLockup.svelte @@ -0,0 +1,463 @@ +<script lang="ts" context="module"> + import type { + AppPlatform, + ShelfBasedProductPage, + } from '@jet-app/app-store/api/models'; + + /** + * The parts of {@linkcode ShelfBasedProductPage} that are required to render + * the `MarkerShelf` component + */ + export type MarkerShelfPageRequirements = Pick< + ShelfBasedProductPage, + | 'badges' + | 'banner' + | 'developerAction' + | 'lockup' + | 'shelfMapping' + | 'titleOfferDisplayProperties' + | 'canonicalURL' + | 'appPlatforms' + >; +</script> + +<script lang="ts"> + import { onMount } from 'svelte'; + import { buildSrc } from '@amp/web-app-components/src/components/Artwork/utils/srcset'; + import { platform } from '@amp/web-apps-utils'; + import AppIcon, { + doesAppIconNeedBorder, + } from '~/components/AppIcon.svelte'; + import Banner from '~/components/jet/item/BannerItem.svelte'; + import ShelfWrapper from '~/components/Shelf/Wrapper.svelte'; + import ProductPageArcadeBanner from '~/components/ProductPageArcadeBanner.svelte'; + import { getI18n } from '~/stores/i18n'; + import { colorAsString, isNamedColor, isRGBColor } from '~/utils/color'; + import { concatWithMiddot, isString } from '~/utils/string-formatting'; + import { + isPlatformExclusivelySupported, + isPlatformSupported, + PlatformToExclusivityText, + } from '~/utils/app-platforms'; + import AppleArcadeLogo from '~/components/icons/AppleArcadeLogo.svg'; + import ShareArrowButton, { + isShareSupported, + } from '~/components/ShareArrowButton.svelte'; + import LaunchNativeButton from '~/components/LaunchNativeButton.svelte'; + + export let page: MarkerShelfPageRequirements; + + $: banner = page.banner; + $: lockup = page.lockup; + $: appPlatforms = page.appPlatforms; + $: offerDisplayProperties = lockup.offerDisplayProperties || {}; + $: ({ expectedReleaseDate } = offerDisplayProperties?.subtitles || {}); + + const i18n = getI18n(); + + // TODO: replace with `supportsArcade` from Jet + // rdar://143706610 (Support `supportsArcade` attribute) + $: supportsArcade = offerDisplayProperties.offerType === 'arcadeApp'; + + $: backgroundColor = isRGBColor(lockup.icon?.backgroundColor) + ? colorAsString(lockup.icon.backgroundColor) + : '#fff'; + + $: backgroundImage = lockup.icon + ? buildSrc( + lockup.icon.template, + { + crop: 'bb', + width: 400, + height: 400, + fileType: 'webp', + }, + {}, + ) + : undefined; + + $: attributes = concatWithMiddot( + [ + expectedReleaseDate && $i18n.t('ASE.Web.AppStore.App.ComingSoon'), + expectedReleaseDate && expectedReleaseDate, + // Attributes that are not relevant for Arcade Apps: + ...(!supportsArcade + ? [ + page.titleOfferDisplayProperties?.isFree && + $i18n.t('ASE.Web.AppStore.Free'), + offerDisplayProperties.priceFormatted, + offerDisplayProperties.subtitles?.standard, + lockup.tertiaryTitle, + ] + : []), + ].filter(isString), + $i18n, + ); + + $: exclusivePlatform = ( + Object.keys(PlatformToExclusivityText) as AppPlatform[] + ).find((platform: AppPlatform) => + isPlatformExclusivelySupported(platform, appPlatforms), + ); + $: exclusivityText = exclusivePlatform + ? PlatformToExclusivityText[exclusivePlatform] + : null; + + $: shouldShowLaunchNativeButton = + platform.ismacOS() && + (lockup.isIOSBinaryMacOSCompatible || + isPlatformSupported('mac', appPlatforms)); + + let shouldShowShareButton: boolean = true; + + onMount(() => { + shouldShowShareButton = isShareSupported(); + }); +</script> + +<ShelfWrapper withBottomPadding={false} withPaddingTop={false}> + <div + class="container" + style:--background-color={backgroundColor} + style:--background-image={`url(${backgroundImage})`} + > + <div class="rotate" /> + <div class="blur" /> + + <div class="content-container"> + {#if lockup.icon} + <div + class="app-icon-contianer" + class:without-border={!doesAppIconNeedBorder(lockup.icon)} + aria-hidden="true" + > + <AppIcon + icon={lockup.icon} + profile="app-icon-large" + fixedWidth={false} + /> + + <div class="glow"> + <AppIcon + icon={lockup.icon} + profile="app-icon-large" + fixedWidth={false} + /> + </div> + </div> + {/if} + + <section> + {#if supportsArcade} + <span + class="arcade-logo" + aria-label={$i18n.t( + 'ASE.Web.AppStore.ArcadeLogo.AccessibilityValue', + )} + > + <AppleArcadeLogo /> + </span> + {:else if lockup.editorialTagline} + <h3>{lockup.editorialTagline}</h3> + {/if} + + <h1> + {lockup.title} + </h1> + + <h2 class="subtitle"> + {lockup.subtitle} + </h2> + + {#if exclusivityText} + <p class="attributes"> + {$i18n.t(exclusivityText)} + </p> + {/if} + + {#if attributes.length > 0} + <p class="attributes"> + {attributes} + </p> + {/if} + + {#if page.canonicalURL && (shouldShowLaunchNativeButton || shouldShowShareButton)} + <div class="buttons-container"> + {#if shouldShowLaunchNativeButton} + <span class="launch-native-button-container"> + <LaunchNativeButton url={page.canonicalURL} /> + </span> + {/if} + + {#if shouldShowShareButton} + <!-- + If there is no launch native button, then we show a label for + the share button, which helps to visually fill out the space. + --> + <ShareArrowButton + url={page.canonicalURL} + withLabel={!shouldShowLaunchNativeButton} + /> + {/if} + </div> + {/if} + </section> + </div> + </div> +</ShelfWrapper> + +{#if banner} + <ShelfWrapper withBottomPadding={false} withTopMargin={false}> + <Banner item={banner} /> + </ShelfWrapper> +{/if} + +{#if supportsArcade} + <ShelfWrapper + withBottomPadding={false} + withTopMargin={true} + centered={false} + > + <ProductPageArcadeBanner /> + </ShelfWrapper> +{/if} + +<style> + .container { + --blend-mode: plus-lighter; + position: relative; + display: flex; + overflow: hidden; + align-items: center; + height: 200px; + color: var(--systemPrimary-onDark); + border-bottom: 1px solid var(--systemQuaternary-vibrant); + border-bottom-right-radius: 2px; + border-bottom-left-radius: 2px; + background: linear-gradient( + to bottom, + transparent 20%, + rgba(0, 0, 0, 0.8) 100% + ), + var(--background-image), var(--background-color, #000); + background-size: cover; + background-position: center; + transition: border-bottom-left-radius 210ms ease-out, + border-bottom-right-radius 210ms ease-out; + transform: translate(0); + + @media (--range-small-up) { + height: 286px; + } + + @media (--range-xlarge-up) { + border: 1px solid var(--systemQuaternary-vibrant); + border-top: none; + border-bottom-right-radius: 30px; + border-bottom-left-radius: 30px; + } + } + + .glow { + position: absolute; + z-index: -1; + top: 0; + width: 100%; + transform: scale(1.5); + filter: blur(60px); + } + + .blur { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 100%; + height: 100%; + backdrop-filter: blur(100px) saturate(1.5); + } + + .rotate { + position: absolute; + z-index: 2; + top: 0; + left: 0; + width: 100%; + filter: brightness(1.3) saturate(0) blur(50px); + mix-blend-mode: overlay; + height: 500%; + background-image: var(--background-image); + background-repeat: repeat; + opacity: 0; + transform-origin: top center; + animation: shift-background 60s infinite linear 10s; + } + + .content-container { + display: flex; + flex-direction: row; + max-width: 840px; + gap: 1em; + margin: 0 var(--bodyGutter); + + @media (--range-small-up) { + gap: 1.5em; + } + + @media (--range-medium-up) { + gap: 2em; + } + } + + .app-icon-contianer { + position: relative; + z-index: 2; + display: flex; + align-items: center; + width: 128px; + flex-shrink: 0; + + @media (--range-small-up) { + width: 194px; + } + } + + .app-icon-contianer:not(.without-border) :global(> .app-icon) { + box-shadow: 0 0 30px rgba(0, 0, 0, 0.33); + border: 2px solid var(--systemQuaternary-onDark); + } + + section { + display: flex; + flex-direction: column; + justify-content: center; + } + + .subtitle, + .attributes { + position: relative; + z-index: 2; + margin-bottom: 4px; + font: var(--body); + color: rgba(245.973, 245.973, 245.973, 0.6); + text-wrap: pretty; + mix-blend-mode: var(--blend-mode); + + @media (--range-small-up) { + margin-bottom: 8px; + font: var(--title-2-emphasized); + } + } + + .attributes { + margin-bottom: 0; + font: var(--body); + } + + .buttons-container { + --share-arrow-size: 27px; + --launch-native-button-arrow-size: 7px; + --get-button-font: var(--footnote-bold); + margin-top: 10px; + display: flex; + align-items: center; + gap: 10px; + + @media (--range-small-up) { + --share-arrow-size: unset; + --launch-native-button-arrow-size: unset; + --get-button-font: unset; + } + } + + h1 { + position: relative; + z-index: 2; + display: flex; + align-items: center; + margin-bottom: 2px; + font: var(--title-2-emphasized); + color: white; + text-shadow: 0 0 5px rgba(0, 0, 0, 0.2); + text-wrap: pretty; + + @media (--sidebar-visible) { + font: var(--title-1-emphasized); + } + + @media (--range-small-up) { + font: var(--header-emphasized); + letter-spacing: -0.02em; + } + } + + h3 { + margin-bottom: 0; + position: relative; + z-index: 2; + mix-blend-mode: plus-lighter; + font: var(--body-emphasized); + + @media (--range-small-up) { + font: var(--title-3-emphasized); + } + } + + .arcade-logo { + display: flex; + height: 10px; + margin-bottom: 4px; + position: relative; + z-index: 2; + mix-blend-mode: plus-lighter; + + @media (--range-small-up) { + height: 14px; + } + } + + .launch-native-button-container { + position: relative; + z-index: 2; + } + + @keyframes shift-background { + 0% { + background-position: 50% 50%; + background-size: 100%; + transform: rotate(0deg); + opacity: 0; + } + + 10% { + opacity: 0.5; + } + + 20% { + background-position: 65% 25%; + background-size: 160%; + transform: rotate(45deg); + } + + 45% { + background-position: 90% 60%; + background-size: 250%; + transform: rotate(160deg); + opacity: 0.5; + } + + 70% { + background-position: 70% 40%; + background-size: 200%; + transform: rotate(250deg); + opacity: 0.5; + } + + 100% { + background-position: 50% 50%; + background-size: 100%; + transform: rotate(360deg); + opacity: 0; + } + } +</style> |
