summaryrefslogtreecommitdiff
path: root/src/components/pages
diff options
context:
space:
mode:
authorrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
committerrxliuli <rxliuli@gmail.com>2025-11-04 05:03:50 +0800
commitbce557cc2dc767628bed6aac87301a1be7c5431b (patch)
treeb51a051228d01fe3306cd7626d4a96768aadb944 /src/components/pages
init commit
Diffstat (limited to 'src/components/pages')
-rw-r--r--src/components/pages/AppEventDetailPage.svelte44
-rw-r--r--src/components/pages/ArticlePage.svelte141
-rw-r--r--src/components/pages/ChartsHubPage.svelte11
-rw-r--r--src/components/pages/DefaultPage.svelte173
-rw-r--r--src/components/pages/ErrorPage.svelte23
-rw-r--r--src/components/pages/ProductPage.svelte77
-rw-r--r--src/components/pages/SearchLandingPage.svelte33
-rw-r--r--src/components/pages/SearchResultsPage.svelte113
-rw-r--r--src/components/pages/SeeAllPage.svelte56
-rw-r--r--src/components/pages/StaticMessagePage.svelte113
-rw-r--r--src/components/pages/TodayPage.svelte22
-rw-r--r--src/components/pages/TopChartsPage.svelte218
-rw-r--r--src/components/pages/VisionProPage.svelte12
13 files changed, 1036 insertions, 0 deletions
diff --git a/src/components/pages/AppEventDetailPage.svelte b/src/components/pages/AppEventDetailPage.svelte
new file mode 100644
index 0000000..a2b798e
--- /dev/null
+++ b/src/components/pages/AppEventDetailPage.svelte
@@ -0,0 +1,44 @@
+<script lang="ts" context="module">
+ import type { DefaultPageRequirements } from './DefaultPage.svelte';
+</script>
+
+<script lang="ts">
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+
+ export let page: DefaultPageRequirements;
+</script>
+
+<div class="app-event-detail-page-container">
+ <div class="shelf-container">
+ {#each page.shelves as shelf}
+ <ShelfComponent {shelf} />
+ {/each}
+ </div>
+</div>
+
+<style>
+ .app-event-detail-page-container {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ padding: 0 var(--bodyGutter);
+ }
+
+ .shelf-container {
+ display: flex;
+ flex: 1;
+ flex-direction: column;
+ width: 100%;
+ max-width: 900px;
+ margin: 0 auto;
+
+ @media (--range-small-up) {
+ justify-content: center;
+ }
+ }
+
+ .shelf-container :global(.shelf) {
+ margin: 0;
+ padding: var(--bodyGutter) 0 0;
+ }
+</style>
diff --git a/src/components/pages/ArticlePage.svelte b/src/components/pages/ArticlePage.svelte
new file mode 100644
index 0000000..32cacb0
--- /dev/null
+++ b/src/components/pages/ArticlePage.svelte
@@ -0,0 +1,141 @@
+<script lang="ts" context="module">
+ import type { ArticlePage } from '@jet-app/app-store/api/models';
+
+ import type { DefaultPageRequirements } from './DefaultPage.svelte';
+
+ /**
+ * Just the `Page` props that we actually need to render this component
+ */
+ export type ArticlePageRequirements = DefaultPageRequirements & {
+ card: ArticlePage['card'];
+ footerLockup: ArticlePage['footerLockup'];
+ };
+</script>
+
+<script lang="ts">
+ import TodayCard from '~/components/jet/today-card/TodayCard.svelte';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import FooterLockupItem from '~/components/jet/item/FooterLockupItem.svelte';
+ export let page: ArticlePageRequirements;
+
+ $: ({ card } = page);
+</script>
+
+<div class="article-page-container" data-testid="article-page-container">
+ <div class="article-layout">
+ {#if card}
+ <div class="card-container">
+ <TodayCard {card} suppressClickAction />
+ </div>
+ {/if}
+
+ <div class="story-container">
+ {#each page.shelves as shelf}
+ {#if !shelf.isHidden}
+ <ShelfComponent {shelf} />
+ {/if}
+ {/each}
+
+ {#if page.footerLockup}
+ <div class="footer-lockup-container">
+ <FooterLockupItem item={page.footerLockup} />
+ </div>
+ {/if}
+ </div>
+ </div>
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .article-page-container {
+ flex-grow: 1;
+ width: 100%;
+ margin: 0 auto;
+ }
+
+ .article-layout {
+ --article-page-padding: 20px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: var(--article-page-padding);
+ max-width: 1600px;
+ margin: 0 auto;
+
+ @media (--range-small-up) {
+ padding: 2em var(--bodyGutter);
+ }
+
+ @media (--range-small-only) {
+ --article-page-padding: 40px;
+ }
+
+ @media (--range-medium-up) {
+ align-items: flex-start;
+ flex-direction: row;
+ }
+
+ @media (--range-medium-only) {
+ --article-page-padding: 20px;
+ }
+
+ @media (--range-large-up) {
+ --article-page-padding: 40px;
+ }
+ }
+
+ .card-container {
+ flex-shrink: 0;
+ aspect-ratio: 3/4;
+ width: 100%;
+
+ @media (--range-xsmall-only) {
+ --border-radius: 0;
+ }
+
+ @media (--range-small-only) {
+ aspect-ratio: 16/9;
+ }
+
+ @media (--range-small-up) {
+ width: 100%;
+ }
+
+ @media (--range-medium-up) {
+ position: sticky;
+ top: 2em;
+ aspect-ratio: 3 / 4;
+ height: min(calc(100vh - 80px), calc(33vw * 4 / 3));
+ min-height: 420px;
+ max-height: 700px;
+ width: auto;
+ }
+ }
+
+ .story-container {
+ width: 100%;
+ margin-top: 20px;
+ padding-bottom: var(--bodyGutter);
+
+ @media (--range-small-up) {
+ width: calc(100%);
+ margin-top: 0;
+ }
+
+ @media (--range-medium-up) {
+ min-width: calc(50% - calc(var(--article-page-padding)));
+ }
+ }
+
+ .story-container :global(.shelf:first-of-type) {
+ padding-top: 0;
+ padding-bottom: 13px;
+ }
+
+ .footer-lockup-container {
+ margin: var(--bodyGutter);
+ }
+</style>
diff --git a/src/components/pages/ChartsHubPage.svelte b/src/components/pages/ChartsHubPage.svelte
new file mode 100644
index 0000000..a75cb64
--- /dev/null
+++ b/src/components/pages/ChartsHubPage.svelte
@@ -0,0 +1,11 @@
+<script lang="ts">
+ import type { ChartsHubPage } from '@jet-app/app-store/api/models';
+
+ import TopChartsPage from './TopChartsPage.svelte';
+
+ export let page: ChartsHubPage;
+</script>
+
+{#each page.charts as chart}
+ <TopChartsPage page={chart} />
+{/each}
diff --git a/src/components/pages/DefaultPage.svelte b/src/components/pages/DefaultPage.svelte
new file mode 100644
index 0000000..7905b07
--- /dev/null
+++ b/src/components/pages/DefaultPage.svelte
@@ -0,0 +1,173 @@
+<script lang="ts" context="module">
+ import type {
+ PagePresentationOptions,
+ Shelf,
+ } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ /**
+ * Just the `Page` props that we actually need to render this component
+ */
+ export interface DefaultPageRequirements extends WebRenderablePage {
+ shelves: Shelf[];
+ presentationOptions?: PagePresentationOptions;
+ }
+</script>
+
+<script lang="ts">
+ import type { MarkerShelf } from '~/components/jet/shelf/MarkerShelf.svelte';
+ import { isUberShelf } from '~/components/jet/shelf/UberShelf.svelte';
+ import ShelfComponent from '~/components/jet/shelf/Shelf.svelte';
+ import { partition } from '~/utils/array';
+ import { carouselMediaStyle } from '~/stores/carousel-media-style';
+ import mediaQueries from '~/utils/media-queries';
+ import { isHeroCarouselShelf } from '../jet/shelf/HeroCarouselShelf.svelte';
+ import { isRtl } from '~/utils/locale';
+
+ interface $$Slots {
+ 'before-shelves': {};
+
+ /**
+ * If {@linkcode ShelfComponent}` recognizes a shelf to be a {@linkcode MarkerShelf},
+ * this slot will be rendered so that the "page" data can be supplied by a "parent"
+ * component
+ */
+ 'marker-shelf': {
+ shelf: MarkerShelf;
+ };
+ }
+
+ export let page: DefaultPageRequirements;
+
+ $: ({ title, presentationOptions = [] } = page);
+
+ // Some shelves are meant to be rendered above the title, rather than below it
+ $: [aboveTitleShelves, belowTitleShelves] = partition(
+ page.shelves,
+ (shelf) => {
+ // Some "uber" shelves might be placed above the title
+ if (isUberShelf(shelf)) {
+ const [uber] = shelf.items;
+ return uber.style === 'above';
+ }
+
+ // Everything else should be below it
+ return false;
+ },
+ );
+
+ $: prefersHiddenPageTitle = presentationOptions.includes(
+ 'prefersHiddenPageTitle',
+ );
+ $: prefersLargeTitle = presentationOptions.includes('prefersLargeTitle');
+ $: prefersOverlayedPageHeader =
+ $mediaQueries === 'xsmall' &&
+ presentationOptions.includes('prefersOverlayedPageHeader');
+ $: isOnDarkBackground =
+ prefersOverlayedPageHeader && $carouselMediaStyle === 'dark';
+
+ $: isTitleDuplicatedInHero = (() => {
+ const firstShelf = page.shelves?.[0];
+
+ if (
+ !firstShelf ||
+ !isHeroCarouselShelf(firstShelf) ||
+ firstShelf.items?.length !== 1
+ ) {
+ return false;
+ }
+
+ const { items: ltrItems, rtlItems } = firstShelf.items?.[0] ?? {};
+ const firstItem = isRtl() && rtlItems?.length ? rtlItems : ltrItems;
+ const firstTitle = firstItem?.[0]?.overlay?.titleText;
+
+ return title === firstTitle;
+ })();
+</script>
+
+<div
+ class="default-page-container"
+ data-testid="default-page-container"
+ class:with-overlaid-title={prefersOverlayedPageHeader}
+ class:with-title-in-hero={isTitleDuplicatedInHero}
+>
+ {#each aboveTitleShelves as shelf}
+ <ShelfComponent {shelf}>
+ <slot name="marker-shelf" slot="marker-shelf" let:shelf {shelf} />
+ </ShelfComponent>
+ {/each}
+
+ {#if title && !prefersHiddenPageTitle && !isTitleDuplicatedInHero}
+ <h1
+ data-test-id="page-title"
+ class:large-title={prefersLargeTitle}
+ class:overlaid={prefersOverlayedPageHeader}
+ class:on-dark-background={isOnDarkBackground}
+ >
+ {title}
+ </h1>
+ {/if}
+
+ <slot name="before-shelves" />
+
+ {#each belowTitleShelves as shelf}
+ {#if !shelf.isHidden}
+ <ShelfComponent {shelf}>
+ <slot
+ name="marker-shelf"
+ slot="marker-shelf"
+ let:shelf
+ {shelf}
+ />
+ </ShelfComponent>
+ {/if}
+ {/each}
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .default-page-container {
+ flex-grow: 1;
+ width: 100%;
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ }
+
+ .default-page-container.with-overlaid-title {
+ margin-top: -13px;
+ }
+
+ .default-page-container.with-title-in-hero {
+ @media (--range-small-up) {
+ margin-top: 10px;
+ }
+ }
+
+ h1 {
+ padding: 11px var(--bodyGutter);
+ font: var(--large-title-emphasized);
+ letter-spacing: -0.5px;
+ word-wrap: break-word;
+ color: var(--systemPrimary, #000);
+ position: relative;
+ z-index: 1;
+ transition: color 210ms ease-in;
+ }
+
+ h1.large-title {
+ font: var(--large-title-emphasized-tall);
+ }
+
+ h1.overlaid {
+ position: absolute;
+ z-index: 3;
+ padding: var(--bodyGutter) var(--bodyGutter) 0;
+ color: var(--systemPrimary-onLight, #000);
+ }
+
+ h1.on-dark-background {
+ color: var(--systemPrimary-onDark);
+ }
+</style>
diff --git a/src/components/pages/ErrorPage.svelte b/src/components/pages/ErrorPage.svelte
new file mode 100644
index 0000000..5756d78
--- /dev/null
+++ b/src/components/pages/ErrorPage.svelte
@@ -0,0 +1,23 @@
+<script lang="ts" context="module">
+ import type { ErrorPage } from '~/jet/models';
+</script>
+
+<script lang="ts">
+ import SharedErrorPage from '@amp/web-app-components/src/components/Error/ErrorPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: ErrorPage;
+
+ const i18n = getI18n();
+</script>
+
+<div class="error-page-container">
+ <SharedErrorPage translateFn={$i18n.t} error={page.error} />
+</div>
+
+<style>
+ .error-page-container :global(.page-error) {
+ /* -50px compensates for the global footer */
+ top: calc(50% - 50px);
+ }
+</style>
diff --git a/src/components/pages/ProductPage.svelte b/src/components/pages/ProductPage.svelte
new file mode 100644
index 0000000..30b0ad8
--- /dev/null
+++ b/src/components/pages/ProductPage.svelte
@@ -0,0 +1,77 @@
+<script lang="ts">
+ import type { ShelfBasedProductPage } from '@jet-app/app-store/api/models';
+ import { isFlowAction } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import DefaultPage, {
+ type DefaultPageRequirements,
+ } from '~/components/pages/DefaultPage.svelte';
+ import MarkerShelf from '~/components/jet/shelf/MarkerShelf.svelte';
+ import ProductPageArcadeFooter from '~/components/ProductPageArcadeFooter.svelte';
+ import { getProductPageShelvesWithExpandedMedia } from '~/utils/shelves';
+ import { setAccessibilityLayoutContext } from '~/context/accessibility-layout';
+ import { getJet } from '~/jet';
+ import { isProductPageLinkShelf } from '~/components/jet/shelf/ProductPageLinkShelf.svelte';
+ import { isEulaPageIntent } from '@jet-app/app-store/api/intents/eula-page-intent';
+ export let page: ShelfBasedProductPage & WebRenderablePage;
+
+ const jet = getJet();
+
+ $: ({ presentationOptions, webNavigation } = page);
+
+ $: shelves = getProductPageShelvesWithExpandedMedia(page);
+
+ let defaultPageRequirements: DefaultPageRequirements;
+
+ $: defaultPageRequirements = {
+ shelves,
+ presentationOptions,
+ webNavigation,
+ };
+
+ // Set up accessibility layout context for neighbor shelf detection
+ $: {
+ setAccessibilityLayoutContext({ shelves });
+
+ /**
+ * We suppport "deep linking" to the product page with the License Agreement modal open by
+ * default, based on the presence of the `lic` query parameter. No other modals support
+ * opening via deep link, which is why there isn't a more robust solution for this use case.
+ * Instead, we are just firing off the click action from the license agreement shelf.
+ */
+ if (page.canonicalURL) {
+ const canonicalUrl = new URL(page.canonicalURL);
+ const hasLic = canonicalUrl.searchParams.has('lic');
+
+ if (hasLic && shelves) {
+ const eulaItem = shelves
+ .find(isProductPageLinkShelf)
+ ?.items.find(
+ ({ clickAction }) =>
+ isFlowAction(clickAction) &&
+ clickAction.destination &&
+ isEulaPageIntent(clickAction.destination),
+ );
+
+ if (eulaItem) {
+ jet.perform(eulaItem.clickAction);
+ }
+ }
+ }
+ }
+
+ // TODO: replace with `supportsArcade` from Jet
+ // rdar://143706610 (Support `supportsArcade` attribute)
+ $: supportsArcade =
+ page.lockup.offerDisplayProperties?.offerType === 'arcadeApp';
+</script>
+
+<DefaultPage page={defaultPageRequirements}>
+ <svelte:fragment slot="marker-shelf" let:shelf>
+ <MarkerShelf {shelf} {page} />
+ </svelte:fragment>
+</DefaultPage>
+
+{#if supportsArcade}
+ <ProductPageArcadeFooter />
+{/if}
diff --git a/src/components/pages/SearchLandingPage.svelte b/src/components/pages/SearchLandingPage.svelte
new file mode 100644
index 0000000..3594ece
--- /dev/null
+++ b/src/components/pages/SearchLandingPage.svelte
@@ -0,0 +1,33 @@
+<script lang="ts">
+ import type { SearchLandingPage } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+ import type { WebSearchFlowAction } from '@jet-app/app-store/common/search/web-search-action';
+ import { unwrapOptional as unwrap } from '@jet/environment/types/optional';
+
+ type SearchPage = SearchLandingPage;
+
+ import DefaultPage from './DefaultPage.svelte';
+ import ShelfWrapper from '~/components/Shelf/Wrapper.svelte';
+ import SearchInput from '~/components/navigation/SearchInput.svelte';
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: SearchPage;
+
+ const i18n = getI18n();
+
+ $: webNavigation = unwrap((page as WebRenderablePage).webNavigation);
+ $: searchAction = webNavigation.searchAction as WebSearchFlowAction;
+ $: hasShelves = !!page.shelves.filter(({ items }) => items?.length).length;
+
+ $: pageWithoutEmptyShelves = {
+ ...page,
+ shelves: hasShelves ? page.shelves : [],
+ title: $i18n.t('ASE.Web.AppStore.Meta.SearchLanding.Title'),
+ };
+</script>
+
+<DefaultPage page={pageWithoutEmptyShelves}>
+ <ShelfWrapper slot="before-shelves" centered>
+ <SearchInput {searchAction} big />
+ </ShelfWrapper>
+</DefaultPage>
diff --git a/src/components/pages/SearchResultsPage.svelte b/src/components/pages/SearchResultsPage.svelte
new file mode 100644
index 0000000..c17b644
--- /dev/null
+++ b/src/components/pages/SearchResultsPage.svelte
@@ -0,0 +1,113 @@
+<script lang="ts">
+ import type { SearchResultsPage } from '@jet-app/app-store/api/models';
+
+ import type { Size } from '@amp/web-app-components/src/types';
+ import { ShelfConfig } from '@amp/web-app-components/config/components/shelf';
+
+ import DefaultPage from './DefaultPage.svelte';
+ import { getI18n } from '~/stores/i18n';
+ import mediaQueries from '~/utils/media-queries';
+ import {
+ isSearchResultShelf,
+ isRenderableInSearchResultsShelf,
+ } from '~/components/jet/shelf/SearchResultShelf.svelte';
+ import { getPlatformFromPage } from '~/utils/seo/common';
+
+ export let page: SearchResultsPage;
+
+ const i18n = getI18n();
+
+ $: resultsShelf = page?.shelves?.find(isSearchResultShelf) ?? null;
+
+ $: renderableItems = (resultsShelf?.items ?? []).filter(
+ isRenderableInSearchResultsShelf,
+ );
+
+ $: columnConfig = ShelfConfig.get().GRID_VALUES.SearchResult;
+ $: numberOfColumns = columnConfig[$mediaQueries as Size] || 3;
+ $: numberOfRows = Math.ceil(renderableItems.length / numberOfColumns);
+ $: middleRow = Math.floor(numberOfRows / 2);
+ $: insertAt = middleRow * numberOfColumns;
+
+ /**
+ * This is unfortunate but only these three platforms support the transparency link.
+ * This link is enabled via the `transparencyLawEditorialItemId` bag key, but when defining
+ * bag keys, we do not have access to the platform being viewed, so we can't opt-out there.
+ * We could do this platform check in the Jet layer, but adding two forms of opting into this
+ * link felt cumbersome and unintuitive, so we can just do it here.
+ */
+ $: transparencyLink =
+ page.transparencyLink &&
+ ['iphone', 'ipad', 'mac'].includes(
+ getPlatformFromPage(page).toLowerCase(),
+ );
+
+ /**
+ * Here we are building constructing a new array of shelves _if_ there is a result shelf _and_
+ * a transparency link. This creates three shelves:
+ * 1) the search results before the transparency banner in the linkable text shelf
+ * 2) the transparency banner
+ * 3) the search results after the transparency banner
+ */
+ $: shelves = resultsShelf
+ ? transparencyLink && renderableItems.length
+ ? [
+ insertAt > 0 && {
+ ...resultsShelf,
+ items: renderableItems.slice(0, insertAt),
+ title: null,
+ isValid: () => true,
+ },
+ {
+ contentType: 'linkableText',
+ items: [page.transparencyLink],
+ },
+ {
+ ...resultsShelf,
+ items: renderableItems.slice(insertAt),
+ title: null,
+ isValid: () => true,
+ },
+ ]
+ : [{ ...resultsShelf, items: renderableItems, title: null }]
+ : [];
+</script>
+
+<DefaultPage
+ page={{
+ shelves,
+ title: renderableItems.length > 0 ? resultsShelf?.title : null,
+ }}
+>
+ <svelte:fragment slot="before-shelves">
+ {#if renderableItems.length === 0}
+ <div>
+ <h1>
+ {$i18n.t('ASE.Web.AppStore.Search.NoResults.FirstLine')}
+ </h1>
+ <p>
+ {$i18n.t('ASE.Web.AppStore.Search.NoResults.SecondLine', {
+ term: page.searchTermContext?.term,
+ })}
+ </p>
+ </div>
+ {/if}
+ </svelte:fragment>
+</DefaultPage>
+
+<style>
+ div {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-direction: column;
+ gap: 3px;
+ height: 70vh;
+ margin: var(--bodyGutter);
+ }
+
+ p {
+ font: var(--title-3);
+ color: var(--systemSecondary);
+ }
+</style>
diff --git a/src/components/pages/SeeAllPage.svelte b/src/components/pages/SeeAllPage.svelte
new file mode 100644
index 0000000..d401f32
--- /dev/null
+++ b/src/components/pages/SeeAllPage.svelte
@@ -0,0 +1,56 @@
+<script lang="ts">
+ import type { SeeAllPage } from '@jet-app/app-store/api/models';
+ import type { WebRenderablePage } from '@jet-app/app-store/api/models/web-renderable-page';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import { getProductPageShelvesForOrdering } from '~/utils/shelves';
+ import { setAccessibilityLayoutContext } from '~/context/accessibility-layout';
+ import { isSmallLockupShelf } from '~/components/jet/shelf/SmallLockupShelf.svelte';
+ import { isProductReviewShelf } from '~/components/jet/shelf/ProductReviewShelf.svelte';
+ import { isProductRatingsShelf } from '~/components/jet/shelf/ProductRatingsShelf.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+
+ export let page: SeeAllPage & WebRenderablePage;
+
+ $: shelves = getProductPageShelvesForOrdering(page, 'notPurchasedOrdering')
+ .filter((shelf) => {
+ const isShelfForReviewPage =
+ isProductReviewShelf(shelf) || isProductRatingsShelf(shelf);
+
+ return (
+ isSmallLockupShelf(shelf) ||
+ (isShelfForReviewPage && page.seeAllType === 'reviews')
+ );
+ })
+ .map((shelf) => {
+ shelf.isHorizontal = false;
+ shelf.seeAllAction = null;
+ return shelf;
+ });
+
+ $: {
+ setAccessibilityLayoutContext({ shelves });
+ }
+</script>
+
+<DefaultPage page={{ shelves, title: null }}>
+ <svelte:fragment slot="before-shelves">
+ <h1>
+ <LinkWrapper action={page.lockup.clickAction}>
+ {page.lockup.title}
+ </LinkWrapper>
+ </h1>
+ </svelte:fragment>
+</DefaultPage>
+
+<style>
+ h1 {
+ font: var(--title-1);
+ color: var(--keyColor);
+ margin: 13px var(--bodyGutter) 0;
+ }
+
+ h1 :global(a:hover) {
+ text-decoration: underline;
+ }
+</style>
diff --git a/src/components/pages/StaticMessagePage.svelte b/src/components/pages/StaticMessagePage.svelte
new file mode 100644
index 0000000..45c1a36
--- /dev/null
+++ b/src/components/pages/StaticMessagePage.svelte
@@ -0,0 +1,113 @@
+<script lang="ts" context="module">
+ import type { StaticMessagePage } from '~/jet/models';
+</script>
+
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+
+ export let page: StaticMessagePage;
+
+ const i18n = getI18n();
+</script>
+
+<div class="static-message-page-container">
+ <div class="static-message-text-wrapper">
+ {#if page.titleLocKey}
+ <h1>{$i18n.t(page.titleLocKey)}</h1>
+ {/if}
+
+ <section>
+ {#if page.contentType === 'win-back' || page.contentType === 'contingent-price'}
+ <p>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Subhead')}
+ </p>
+
+ <p>
+ <b>
+ {$i18n.t('ASE.Web.AppStore.WinBack.DirectionalTitle')}
+ </b>
+ </p>
+
+ <ul>
+ <li>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Update.iOS')}
+ </li>
+ <li>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Update.macOS')}
+ </li>
+ </ul>
+
+ <p>
+ {$i18n.t('ASE.Web.AppStore.WinBack.Body')}
+ </p>
+ {:else if page.contentType === 'carrier'}
+ <p class="carrier__instructions">
+ {$i18n.t('ASE.Web.AppStore.Carrier.Update.iOS')}
+ </p>
+ <p>
+ {$i18n.t('ASE.Web.AppStore.Carrier.Body')}
+ </p>
+ {:else if page.contentType === 'invoice'}
+ <p class="invoice__instructions">
+ {$i18n.t('ASE.Web.AppStore.Invoice.Body')}
+ </p>
+ {/if}
+ </section>
+ </div>
+</div>
+
+<style lang="scss">
+ @use 'ac-sasskit/modules/viewportcontent/core' as *;
+ @use 'amp/stylekit/core/viewports' as *;
+
+ .static-message-page-container {
+ display: flex;
+ flex-grow: 1;
+ width: 100%;
+ max-width: viewport-content-for(xlarge);
+ margin: 0 auto;
+ align-items: center;
+ }
+
+ @media (--range-sidebar-visible-up) {
+ .static-message-page-container {
+ height: 100%;
+ }
+ }
+
+ .static-message-text-wrapper {
+ display: inline-flex;
+ flex-direction: column;
+ align-items: flex-start;
+ width: auto;
+ margin: 0 auto;
+ }
+
+ .static-message-page-container h1 {
+ padding: 13px var(--bodyGutter) 0;
+ font: var(--header-emphasized);
+ color: var(--systemPrimary, #000);
+ position: relative;
+ z-index: 1;
+ margin-bottom: 16px;
+ }
+
+ .static-message-page-container section {
+ margin: 0 var(--bodyGutter);
+ font: var(--title-3);
+ }
+
+ .static-message-page-container li {
+ list-style-type: disc;
+ }
+
+ .static-message-page-container p,
+ .static-message-page-container ul {
+ margin-bottom: 16px;
+ text-wrap: pretty;
+ }
+
+ .static-message-page-container ul {
+ padding-inline-start: 1em;
+ }
+</style>
diff --git a/src/components/pages/TodayPage.svelte b/src/components/pages/TodayPage.svelte
new file mode 100644
index 0000000..3d38932
--- /dev/null
+++ b/src/components/pages/TodayPage.svelte
@@ -0,0 +1,22 @@
+<!--
+@component
+Page component for the "Today Page"
+
+This is required so that the correct layout of the cards within each `TodayCardShelf`
+can be computed at the page level, as the algorithm for stretching the correct cards
+in each shelf requires knowledge of the previously-rendered shelf
+-->
+<script lang="ts">
+ import type { TodayPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import { setTodayCardLayoutContext } from '~/context/today-card-layout';
+
+ export let page: TodayPage;
+
+ $: {
+ setTodayCardLayoutContext(page);
+ }
+</script>
+
+<DefaultPage {page} />
diff --git a/src/components/pages/TopChartsPage.svelte b/src/components/pages/TopChartsPage.svelte
new file mode 100644
index 0000000..4a3e7b7
--- /dev/null
+++ b/src/components/pages/TopChartsPage.svelte
@@ -0,0 +1,218 @@
+<script lang="ts">
+ import { getI18n } from '~/stores/i18n';
+ import { isSome } from '@jet/environment/types/optional';
+ import type { TopChartsPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from '~/components/pages/DefaultPage.svelte';
+ import Shelf from '~/components/Shelf/Wrapper.svelte';
+ import Artwork, { getNaturalProfile } from '~/components/Artwork.svelte';
+ import Menu from '~/components/Menu.svelte';
+ import LinkWrapper from '~/components/LinkWrapper.svelte';
+ import SFSymbol from '~/components/SFSymbol.svelte';
+
+ export let page: TopChartsPage;
+
+ const i18n = getI18n();
+
+ $: ({ categories, categoriesButtonTitle, segments, initialSegmentIndex } =
+ page);
+ $: segment = segments[initialSegmentIndex];
+</script>
+
+<DefaultPage page={{ shelves: segment.shelves, title: page.title }}>
+ <Shelf slot="before-shelves" centered>
+ <header>
+ <div class="dropdown-container">
+ {#if categoriesButtonTitle}
+ <Menu options={categories}>
+ <svelte:fragment slot="trigger">
+ <span class="menu-trigger-contents">
+ {categoriesButtonTitle}
+
+ <SFSymbol name="chevron.down" />
+ </span>
+ </svelte:fragment>
+
+ <svelte:fragment slot="option" let:option>
+ {@const { artwork, chartSelectAction, name } =
+ option}
+
+ <LinkWrapper action={chartSelectAction}>
+ <div
+ class="category-menu-item"
+ class:active={name ===
+ categoriesButtonTitle}
+ >
+ {#if isSome(artwork)}
+ <div class="artwork-container">
+ <Artwork
+ {artwork}
+ profile={getNaturalProfile(
+ artwork,
+ [24],
+ )}
+ />
+ </div>
+ {/if}
+
+ <span>{name}</span>
+ </div>
+ </LinkWrapper>
+ </svelte:fragment>
+ </Menu>
+ {/if}
+ </div>
+
+ <div class="segment-selector" aria-label={categoriesButtonTitle}>
+ {#each segments as segment, index}
+ {@const { segmentSelectAction } = segment}
+ {@const isSelected = initialSegmentIndex === index}
+ {@const filterLabel = $i18n.t(
+ isSelected
+ ? 'ASE.Web.AppStore.SelectedFilterApps.AX.Label'
+ : 'ASE.Web.AppStore.FilterApps.AX.Label',
+ { filterName: segment.shortName },
+ )}
+
+ <LinkWrapper
+ action={segmentSelectAction}
+ label={filterLabel}
+ >
+ <span class="segment" class:selected={isSelected}>
+ {segment.shortName}
+ </span>
+ </LinkWrapper>
+ {/each}
+ </div>
+ </header>
+ </Shelf>
+</DefaultPage>
+
+<style>
+ header {
+ --pill-button-border-radius: 1000px; /* Arbitrary large value for "pill-style" rounded sides */
+ --menu-item-padding: 0;
+ --menu-item-margin: 0 0 8px 0;
+ --menu-popover-padding: 12px 16px;
+ --menu-common-padding: 0;
+ --menu-trigger-border-radius: var(--pill-button-border-radius);
+ --menu-trigger-background-color: var(--systemPrimary-onDark);
+ --menu-trigger-padding: 6px 16px;
+ --menu-trigger-font: var(--body-semibold-tall);
+ --menu-popover-background-color: white;
+ --menu-popover-box-shadow: 10px 10px 10px 0
+ var(--systemQuaternary-onLight);
+ --menu-popover-border-radius: 14px;
+ --menu-popover-border: 1px solid var(--systemQuaternary);
+ --menu-popover-z-index: calc(var(--z-web-chrome) + 1);
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 20px;
+
+ @media (--range-small-up) {
+ display: grid;
+ align-items: center;
+ justify-items: start;
+ grid-template-columns: 1fr max-content 1fr;
+ }
+
+ @media (prefers-color-scheme: dark) {
+ --menu-trigger-background-color: var(--systemQuaternary);
+ --menu-popover-background-color: var(--systemQuaternary-vibrant);
+ }
+ }
+
+ .segment-selector {
+ display: flex;
+ justify-self: end;
+ gap: 4px;
+ padding: 2px;
+ background: var(--systemQuaternary);
+ border-radius: var(--pill-button-border-radius);
+
+ @media (--range-small-up) {
+ align-items: center;
+ justify-self: center;
+ grid-column: 2;
+ }
+ }
+
+ .segment-selector :global(a) {
+ display: contents;
+ }
+
+ .segment {
+ border-radius: var(--pill-button-border-radius);
+ font: var(--body-semibold-tall);
+ padding: 6px 16px;
+ }
+
+ .segment.selected {
+ background-color: var(--systemPrimary-onDark);
+ color: var(--systemPrimary);
+
+ @media (prefers-color-scheme: dark) {
+ background-color: var(--systemQuaternary);
+ }
+ }
+
+ .dropdown-container {
+ justify-self: start;
+
+ @media (--range-small-up) {
+ grid-column: 1;
+ }
+ }
+
+ .menu-trigger-contents {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ }
+
+ .menu-trigger-contents :global(svg) {
+ height: 0.7em;
+ }
+
+ .menu-trigger-contents :global(path:not([fill='none'])) {
+ fill: currentColor;
+ }
+
+ .category-menu-item {
+ display: flex;
+ align-items: center;
+ padding: 8px;
+ border-radius: 10px;
+ height: 40px;
+ transition: background 150ms ease-in;
+ }
+
+ .category-menu-item.active {
+ background: var(--systemQuinary);
+ }
+
+ .category-menu-item:not(.active):hover {
+ background: rgba(0, 0, 0, 0.035);
+ }
+
+ .artwork-container {
+ width: 24px;
+ margin-inline-end: 8px;
+ flex-shrink: 0;
+ }
+
+ .category-menu-item span {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ }
+
+ .dropdown-container :global(.menu-popover) {
+ max-width: 600px;
+ width: 100%;
+ column-count: 2;
+
+ @media (--range-medium-up) {
+ column-count: 3;
+ }
+ }
+</style>
diff --git a/src/components/pages/VisionProPage.svelte b/src/components/pages/VisionProPage.svelte
new file mode 100644
index 0000000..c87ee09
--- /dev/null
+++ b/src/components/pages/VisionProPage.svelte
@@ -0,0 +1,12 @@
+<script lang="ts">
+ import type { GenericPage } from '@jet-app/app-store/api/models';
+
+ import DefaultPage from './DefaultPage.svelte';
+ import VisionProFooter from '~/components/structure/VisionProFooter.svelte';
+
+ export let page: GenericPage;
+</script>
+
+<DefaultPage {page} />
+
+<VisionProFooter />