Files
sky_phone/frontend/src/views/apps/AppStoreApp.vue
T
2026-08-20 19:51:26 +02:00

2890 lines
74 KiB
Vue

<script setup lang="ts">
import {
Camera,
ChevronRight,
Crown,
Gamepad2,
Grid2X2,
Newspaper,
Plane,
Search,
ShieldCheck,
Sparkles,
Trophy,
Trash2,
} from 'lucide-vue-next'
import {
computed,
nextTick,
ref,
watch,
type ComponentPublicInstance,
} from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
DEFAULT_INSTALLED_PHONE_APP_IDS,
getPhoneAppLabel,
isExternalPhoneApp,
isLaunchablePhoneApp,
isPhoneAppRemovable,
PHONE_APPS,
} from '@/config/apps'
import { useAppStoreStore } from '@/stores/app-store'
import { useEasyShareStore } from '@/stores/easyshare'
import { usePhoneStore } from '@/stores/phone'
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
import {
SkyAppPage,
SkyDialog,
SkyDialogButton,
SkyEmptyState,
SkyNavbar,
SkyPillNavigation,
SkyScrollArea,
SkySearchbar,
SkySegmented,
SkySegmentedButton,
SkySheet,
} from '@/ui'
import { getDailyHighlights } from '@/utils/appStoreHighlights'
import AppStoreDetail from './AppStoreDetail.vue'
import AppStoreAction from './AppStoreAction.vue'
const phone = usePhoneStore()
const appStore = useAppStoreStore()
const easyShare = useEasyShareStore()
const route = useRoute()
const router = useRouter()
const tab = ref<'today' | 'apps' | 'games' | 'search'>('today')
const query = ref('')
const openedAt = new Date()
const featuredSlide = ref(0)
const profileOpened = ref(false)
const uninstallCandidate = ref<LaunchablePhoneAppDefinition | null>(null)
const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)
const storeScroll = ref<ComponentPublicInstance | null>(null)
const featuredScroller = ref<HTMLElement | null>(null)
const profileDragOffset = ref(0)
const profileDragging = ref(false)
let profileDragPointerId: number | null = null
let profileDragStartTime = 0
let profileDragStartY = 0
const tabs = [
{ id: 'today', icon: Newspaper },
{ id: 'apps', icon: Grid2X2 },
{ id: 'games', icon: Gamepad2 },
{ id: 'search', icon: Search },
] as const
const searchDiscoverTiles = [
{ id: 'topApps', icon: Trophy, background: '#5d9ee8' },
{ id: 'topGames', icon: Crown, background: '#ee9347' },
{ id: 'bestApps', icon: Sparkles, background: '#58c85a' },
{ id: 'bestGames', icon: Gamepad2, background: '#7b5ce9' },
{ id: 'productivity', icon: Plane, background: '#4e91cf' },
{ id: 'photoVideo', icon: Camera, background: '#c28b36' },
] as const
const highlightPalettes = [
{ background: '#244cc8', glow: '#7da4ff' },
{ background: '#d54b38', glow: '#ffbd6f' },
{ background: '#087c78', glow: '#4ee4cb' },
{ background: '#783daf', glow: '#dca4ff' },
{ background: '#ae6c13', glow: '#ffd37d' },
] as const
const activeTabIndex = computed(() =>
tabs.findIndex((item) => item.id === tab.value),
)
const profileName = computed(() => {
const name = [phone.player.firstName, phone.player.lastName]
.map((part) => part.trim())
.filter(Boolean)
.join(' ')
return name || phone.t('Apps.appStore.player')
})
const profileInitials = computed(() => {
const initials = `${phone.player.firstName.trim().charAt(0)}${phone.player.lastName
.trim()
.charAt(0)}`
return initials.toLocaleUpperCase(phone.lang) || 'P'
})
const downloadDateDescription = computed(() =>
phone.t('Apps.appStore.account.downloadedOn', {
date: new Intl.DateTimeFormat(phone.lang, {
day: 'numeric',
month: 'short',
year: 'numeric',
}).format(openedAt),
}),
)
const catalog = computed(() =>
PHONE_APPS.filter((app): app is LaunchablePhoneAppDefinition => {
if (!isLaunchablePhoneApp(app) || app.id === 'app-store' || app.adminOnly) {
return false
}
return !appStore.isInstalled(app.id)
}).sort((a, b) => a.gridOrder - b.gridOrder),
)
const installedApps = computed(() =>
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) && appStore.isInstalled(app.id),
).sort((a, b) => a.gridOrder - b.gridOrder),
)
const installedGameCount = computed(
() => installedApps.value.filter((app) => app.category === 'games').length,
)
const dailyCandidates = computed(() =>
getDailyHighlights(
PHONE_APPS.filter(
(app): app is LaunchablePhoneAppDefinition =>
isLaunchablePhoneApp(app) &&
!app.adminOnly &&
!isExternalPhoneApp(app) &&
app.id !== 'app-store' &&
!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id) &&
!appStore.isInstalled(app.id),
),
),
)
const dailyHighlights = computed(() => dailyCandidates.value.slice(0, 8))
const editorialHighlights = computed(() => dailyHighlights.value.slice(1, 3))
const topToday = computed(() => dailyHighlights.value.slice(3, 7))
const finalHighlight = computed(() => dailyHighlights.value[7])
const featuredCandidates = computed(() => {
return dailyCandidates.value
.filter((app) =>
tab.value === 'games'
? app.category === 'games'
: app.category !== 'games',
)
.slice(0, 6)
})
const hasSearchQuery = computed(() => Boolean(query.value.trim()))
const searchRecommendations = computed(() => dailyCandidates.value.slice(0, 3))
const searchDiscoverCards = computed(() => {
if (!dailyCandidates.value.length) return []
return searchDiscoverTiles.map((tile, index) => ({
...tile,
app: dailyCandidates.value[(index + 3) % dailyCandidates.value.length]!,
}))
})
const shownApps = computed(() => {
if (tab.value === 'today') return []
if (tab.value === 'games') {
return catalog.value.filter((app) => app.category === 'games')
}
if (tab.value === 'apps') {
return catalog.value.filter((app) => app.category !== 'games')
}
const search = query.value.trim().toLocaleLowerCase(phone.lang)
if (!search) return []
return catalog.value.filter((app) => {
const searchable = [
getPhoneAppLabel(app, phone.t),
phone.t(`Home.groups.${app.category}`),
appStoreTagline(app),
]
.join(' ')
.toLocaleLowerCase(phone.lang)
return searchable.includes(search)
})
})
watch(tab, async () => {
featuredSlide.value = 0
await nextTick()
featuredScroller.value?.scrollTo({ left: 0 })
})
function updateFeaturedSlide(): void {
const scroller = featuredScroller.value
if (!scroller) return
const cards = Array.from(
scroller.querySelectorAll<HTMLElement>('.store-browse-feature'),
)
let closestIndex = 0
let closestDistance = Number.POSITIVE_INFINITY
cards.forEach((card, index) => {
const distance = Math.abs(card.offsetLeft - scroller.scrollLeft)
if (distance < closestDistance) {
closestIndex = index
closestDistance = distance
}
})
featuredSlide.value = closestIndex
}
function scrollToFeatured(index: number): void {
const scroller = featuredScroller.value
const card = scroller?.querySelectorAll<HTMLElement>(
'.store-browse-feature',
)[index]
if (!scroller || !card) return
scroller.scrollTo({ left: card.offsetLeft, behavior: 'smooth' })
}
function handleFeaturedWheel(event: WheelEvent): void {
const scroller = event.currentTarget as HTMLElement
const delta =
Math.abs(event.deltaX) > Math.abs(event.deltaY)
? event.deltaX
: event.deltaY
const maxScrollLeft = scroller.scrollWidth - scroller.clientWidth
if (
!delta ||
(delta < 0 && scroller.scrollLeft <= 0) ||
(delta > 0 && scroller.scrollLeft >= maxScrollLeft - 1)
) {
return
}
event.preventDefault()
scroller.scrollBy({ left: delta })
}
function appAction(
app: LaunchablePhoneAppDefinition,
): 'get' | 'installing' | 'open' {
if (appStore.installingApps[app.id]) return 'installing'
const installed = appStore.isInstalled(app.id)
if (installed && !appStore.homeLayout.hidden.includes(app.id)) {
return 'open'
}
return 'get'
}
function handleApp(app: LaunchablePhoneAppDefinition): void {
if (appAction(app) === 'open') {
void router.push(app.route)
return
}
appStore.installApp(app.id)
}
function handleManagedApp(app: LaunchablePhoneAppDefinition): void {
closeProfile()
void router.push(app.route)
}
function closeProfile(): void {
profileOpened.value = false
profileDragging.value = false
profileDragPointerId = null
profileDragOffset.value = 0
}
function beginProfileDrag(event: PointerEvent): void {
if (!profileOpened.value || event.button !== 0) return
profileDragPointerId = event.pointerId
profileDragStartTime = performance.now()
profileDragStartY = event.clientY
profileDragOffset.value = 0
profileDragging.value = true
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
}
function moveProfileDrag(event: PointerEvent): void {
if (!profileDragging.value || event.pointerId !== profileDragPointerId) return
profileDragOffset.value = Math.max(0, event.clientY - profileDragStartY)
}
function endProfileDrag(event: PointerEvent): void {
if (!profileDragging.value || event.pointerId !== profileDragPointerId) return
const elapsed = Math.max(1, performance.now() - profileDragStartTime)
const shouldClose =
profileDragOffset.value >= 72 ||
(profileDragOffset.value >= 24 && profileDragOffset.value / elapsed >= 0.55)
;(event.currentTarget as HTMLElement).releasePointerCapture(event.pointerId)
profileDragging.value = false
profileDragPointerId = null
if (shouldClose) closeProfile()
profileDragOffset.value = 0
}
function confirmUninstall(): void {
if (!uninstallCandidate.value) return
appStore.uninstallApp(uninstallCandidate.value.id)
uninstallCandidate.value = null
}
function highlightStyle(index: number): Record<string, string> {
const palette = highlightPalettes[index % highlightPalettes.length]
return {
'--store-highlight-background': palette.background,
'--store-highlight-glow': palette.glow,
}
}
function highlightTitle(app: LaunchablePhoneAppDefinition): string {
const appName = getPhoneAppLabel(app, phone.t)
return phone.t(
app.category === 'games'
? 'Apps.appStore.today.playTitle'
: 'Apps.appStore.today.discoverTitle',
{ app: appName },
)
}
function categoryDescription(app: LaunchablePhoneAppDefinition): string {
return phone.t('Apps.appStore.today.categoryDescription', {
category: phone.t(`Home.groups.${app.category}`),
})
}
function appStoreTagline(app: LaunchablePhoneAppDefinition): string {
if (isExternalPhoneApp(app)) {
return app.description.trim() || app.developer
}
return phone.t(`Apps.appStore.taglines.${app.id}`)
}
function selectSearchDiscovery(app: LaunchablePhoneAppDefinition): void {
query.value = getPhoneAppLabel(app, phone.t)
}
function openAppDetail(app: LaunchablePhoneAppDefinition): void {
selectedApp.value = app
void nextTick(() => {
const scrollElement = storeScroll.value?.$el as HTMLElement | undefined
scrollElement?.scrollTo({ top: 0 })
})
}
function selectStoreTab(nextTab: (typeof tabs)[number]['id']): void {
selectedApp.value = null
tab.value = nextTab
}
function shareSelectedApp(): void {
const app = selectedApp.value
if (!app) return
const appName = getPhoneAppLabel(app, phone.t)
easyShare.open({
appId: 'app-store',
copyText: phone.t('Apps.appStore.details.shareCopy', { app: appName }),
id: app.id,
imageUrl: app.iconImage,
kind: 'link',
link: `skyphone://app-store/${app.id}`,
meta: { appId: app.id },
subtitle: phone.t(`Home.groups.${app.category}`),
title: appName,
})
}
watch(
() => route.query.easyShareId,
(sharedAppId) => {
if (
route.query.easyShareKind !== 'link' ||
typeof sharedAppId !== 'string'
) {
return
}
const app = PHONE_APPS.find(
(candidate) =>
candidate.id === sharedAppId && isLaunchablePhoneApp(candidate),
)
if (app && isLaunchablePhoneApp(app)) openAppDetail(app)
},
{ immediate: true },
)
</script>
<template>
<SkyAppPage
class="app-store-page"
accent="#0a84ff"
accent-soft="rgba(10, 132, 255, 0.16)"
:dark="phone.isDarkMode"
:label="phone.t('Apps.appStore.name')"
>
<SkyNavbar
v-if="!selectedApp"
class="app-store-navbar"
:class="{ 'app-store-navbar--search': tab === 'search' }"
:scroll-el="null"
transparent
:title="phone.t(`Apps.appStore.tabs.${tab}`)"
variant="large"
>
<template #right>
<button
type="button"
class="app-store-profile"
:aria-label="
phone.t('Apps.appStore.profileLabel', { name: profileName })
"
@click="profileOpened = true"
>
{{ profileInitials }}
</button>
</template>
<template v-if="tab === 'search'" #subnavbar>
<SkySearchbar
v-model="query"
:clear-label="phone.t('Common.clear')"
:label="phone.t('Apps.appStore.searchPlaceholder')"
:placeholder="phone.t('Apps.appStore.searchPlaceholder')"
/>
</template>
</SkyNavbar>
<SkyScrollArea
ref="storeScroll"
padded
class="store-scroll"
:class="{ 'store-scroll--detail': selectedApp }"
with-tabbar
>
<AppStoreDetail
v-if="selectedApp"
:app="selectedApp"
:action="appAction(selectedApp)"
@action="handleApp(selectedApp)"
@back="selectedApp = null"
@share="shareSelectedApp"
/>
<section v-else-if="tab === 'today'" class="store-today">
<article
v-if="dailyHighlights[0]"
class="store-highlight store-highlight--hero phone-effect--expensive-shadow"
:style="highlightStyle(0)"
>
<button
type="button"
class="store-highlight__detail-link"
:aria-label="
phone.t('Apps.appStore.details.openDetails', {
app: getPhoneAppLabel(dailyHighlights[0], phone.t),
})
"
@click="openAppDetail(dailyHighlights[0])"
></button>
<div
class="store-highlight__texture phone-effect--decorative"
aria-hidden="true"
></div>
<div class="store-highlight__copy">
<p>
<Sparkles :size="12" :stroke-width="2.5" aria-hidden="true" />
{{ phone.t('Apps.appStore.today.featured') }}
</p>
<h2>{{ highlightTitle(dailyHighlights[0]) }}</h2>
<span>
{{
phone.t('Apps.appStore.today.description', {
app: getPhoneAppLabel(dailyHighlights[0], phone.t),
})
}}
</span>
</div>
<div class="store-highlight__art" aria-hidden="true">
<span class="store-highlight__orbit"></span>
<span
class="store-highlight__spark store-highlight__spark--one"
></span>
<span
class="store-highlight__spark store-highlight__spark--two"
></span>
<img
class="phone-effect--filtered-media"
:src="dailyHighlights[0].iconImage"
alt=""
draggable="false"
/>
</div>
<footer class="store-highlight__footer">
<img :src="dailyHighlights[0].iconImage" alt="" draggable="false" />
<div>
<strong>{{
getPhoneAppLabel(dailyHighlights[0], phone.t)
}}</strong>
<small>
{{ categoryDescription(dailyHighlights[0]) }}
</small>
</div>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon':
appAction(dailyHighlights[0]) === 'installing',
'store-action-button--get':
appAction(dailyHighlights[0]) === 'get',
}"
:disabled="appStore.installingApps[dailyHighlights[0].id]"
:aria-label="`${getPhoneAppLabel(dailyHighlights[0], phone.t)} ${phone.t(
`Apps.appStore.${appAction(dailyHighlights[0])}`,
)}`"
@click.stop="handleApp(dailyHighlights[0])"
>
<AppStoreAction :action="appAction(dailyHighlights[0])" />
</button>
</footer>
</article>
<header class="store-today__section-heading">
<div>
<span>{{ phone.t('Apps.appStore.today.curatedForYou') }}</span>
<h2>{{ phone.t('Apps.appStore.today.moreHighlights') }}</h2>
</div>
<Sparkles :size="20" :stroke-width="2" aria-hidden="true" />
</header>
<article
v-for="(app, index) in editorialHighlights"
:key="app.id"
class="store-highlight store-highlight--compact phone-effect--expensive-shadow"
:class="{ 'store-highlight--reverse': index % 2 === 1 }"
:style="highlightStyle(index + 1)"
>
<button
type="button"
class="store-highlight__detail-link"
:aria-label="
phone.t('Apps.appStore.details.openDetails', {
app: getPhoneAppLabel(app, phone.t),
})
"
@click="openAppDetail(app)"
></button>
<div
class="store-highlight__texture phone-effect--decorative"
aria-hidden="true"
></div>
<div class="store-highlight__copy">
<p>
{{
phone.t(
app.category === 'games'
? 'Apps.appStore.today.gameHighlight'
: 'Apps.appStore.today.appHighlight',
)
}}
</p>
<h2>{{ highlightTitle(app) }}</h2>
<span>{{ categoryDescription(app) }}</span>
</div>
<div class="store-highlight__art" aria-hidden="true">
<span class="store-highlight__orbit"></span>
<img
class="phone-effect--filtered-media"
:src="app.iconImage"
alt=""
draggable="false"
/>
</div>
<footer class="store-highlight__footer">
<img :src="app.iconImage" alt="" draggable="false" />
<div>
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small>{{ phone.t('Apps.appStore.today.editorsChoice') }}</small>
</div>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) === 'installing',
'store-action-button--get': appAction(app) === 'get',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
)}`"
@click.stop="handleApp(app)"
>
<AppStoreAction :action="appAction(app)" />
</button>
</footer>
</article>
<section class="store-ranking phone-effect--expensive-shadow">
<header class="store-ranking__header">
<span class="store-ranking__icon">
<Trophy :size="18" :stroke-width="2.2" aria-hidden="true" />
</span>
<div>
<h2>{{ phone.t('Apps.appStore.today.topToday') }}</h2>
<p>{{ phone.t('Apps.appStore.today.topTodayDescription') }}</p>
</div>
</header>
<ol>
<li v-for="app in topToday" :key="app.id">
<button
type="button"
class="store-ranking__detail-link"
:aria-label="
phone.t('Apps.appStore.details.openDetails', {
app: getPhoneAppLabel(app, phone.t),
})
"
@click="openAppDetail(app)"
>
<img :src="app.iconImage" alt="" draggable="false" />
<span class="store-ranking__details">
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small>{{ categoryDescription(app) }}</small>
</span>
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) === 'installing',
'store-action-button--get': appAction(app) === 'get',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
)}`"
@click.stop="handleApp(app)"
>
<AppStoreAction :action="appAction(app)" />
</button>
</li>
</ol>
</section>
<article
v-if="finalHighlight"
class="store-final-pick phone-effect--expensive-shadow"
:style="highlightStyle(4)"
>
<button
type="button"
class="store-highlight__detail-link"
:aria-label="
phone.t('Apps.appStore.details.openDetails', {
app: getPhoneAppLabel(finalHighlight, phone.t),
})
"
@click="openAppDetail(finalHighlight)"
></button>
<div
class="store-final-pick__glow phone-effect--decorative"
aria-hidden="true"
></div>
<div class="store-final-pick__heading">
<span>{{ phone.t('Apps.appStore.today.oneMoreThing') }}</span>
<strong>{{ highlightTitle(finalHighlight) }}</strong>
<small>{{ categoryDescription(finalHighlight) }}</small>
</div>
<img
class="phone-effect--filtered-media"
:src="finalHighlight.iconImage"
alt=""
draggable="false"
/>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon':
appAction(finalHighlight) === 'installing',
'store-action-button--get': appAction(finalHighlight) === 'get',
}"
:disabled="appStore.installingApps[finalHighlight.id]"
:aria-label="`${getPhoneAppLabel(finalHighlight, phone.t)} ${phone.t(
`Apps.appStore.${appAction(finalHighlight)}`,
)}`"
@click.stop="handleApp(finalHighlight)"
>
<AppStoreAction :action="appAction(finalHighlight)" />
</button>
</article>
</section>
<section
v-else-if="tab === 'apps' || tab === 'games'"
class="store-browse"
>
<div
ref="featuredScroller"
class="store-browse__featured-scroll"
@scroll.passive="updateFeaturedSlide"
@wheel="handleFeaturedWheel"
>
<article
v-for="(app, index) in featuredCandidates"
:key="app.id"
class="store-browse-feature phone-effect--expensive-shadow"
:class="{ 'is-active': featuredSlide === index }"
:style="highlightStyle(index + 1)"
>
<div
class="store-browse-feature__texture phone-effect--decorative"
aria-hidden="true"
></div>
<div class="store-browse-feature__copy">
<span>{{ phone.t('Apps.appStore.browse.availableNow') }}</span>
<h2>
{{
phone.t('Apps.appStore.browse.featureTitle', {
app: getPhoneAppLabel(app, phone.t),
})
}}
</h2>
<p>{{ categoryDescription(app) }}</p>
</div>
<div class="store-browse-feature__art" aria-hidden="true">
<span></span>
<component
:is="app.icon"
:size="84"
:stroke-width="1.1"
/>
<img
class="phone-effect--filtered-media"
:src="app.iconImage"
alt=""
draggable="false"
/>
</div>
<footer>
<img :src="app.iconImage" alt="" draggable="false" />
<button
type="button"
class="store-browse-feature__details"
@click="openAppDetail(app)"
>
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small>{{ appStoreTagline(app) }}</small>
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon':
appAction(app) === 'installing',
'store-action-button--get': appAction(app) === 'get',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
)}`"
@click="handleApp(app)"
>
<AppStoreAction :action="appAction(app)" />
</button>
</footer>
</article>
</div>
<div
v-if="featuredCandidates.length > 1"
class="store-browse__pages"
:aria-label="phone.t('Apps.appStore.browse.featuredPages')"
>
<button
v-for="(app, index) in featuredCandidates"
:key="app.id"
type="button"
:class="{
'is-active': featuredSlide === index,
}"
:aria-label="getPhoneAppLabel(app, phone.t)"
@click="scrollToFeatured(index)"
></button>
</div>
<header class="store-browse__list-heading">
<div>
<span>{{ phone.t('Apps.appStore.browse.handPicked') }}</span>
<h2>
{{
phone.t(
tab === 'games'
? 'Apps.appStore.browse.essentialGames'
: 'Apps.appStore.browse.essentialApps',
)
}}
</h2>
</div>
<strong>{{ shownApps.length }}</strong>
</header>
<section class="store-list store-list--browse">
<article v-for="app in shownApps" :key="app.id">
<button
type="button"
class="store-list__detail-link"
@click="openAppDetail(app)"
>
<img
class="store-icon"
:src="app.iconImage"
alt=""
draggable="false"
/>
<span>
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small class="store-list__tagline">{{ appStoreTagline(app) }}</small>
</span>
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) === 'installing',
'store-action-button--get': appAction(app) === 'get',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
)}`"
@click="handleApp(app)"
>
<AppStoreAction :action="appAction(app)" />
</button>
</article>
<p v-if="shownApps.length === 0" class="store-empty">
{{ phone.t('Home.noApps') }}
</p>
</section>
</section>
<section v-else class="store-search">
<template v-if="!hasSearchQuery">
<section class="store-search__section">
<header class="store-search__heading">
<h2>{{ phone.t('Apps.appStore.search.recommended') }}</h2>
<ChevronRight :size="22" :stroke-width="2.5" aria-hidden="true" />
</header>
<div class="store-search__recommendations">
<article
v-for="(app, index) in searchRecommendations"
:key="app.id"
:class="{
'store-search__recommendation--promoted': index === 0,
}"
>
<button
type="button"
class="store-search__detail-link"
@click="openAppDetail(app)"
>
<img :src="app.iconImage" alt="" draggable="false" />
<span>
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small>{{ categoryDescription(app) }}</small>
<em v-if="index === 0">{{
phone.t('Apps.appStore.search.ad')
}}</em>
</span>
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) === 'installing',
'store-action-button--get': appAction(app) === 'get',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
)}`"
@click="handleApp(app)"
>
<AppStoreAction :action="appAction(app)" />
</button>
</article>
</div>
</section>
<section class="store-search__section store-search__discover">
<header class="store-search__heading">
<h2>{{ phone.t('Apps.appStore.search.discover') }}</h2>
<ChevronRight :size="22" :stroke-width="2.5" aria-hidden="true" />
</header>
<div class="store-search__discover-grid">
<button
v-for="card in searchDiscoverCards"
:key="card.id"
type="button"
class="phone-effect--solid-surface"
:style="{ '--store-search-tile': card.background }"
@click="selectSearchDiscovery(card.app)"
>
<component
:is="card.icon"
:size="40"
:stroke-width="1.4"
aria-hidden="true"
/>
<span>{{ phone.t(`Apps.appStore.search.${card.id}`) }}</span>
<small>{{ getPhoneAppLabel(card.app, phone.t) }}</small>
</button>
</div>
</section>
</template>
<section v-else class="store-search__results">
<header class="store-search__heading">
<div>
<h2>{{ phone.t('Apps.appStore.search.results') }}</h2>
<span>{{ shownApps.length }}</span>
</div>
</header>
<div class="store-list store-list--browse store-list--search-results">
<article v-for="app in shownApps" :key="app.id">
<button
type="button"
class="store-list__detail-link"
@click="openAppDetail(app)"
>
<img
class="store-icon"
:src="app.iconImage"
alt=""
draggable="false"
/>
<span>
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small class="store-list__tagline">{{ appStoreTagline(app) }}</small>
</span>
</button>
<button
type="button"
class="store-action-button"
:class="{
'store-action-button--icon': appAction(app) === 'installing',
'store-action-button--get': appAction(app) === 'get',
}"
:disabled="appStore.installingApps[app.id]"
:aria-label="`${getPhoneAppLabel(app, phone.t)} ${phone.t(
`Apps.appStore.${appAction(app)}`,
)}`"
@click="handleApp(app)"
>
<AppStoreAction :action="appAction(app)" />
</button>
</article>
</div>
<SkyEmptyState
v-if="shownApps.length === 0"
compact
class="store-search__empty"
:title="phone.t('Apps.appStore.search.noResults')"
:body="phone.t('Apps.appStore.search.noResultsBody')"
>
<template #icon>
<Search :size="38" :stroke-width="1.8" aria-hidden="true" />
</template>
</SkyEmptyState>
</section>
</section>
</SkyScrollArea>
<SkyPillNavigation
class="app-store-navigation"
layout="full"
:label="phone.t('Apps.appStore.name')"
>
<SkySegmented
strong
rounded
navigation
:active-index="activeTabIndex"
:aria-label="phone.t('Apps.appStore.name')"
:data-active-tab="tab"
:item-count="tabs.length"
>
<SkySegmentedButton
v-for="item in tabs"
:key="item.id"
:active="tab === item.id"
:aria-label="phone.t(`Apps.appStore.tabs.${item.id}`)"
@click="selectStoreTab(item.id)"
>
<span class="app-store-navigation__item">
<component
:is="item.icon"
:size="20"
:stroke-width="2"
aria-hidden="true"
/>
<span>{{ phone.t(`Apps.appStore.tabs.${item.id}`) }}</span>
</span>
</SkySegmentedButton>
</SkySegmented>
</SkyPillNavigation>
<SkySheet
:opened="profileOpened"
class="app-store-account-sheet"
:class="{ 'app-store-account-sheet--dragging': profileDragging }"
:style="{
'--store-account-drag-offset': `${profileDragOffset}px`,
}"
:aria-label="phone.t('Apps.appStore.account.title')"
@backdropclick="closeProfile"
@escape="closeProfile"
>
<section class="store-account">
<button
type="button"
class="store-account__grabber"
:aria-label="phone.t('Common.close')"
@click="closeProfile"
@pointercancel="endProfileDrag"
@pointerdown="beginProfileDrag"
@pointermove="moveProfileDrag"
@pointerup="endProfileDrag"
>
<span aria-hidden="true"></span>
</button>
<header class="store-account__toolbar">
<div>
<span>{{ phone.t('Apps.appStore.account.account') }}</span>
<h2>{{ phone.t('Apps.appStore.account.title') }}</h2>
</div>
</header>
<section class="store-account__identity phone-effect--expensive-shadow">
<span>{{ profileInitials }}</span>
<div>
<strong>{{ profileName }}</strong>
<small>{{ phone.t('Apps.appStore.account.skyAccount') }}</small>
</div>
<ShieldCheck :size="24" :stroke-width="1.8" aria-hidden="true" />
</section>
<dl class="store-account__summary">
<div>
<dt>{{ installedApps.length }}</dt>
<dd>{{ phone.t('Apps.appStore.account.apps') }}</dd>
</div>
<div>
<dt>{{ installedGameCount }}</dt>
<dd>{{ phone.t('Apps.appStore.account.games') }}</dd>
</div>
</dl>
<header class="store-account__section-heading">
<div>
<span>{{ phone.t('Apps.appStore.account.library') }}</span>
<h3>{{ phone.t('Apps.appStore.account.myApps') }}</h3>
</div>
<strong>{{ installedApps.length }}</strong>
</header>
<div class="store-account__apps phone-effect--expensive-shadow">
<article v-for="app in installedApps" :key="app.id">
<img :src="app.iconImage" alt="" draggable="false" />
<div>
<strong>{{ getPhoneAppLabel(app, phone.t) }}</strong>
<small>{{ downloadDateDescription }}</small>
</div>
<button
type="button"
class="store-account__primary-action"
@click="handleManagedApp(app)"
>
{{ phone.t('Apps.appStore.open') }}
</button>
<button
v-if="isPhoneAppRemovable(app)"
type="button"
class="store-account__remove"
:aria-label="
phone.t('Apps.appStore.account.uninstallApp', {
app: getPhoneAppLabel(app, phone.t),
})
"
@click="uninstallCandidate = app"
>
<Trash2 :size="17" :stroke-width="2" aria-hidden="true" />
</button>
</article>
</div>
</section>
</SkySheet>
<SkyDialog
:opened="Boolean(uninstallCandidate)"
role="alertdialog"
@backdropclick="uninstallCandidate = null"
@escape="uninstallCandidate = null"
>
<template #title>
{{ phone.t('Apps.appStore.account.uninstallTitle') }}
</template>
<p v-if="uninstallCandidate">
{{
phone.t('Apps.appStore.account.uninstallBody', {
app: getPhoneAppLabel(uninstallCandidate, phone.t),
})
}}
</p>
<template #buttons>
<SkyDialogButton @click="uninstallCandidate = null">
{{ phone.t('Common.cancel') }}
</SkyDialogButton>
<SkyDialogButton strong @click="confirmUninstall">
{{ phone.t('Apps.appStore.account.uninstall') }}
</SkyDialogButton>
</template>
</SkyDialog>
</SkyAppPage>
</template>
<style scoped>
.app-store-page {
padding: 0;
background: var(--sky-bg);
color: var(--sky-text);
}
.app-store-page :deep(button:not(:disabled)) {
transition:
transform var(--sky-transition-fast) var(--sky-ease-out),
filter var(--sky-transition-fast) var(--sky-ease-out),
background-color var(--sky-transition-fast) var(--sky-ease-out),
color var(--sky-transition-fast) var(--sky-ease-out);
}
.app-store-navbar {
min-height: calc(
var(--sky-safe-area-top) + var(--sky-navbar-height) +
var(--sky-navbar-large-title-height) - 30px
);
}
.app-store-navbar :deep(.sky-navbar__right) {
z-index: 2;
transform: translateY(calc(var(--sky-navbar-height) - 30px));
}
.app-store-navbar :deep(.sky-navbar__inner) {
margin-bottom: 0;
}
.app-store-navbar :deep(.sky-navbar__title-container) {
padding-right: calc(
var(--sky-touch-target) + var(--sky-page-gutter) +
var(--sky-safe-area-right)
);
transform: translateY(calc(0px - var(--sky-navbar-collapse-offset) - 30px));
}
.app-store-navbar--search {
margin-bottom: -24px;
}
.app-store-navbar--search :deep(.sky-navbar__subnavbar) {
transform: translateY(
calc(0px - var(--sky-navbar-collapse-offset) - 24px)
);
}
.app-store-profile {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
padding: 0;
border-radius: 50%;
color: var(--sky-text);
background: var(--sky-glass-solid);
box-shadow: var(
--sky-shadow-glass,
inset 0 0 0 1px var(--sky-hairline),
inset 0 0 5px 1px rgba(255, 255, 255, 0.35),
0 4px 14px rgba(0, 0, 0, 0.16)
);
font-size: 14px;
font-weight: 700;
letter-spacing: 0.02em;
}
.app-store-profile:focus-visible {
outline: 2px solid var(--sky-app-accent);
outline-offset: 2px;
}
.app-store-account-sheet :deep(.sky-sheet__panel) {
max-height: 92%;
}
.app-store-account-sheet--dragging :deep(.sky-sheet__panel) {
transform: translateY(var(--store-account-drag-offset, 0px));
transition-duration: 0ms;
}
.store-account {
display: grid;
gap: var(--sky-space-4);
padding: var(--sky-space-4) var(--sky-page-gutter)
calc(var(--sky-safe-area-bottom) + var(--sky-space-5));
}
.store-account__grabber {
width: 100%;
height: 28px;
display: grid;
place-items: center;
margin: calc(0px - var(--sky-space-3)) 0 calc(0px - var(--sky-space-2));
border: 0;
padding: 0;
background: transparent;
cursor: grab;
touch-action: none;
}
.store-account__grabber:active {
cursor: grabbing;
}
.store-account__grabber:focus-visible {
outline: none;
}
.store-account__grabber span {
width: 36px;
height: 5px;
border-radius: var(--sky-radius-pill);
background: var(--sky-subtle);
}
.store-account__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sky-space-3);
}
.store-account__toolbar > div {
min-width: 0;
}
.store-account__toolbar span,
.store-account__section-heading span {
color: var(--sky-app-accent);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.store-account__toolbar h2,
.store-account__section-heading h3 {
margin: 2px 0 0;
color: var(--sky-text);
line-height: 1.08;
}
.store-account__toolbar h2 {
font-size: var(--sky-font-medium-title);
}
.store-account__identity {
min-height: 82px;
display: flex;
align-items: center;
gap: var(--sky-space-3);
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
padding: var(--sky-space-4);
background: var(--sky-surface-muted);
}
.store-account__identity > span {
width: 54px;
height: 54px;
display: grid;
place-items: center;
flex: 0 0 54px;
border-radius: 50%;
color: #fff;
background: linear-gradient(145deg, #8eacf0, #586db8);
font-size: 17px;
font-weight: 800;
}
.store-account__identity > div {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
}
.store-account__identity strong,
.store-account__identity small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-account__identity small {
margin-top: 3px;
color: var(--sky-muted);
font-size: 11px;
}
.store-account__identity > svg {
flex: 0 0 auto;
color: var(--sky-app-accent);
}
.store-account__summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-3);
margin: 0;
}
.store-account__summary > div {
min-height: 70px;
display: grid;
place-items: center;
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
padding: var(--sky-space-3);
background: var(--sky-surface-muted);
text-align: center;
}
.store-account__summary dt {
color: var(--sky-text);
font-size: 22px;
font-weight: 800;
}
.store-account__summary dd {
margin: -4px 0 0;
color: var(--sky-muted);
font-size: 11px;
font-weight: 700;
}
.store-account__section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--sky-space-3);
}
.store-account__section-heading h3 {
font-size: 20px;
}
.store-account__section-heading > strong {
min-width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--sky-radius-pill);
color: var(--sky-muted);
background: var(--sky-surface-variant);
font-size: 11px;
}
.store-account__apps {
overflow: hidden;
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
}
.store-account__apps article {
min-height: 76px;
display: flex;
align-items: center;
gap: var(--sky-space-3);
border-bottom: 1px solid var(--sky-hairline);
padding: var(--sky-space-3);
}
.store-account__apps article:last-child {
border-bottom: 0;
}
.store-account__apps article > img {
width: 50px;
height: 50px;
flex: 0 0 50px;
border-radius: calc(var(--sky-radius-control) + var(--sky-space-1));
object-fit: cover;
}
.store-account__apps article > div {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
}
.store-account__apps article > div strong,
.store-account__apps article > div small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-account__apps article > div strong {
font-size: 13px;
}
.store-account__apps article > div small {
color: var(--sky-muted);
font-size: 9px;
}
.store-account__primary-action {
min-width: 60px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
flex: 0 0 auto;
border: 0;
border-radius: var(--sky-radius-pill);
padding: 0 10px;
color: var(--sky-app-accent);
background: var(--sky-surface-muted);
font-size: 11px;
font-weight: 850;
}
.store-account__remove {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
place-items: center;
flex: 0 0 var(--sky-touch-target);
border: 0;
border-radius: 50%;
color: var(--sky-danger);
background: var(--sky-danger-soft);
}
.store-scroll {
min-height: 0;
flex: 1 1 auto;
padding-top: 0;
padding-right: calc(var(--sky-page-gutter) + var(--sky-safe-area-right));
padding-left: calc(var(--sky-page-gutter) + var(--sky-safe-area-left));
overflow-y: auto;
}
.store-scroll--detail {
margin-top: var(--sky-safe-area-top);
}
.store-today {
display: grid;
gap: var(--sky-space-5);
padding-bottom: var(--sky-space-6);
}
.store-today__date {
margin: 0;
color: var(--sky-muted);
font-size: var(--sky-font-caption);
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.store-today__heading {
margin: var(--sky-space-2) 0 0;
color: var(--sky-text);
font-size: var(--sky-font-medium-title);
line-height: 1.08;
}
.store-highlight {
position: relative;
min-width: 0;
overflow: hidden;
border-radius: var(--sky-radius-card);
color: #fff;
background:
radial-gradient(
circle at 78% 20%,
var(--store-highlight-glow),
transparent 35%
),
linear-gradient(145deg, var(--store-highlight-background), #10141f 125%);
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.18);
transition:
box-shadow 100ms ease,
transform 100ms ease;
}
.store-highlight__detail-link {
width: 100%;
height: 100%;
position: absolute;
z-index: 3;
inset: 0;
border: 0;
border-radius: inherit;
padding: 0;
background: transparent;
cursor: pointer;
}
.store-highlight__detail-link:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.9);
outline-offset: -3px;
}
.store-highlight__copy {
position: relative;
z-index: 2;
padding: var(--sky-space-5);
}
.store-highlight__copy p,
.store-highlight__copy h2,
.store-highlight__copy > span {
margin: 0;
}
.store-highlight__copy p {
display: flex;
align-items: center;
gap: 6px;
color: rgba(255, 255, 255, 0.76);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.store-highlight__copy h2 {
max-width: 88%;
margin-top: var(--sky-space-2);
font-size: 28px;
line-height: 1.03;
}
.store-highlight__copy > span {
max-width: 78%;
display: block;
margin-top: var(--sky-space-2);
color: rgba(255, 255, 255, 0.78);
font-size: 14px;
line-height: 1.3;
}
.store-highlight__art {
position: relative;
height: 170px;
display: grid;
place-items: center;
}
.store-highlight__orbit {
position: absolute;
width: 180px;
height: 180px;
border: 0;
border-radius: 50%;
background:
radial-gradient(circle at 34% 30%, rgba(255, 255, 255, 0.24), transparent 38%),
radial-gradient(circle at 66% 68%, var(--store-highlight-glow), transparent 70%);
box-shadow: none;
opacity: 0.68;
filter: blur(14px) saturate(125%);
}
.store-highlight__orbit::before,
.store-highlight__orbit::after {
content: '';
position: absolute;
border-radius: 50%;
pointer-events: none;
}
.store-highlight__orbit::before {
inset: -30px;
background: radial-gradient(
circle,
transparent 20%,
rgba(255, 255, 255, 0.12) 45%,
transparent 74%
);
filter: blur(12px);
}
.store-highlight__orbit::after {
inset: -58px;
background:
radial-gradient(circle at 68% 36%, rgba(255, 255, 255, 0.12), transparent 42%),
radial-gradient(circle at 38% 64%, var(--store-highlight-glow), transparent 72%);
opacity: 0.56;
filter: blur(20px) saturate(130%);
}
.store-highlight__art img {
position: relative;
width: 124px;
height: 124px;
border-radius: calc(var(--sky-radius-card) - var(--sky-space-1));
object-fit: cover;
filter: drop-shadow(0 16px 18px rgba(0, 0, 0, 0.34));
transform: rotate(-4deg);
}
.store-highlight__footer {
position: relative;
z-index: 2;
min-height: 76px;
display: flex;
align-items: center;
gap: var(--sky-space-3);
border-radius: 0 0 var(--sky-radius-card) var(--sky-radius-card);
padding: var(--sky-space-3) var(--sky-space-4);
background: linear-gradient(
180deg,
rgba(5, 8, 16, 0.2),
rgba(5, 8, 16, 0.42)
);
}
.store-highlight__footer::before {
content: '';
height: 28px;
position: absolute;
z-index: 0;
top: -28px;
right: 0;
left: 0;
background: linear-gradient(180deg, transparent, rgba(5, 8, 16, 0.2));
pointer-events: none;
}
.store-highlight__footer > img {
position: relative;
z-index: 1;
width: 46px;
height: 46px;
flex: 0 0 46px;
border-radius: calc(var(--sky-radius-control) + var(--sky-space-1));
object-fit: cover;
}
.store-highlight__footer > div {
min-width: 0;
position: relative;
z-index: 1;
display: flex;
flex: 1;
flex-direction: column;
}
.store-highlight__footer strong,
.store-highlight__footer small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-highlight__footer small {
color: rgba(255, 255, 255, 0.7);
}
.store-highlight__footer button {
position: relative;
z-index: 4;
min-width: 68px;
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
border-radius: var(--sky-radius-pill);
padding: 0 var(--sky-space-3);
color: var(--sky-app-accent);
background: rgba(255, 255, 255, 0.9);
font-weight: 800;
}
.store-highlight--compact .store-highlight__copy {
min-height: 116px;
}
.store-highlight--compact .store-highlight__copy h2 {
max-width: 70%;
font-size: 24px;
}
.store-highlight--compact .store-highlight__art {
position: absolute;
top: 18px;
right: 8px;
width: 112px;
height: 112px;
}
.store-highlight--compact .store-highlight__orbit {
width: 96px;
height: 96px;
filter: blur(10px) saturate(125%);
}
.store-highlight--compact .store-highlight__orbit::before {
inset: -18px;
filter: blur(9px);
}
.store-highlight--compact .store-highlight__orbit::after {
inset: -32px;
filter: blur(14px) saturate(130%);
}
.store-highlight--compact .store-highlight__art img {
width: 72px;
height: 72px;
border-radius: calc(var(--sky-radius-control) + var(--sky-space-2));
}
.store-today__section-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--sky-space-3);
margin-top: var(--sky-space-1);
}
.store-today__section-heading > div {
display: flex;
flex-direction: column;
gap: 3px;
}
.store-today__section-heading span {
color: var(--sky-app-accent);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.store-today__section-heading h2 {
margin: 0;
color: var(--sky-text);
font-size: var(--sky-font-medium-title);
line-height: 1.08;
}
.store-today__section-heading > svg {
margin-bottom: 2px;
color: var(--sky-app-accent);
}
.store-highlight__texture {
position: absolute;
inset: 0;
opacity: 0.42;
background-image:
linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
background-size: 22px 22px;
pointer-events: none;
}
.store-highlight__spark {
position: absolute;
z-index: 1;
width: 7px;
height: 7px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 14px 4px var(--store-highlight-glow);
}
.store-highlight__spark--one {
top: 18px;
right: 24%;
}
.store-highlight__spark--two {
bottom: 34px;
left: 22%;
width: 4px;
height: 4px;
opacity: 0.7;
}
.store-highlight--compact .store-highlight__copy {
min-height: 132px;
}
.store-highlight--compact .store-highlight__copy > span {
max-width: 62%;
font-size: 12px;
}
.store-highlight--reverse {
background:
radial-gradient(
circle at 18% 18%,
var(--store-highlight-glow),
transparent 34%
),
linear-gradient(215deg, var(--store-highlight-background), #10141f 125%);
}
.store-highlight--reverse .store-highlight__copy {
display: flex;
align-items: flex-end;
flex-direction: column;
text-align: right;
}
.store-highlight--reverse .store-highlight__copy p,
.store-highlight--reverse .store-highlight__copy h2,
.store-highlight--reverse .store-highlight__copy > span {
max-width: 70%;
}
.store-highlight--reverse .store-highlight__art {
right: auto;
left: 8px;
}
.store-ranking {
overflow: hidden;
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.1);
}
.store-ranking__header {
display: flex;
align-items: center;
gap: var(--sky-space-3);
border-bottom: 1px solid var(--sky-hairline);
padding: var(--sky-space-4);
background: var(--sky-surface-muted);
}
.store-ranking__icon {
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
place-items: center;
flex: 0 0 var(--sky-touch-target);
border-radius: 50%;
color: #fff;
background: linear-gradient(145deg, #ffb21c, #ff7a00);
box-shadow: 0 7px 16px rgba(255, 132, 0, 0.28);
}
.store-ranking__header h2,
.store-ranking__header p {
margin: 0;
}
.store-ranking__header h2 {
color: var(--sky-text);
font-size: 20px;
}
.store-ranking__header p {
margin-top: 2px;
color: var(--sky-muted);
font-size: 12px;
}
.store-ranking ol {
margin: 0;
padding: 0 var(--sky-space-4);
list-style: none;
}
.store-ranking li {
min-height: 72px;
display: flex;
align-items: center;
gap: var(--sky-space-2);
border-bottom: 1px solid var(--sky-hairline);
}
.store-ranking li:last-child {
border-bottom: 0;
}
.store-ranking__detail-link {
min-width: 0;
min-height: 72px;
display: flex;
align-items: center;
flex: 1;
gap: var(--sky-space-2);
border: 0;
padding: 0;
color: inherit;
background: transparent;
text-align: left;
transition: transform 100ms ease;
}
.store-ranking__detail-link:focus-visible {
border-radius: var(--sky-radius-control);
outline: 2px solid var(--sky-app-accent);
outline-offset: -2px;
}
.store-ranking__detail-link > img {
width: 48px;
height: 48px;
flex: 0 0 48px;
border-radius: calc(var(--sky-radius-control) + var(--sky-space-1));
object-fit: cover;
}
.store-ranking__details {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
}
.store-ranking__details strong,
.store-ranking__details small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-ranking__details small {
color: var(--sky-muted);
font-size: 11px;
}
.store-ranking li > button:not(.store-ranking__detail-link),
.store-final-pick > button:not(.store-highlight__detail-link) {
min-width: 68px;
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
border-radius: var(--sky-radius-pill);
padding: 0 var(--sky-space-2);
color: var(--sky-app-accent);
background: var(--sky-surface-muted);
font-size: 12px;
font-weight: 800;
}
.store-final-pick {
position: relative;
min-height: 150px;
display: grid;
align-items: center;
grid-template-columns: 1fr auto;
gap: var(--sky-space-3);
overflow: hidden;
border-radius: var(--sky-radius-card);
padding: var(--sky-space-5);
color: #fff;
background: linear-gradient(
145deg,
var(--store-highlight-background),
#111826
);
transition:
box-shadow 100ms ease,
transform 100ms ease;
}
.store-final-pick__glow {
position: absolute;
top: -70px;
right: -50px;
width: 190px;
height: 190px;
border-radius: 50%;
background: var(--store-highlight-glow);
opacity: 0.42;
filter: blur(30px);
}
.store-final-pick__heading {
position: relative;
z-index: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.store-final-pick__heading span {
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.store-final-pick__heading strong {
margin-top: 6px;
font-size: 22px;
line-height: 1.05;
}
.store-final-pick__heading small {
margin-top: 7px;
color: rgba(255, 255, 255, 0.7);
font-size: 12px;
}
.store-final-pick > img {
position: relative;
z-index: 1;
width: 76px;
height: 76px;
border-radius: calc(var(--sky-radius-card) - var(--sky-space-2));
object-fit: cover;
filter: drop-shadow(0 12px 15px rgba(0, 0, 0, 0.32));
transform: rotate(4deg);
}
.store-final-pick > button:not(.store-highlight__detail-link) {
position: relative;
z-index: 4;
grid-column: 1 / -1;
color: #fff;
background: rgba(255, 255, 255, 0.18);
}
@media (hover: hover) and (pointer: fine) {
.store-highlight:hover,
.store-final-pick:hover {
box-shadow: 0 17px 32px rgba(0, 0, 0, 0.24);
transform: translateY(-1px);
}
}
.store-browse {
display: grid;
gap: var(--sky-space-4);
padding-bottom: var(--sky-space-5);
}
.store-browse__featured-scroll {
min-width: 0;
display: grid;
grid-auto-columns: calc(100% - var(--sky-space-4));
grid-auto-flow: column;
gap: var(--sky-space-3);
overflow-x: auto;
overscroll-behavior-x: contain;
scroll-behavior: smooth;
scroll-snap-type: x mandatory;
scrollbar-width: none;
cursor: grab;
touch-action: pan-y;
}
.store-browse__featured-scroll:active {
cursor: grabbing;
}
.store-browse__featured-scroll::-webkit-scrollbar {
display: none;
}
.store-browse-feature {
position: relative;
min-height: 246px;
overflow: hidden;
border-radius: var(--sky-radius-card);
color: #fff;
background:
radial-gradient(
circle at 78% 18%,
var(--store-highlight-glow),
transparent 38%
),
linear-gradient(150deg, var(--store-highlight-background), #0e1421 125%);
box-shadow: 0 14px 28px rgba(0, 0, 0, 0.18);
opacity: 0.72;
scroll-snap-align: start;
scroll-snap-stop: always;
transform: scale(0.97);
transform-origin: center;
transition:
opacity 260ms ease,
transform 380ms cubic-bezier(0.22, 1, 0.36, 1);
}
.store-browse-feature.is-active {
opacity: 1;
transform: scale(1);
}
.store-browse-feature__texture {
position: absolute;
inset: 0;
opacity: 0.45;
background:
linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.08)),
repeating-linear-gradient(
-32deg,
transparent 0,
transparent 16px,
rgba(255, 255, 255, 0.025) 17px,
rgba(255, 255, 255, 0.025) 18px
);
pointer-events: none;
}
.store-browse-feature__copy {
position: relative;
z-index: 2;
min-height: 80px;
padding: var(--sky-space-3) var(--sky-space-4) 0;
}
.store-browse-feature__copy span {
color: #55b6ff;
font-size: 10px;
font-weight: 850;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.store-browse-feature__copy h2,
.store-browse-feature__copy p {
margin: 0;
}
.store-browse-feature__copy h2 {
max-width: 94%;
margin-top: 4px;
font-size: 21px;
line-height: 1.04;
}
.store-browse-feature__copy p {
margin-top: 4px;
color: rgba(255, 255, 255, 0.68);
font-size: 12px;
}
.store-browse-feature__art {
position: relative;
height: 106px;
display: grid;
place-items: center;
}
.store-browse-feature__art > span {
position: absolute;
width: 150px;
height: 80px;
border: 0;
border-radius: 50%;
background:
radial-gradient(ellipse at 30% 36%, rgba(255, 255, 255, 0.24), transparent 38%),
radial-gradient(ellipse at 70% 64%, var(--store-highlight-glow), transparent 74%);
box-shadow: none;
opacity: 0.72;
filter: blur(11px) saturate(125%);
transform: rotate(-8deg);
}
.store-browse-feature__art > span::before {
content: '';
position: absolute;
inset: -22px -34px;
border-radius: 50%;
background: radial-gradient(
ellipse,
transparent 18%,
rgba(255, 255, 255, 0.12) 46%,
transparent 76%
);
filter: blur(14px);
pointer-events: none;
}
.store-browse-feature__art > svg {
position: absolute;
color: rgba(255, 255, 255, 0.14);
transform: translate(58px, -14px) rotate(12deg);
}
.store-browse-feature__art > img {
position: relative;
z-index: 1;
width: 76px;
height: 76px;
border-radius: calc(var(--sky-radius-card) - var(--sky-space-1));
object-fit: cover;
filter: drop-shadow(0 12px 15px rgba(0, 0, 0, 0.34));
transform: translateX(-24px) rotate(-4deg);
transition: transform 420ms cubic-bezier(0.22, 1, 0.36, 1);
}
.store-browse-feature.is-active .store-browse-feature__art > img {
transform: translateX(-24px) rotate(-4deg) scale(1.04);
}
.store-browse-feature footer {
position: relative;
z-index: 2;
min-height: 60px;
display: flex;
align-items: center;
gap: var(--sky-space-3);
padding: var(--sky-space-2) var(--sky-space-3);
background:
linear-gradient(180deg, rgba(5, 8, 16, 0.22), rgba(5, 8, 16, 0.52)),
linear-gradient(90deg, rgba(5, 8, 16, 0.42), rgba(5, 8, 16, 0.26));
backdrop-filter: blur(10px) saturate(115%);
}
.store-browse-feature footer::before {
content: '';
position: absolute;
z-index: 0;
top: -28px;
right: 0;
left: 0;
height: 28px;
background: linear-gradient(180deg, transparent, rgba(5, 8, 16, 0.3));
backdrop-filter: blur(7px) saturate(110%);
pointer-events: none;
}
.store-browse-feature footer > * {
position: relative;
z-index: 1;
}
.store-browse-feature footer > img {
width: 36px;
height: 36px;
flex: 0 0 36px;
border-radius: calc(var(--sky-radius-control) + var(--sky-space-1));
object-fit: cover;
}
.store-browse-feature__details {
min-width: 0;
overflow: hidden;
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: flex-start;
border: 0;
padding: 0;
color: inherit;
background: transparent;
text-align: left;
}
.store-browse-feature footer strong,
.store-browse-feature footer small {
width: 100%;
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-browse-feature__details:focus-visible,
.store-list__detail-link:focus-visible,
.store-search__detail-link:focus-visible {
outline: 2px solid var(--sky-app-accent);
outline-offset: 2px;
}
.store-browse-feature footer small {
color: rgba(255, 255, 255, 0.66);
font-size: 10px;
}
.store-browse-feature footer button:not(.store-browse-feature__details) {
min-width: 68px;
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
border-radius: var(--sky-radius-pill);
padding: 0 var(--sky-space-3);
color: var(--sky-app-accent);
background: rgba(255, 255, 255, 0.92);
font-size: 12px;
font-weight: 850;
}
.store-browse__pages {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin: calc(0px - var(--sky-space-3)) 0 calc(0px - var(--sky-space-3));
}
.store-browse__pages button {
position: relative;
width: var(--sky-touch-target);
height: var(--sky-touch-target);
display: grid;
place-items: center;
border: 0;
padding: 0;
background: transparent;
}
.store-browse__pages button::after {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--sky-subtle);
content: '';
transition:
width var(--sky-transition-fast) var(--sky-ease-out),
background var(--sky-transition-fast) var(--sky-ease-out);
}
.store-browse__pages button.is-active::after {
width: 18px;
border-radius: var(--sky-radius-pill);
background: var(--sky-app-accent);
}
.store-browse__list-heading {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: var(--sky-space-3);
margin-top: var(--sky-space-2);
}
.store-browse__list-heading > div {
min-width: 0;
}
.store-browse__list-heading span {
color: var(--sky-app-accent);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.09em;
text-transform: uppercase;
}
.store-browse__list-heading h2 {
margin: 2px 0 0;
color: var(--sky-text);
font-size: var(--sky-font-medium-title);
line-height: 1.08;
}
.store-browse__list-heading > strong {
min-width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--sky-radius-pill);
color: var(--sky-muted);
background: var(--sky-surface-variant);
font-size: 11px;
}
.store-list--browse {
overflow: hidden;
border: 1px solid var(--sky-hairline);
border-radius: var(--sky-radius-card);
background: var(--sky-surface);
}
.store-list--browse article {
min-height: 76px;
padding: var(--sky-space-3) var(--sky-space-4);
transition: background-color var(--sky-transition-fast) var(--sky-ease-out);
}
.store-list--browse article:last-of-type {
border-bottom: 0;
}
.store-list--browse .store-icon {
width: 52px;
height: 52px;
flex-basis: 52px;
border-radius: calc(var(--sky-radius-control) + var(--sky-space-1));
}
.store-list--browse small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
}
.app-store-navbar :deep(.sky-navbar__subnavbar .sky-searchbar__control) {
border-color: var(--sky-hairline);
background: var(--sky-surface-variant);
}
.app-store-navbar :deep(.sky-navbar__subnavbar .sky-searchbar__input) {
font-size: 15px;
font-weight: 600;
}
.app-store-navbar :deep(.sky-navbar__subnavbar .sky-searchbar__suffix) {
color: var(--sky-text);
}
.store-search {
display: grid;
gap: var(--sky-space-6);
padding-bottom: var(--sky-space-6);
}
.store-search__section,
.store-search__results {
min-width: 0;
}
.store-search__empty {
min-height: 220px;
margin: 0;
border: 0;
background: transparent;
}
.store-search__heading {
min-height: var(--sky-touch-target);
display: flex;
align-items: center;
gap: var(--sky-space-1);
color: var(--sky-text);
}
.store-search__heading h2 {
margin: 0;
font-size: var(--sky-font-medium-title);
line-height: 1.08;
}
.store-search__heading > div {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--sky-space-3);
}
.store-search__heading > div > span {
min-width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: var(--sky-radius-pill);
color: var(--sky-muted);
background: var(--sky-surface-variant);
font-size: 11px;
font-weight: 800;
}
.store-search__recommendations {
overflow: visible;
border-bottom: 1px solid var(--sky-hairline);
}
.store-search__recommendations article {
min-height: 82px;
display: flex;
align-items: center;
gap: var(--sky-space-3);
border-top: 1px solid var(--sky-hairline);
padding: var(--sky-space-3) 0;
}
.store-search__recommendations article:first-child {
border-top: 0;
}
.store-search__recommendations article.store-search__recommendation--promoted {
margin: 0 calc(0px - var(--sky-space-2));
border: 0;
border-radius: var(--sky-radius-control);
padding: var(--sky-space-3) var(--sky-space-2);
background: linear-gradient(
90deg,
rgba(10, 132, 255, 0.18),
rgba(10, 132, 255, 0.04)
);
box-shadow: inset 0 0 0 1px rgba(10, 132, 255, 0.12);
}
.store-search__recommendations img {
width: 58px;
height: 58px;
flex: 0 0 58px;
border-radius: calc(var(--sky-radius-control) + var(--sky-space-1));
object-fit: cover;
}
.store-search__detail-link {
min-width: 0;
display: flex;
flex: 1;
align-items: center;
gap: var(--sky-space-3);
border: 0;
padding: 0;
color: inherit;
background: transparent;
text-align: left;
}
.store-search__detail-link > span {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
gap: 2px;
}
.store-search__recommendations strong,
.store-search__recommendations small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-search__recommendations strong {
font-size: 15px;
}
.store-search__recommendations small {
color: var(--sky-muted);
font-size: 11px;
}
.store-search__recommendations em {
width: max-content;
margin-top: 2px;
border-radius: 4px;
padding: 2px 5px;
color: #fff;
background: #72aaf5;
font-size: 9px;
font-weight: 800;
font-style: normal;
}
.store-search__recommendations
article
> button:not(.store-search__detail-link) {
min-width: 68px;
min-height: var(--sky-touch-target);
display: grid;
place-items: center;
flex: 0 0 auto;
border: 0;
border-radius: var(--sky-radius-pill);
padding: 0 var(--sky-space-3);
color: var(--sky-app-accent);
background: var(--sky-surface-muted);
font-size: 12px;
font-weight: 850;
}
.store-search__discover {
margin-top: calc(0px - var(--sky-space-2));
}
.store-search__discover-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sky-space-3);
}
.store-search__discover-grid > button {
--phone-effect-solid-background: var(--store-search-tile);
min-width: 0;
min-height: 124px;
position: relative;
display: flex;
overflow: hidden;
flex-direction: column;
justify-content: flex-end;
border: 0;
border-radius: var(--sky-radius-card);
padding: var(--sky-space-4);
color: #fff;
background:
radial-gradient(
circle at 80% 18%,
rgba(255, 255, 255, 0.36),
transparent 34%
),
var(--store-search-tile);
text-align: left;
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.12);
}
.store-search__discover-grid > button::before {
width: 68px;
height: 68px;
position: absolute;
top: -16px;
right: -8px;
border: 12px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
content: '';
}
.store-search__discover-grid svg {
position: absolute;
top: var(--sky-space-3);
right: var(--sky-space-3);
color: rgba(255, 255, 255, 0.5);
filter: drop-shadow(0 3px 5px rgba(0, 0, 0, 0.12));
}
.store-search__discover-grid span,
.store-search__discover-grid small {
position: relative;
z-index: 1;
}
.store-search__discover-grid span {
max-width: 92%;
font-size: 18px;
font-weight: 800;
line-height: 1.05;
}
.store-search__discover-grid small {
margin-top: 5px;
overflow: hidden;
color: rgba(255, 255, 255, 0.72);
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.store-browse__featured-scroll {
scroll-behavior: auto;
}
.store-browse-feature,
.store-browse-feature__art > img,
.store-browse__pages button {
transition: none;
}
}
.store-list {
padding: 0;
}
.store-list__detail-link {
min-width: 0;
min-height: 60px;
display: flex;
align-items: center;
flex: 1;
gap: var(--sky-space-3);
border: 0;
padding: 0;
color: inherit;
background: transparent;
text-align: left;
}
.store-list__detail-link > span {
min-width: 0;
display: flex;
flex: 1;
flex-direction: column;
}
.store-list__detail-link strong,
.store-list__detail-link small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-list article {
display: flex;
align-items: center;
gap: var(--sky-space-3);
border-color: var(--sky-hairline);
}
.store-list small,
.store-empty {
color: var(--sky-muted);
}
.store-list__tagline {
max-width: 100%;
margin-top: 3px;
overflow: hidden;
color: var(--sky-muted);
font-size: 11px;
font-weight: 500;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-list article > button:not(.store-list__detail-link) {
min-width: 68px;
min-height: var(--sky-touch-target);
flex: 0 0 auto;
color: var(--sky-app-accent);
background: var(--sky-surface-muted);
}
.store-highlight__footer .store-action-button--icon,
.store-ranking li > .store-action-button--icon,
.store-final-pick > .store-action-button--icon,
.store-browse-feature footer > .store-action-button--icon,
.store-search__recommendations article > .store-action-button--icon,
.store-list article > .store-action-button--icon {
width: 54px;
min-width: 54px;
min-height: 30px;
height: 30px;
padding: 0;
border-color: transparent;
color: var(--sky-app-accent);
background: transparent;
box-shadow: none;
}
.store-highlight__footer .store-action-button--get,
.store-ranking li > .store-action-button--get,
.store-final-pick > .store-action-button--get,
.store-browse-feature footer > .store-action-button--get,
.store-search__recommendations article > .store-action-button--get,
.store-list article > .store-action-button--get {
min-width: 54px;
min-height: 30px;
height: 30px;
padding: 0 10px;
font-size: 10px;
}
.store-ranking li > button.store-action-button--get:not(.store-ranking__detail-link),
.store-browse-feature
footer
> button.store-action-button--get:not(.store-browse-feature__details),
.store-search__recommendations
article
> button.store-action-button--get:not(.store-search__detail-link),
.store-list article > button.store-action-button--get:not(.store-list__detail-link) {
width: 56px;
min-width: 56px;
min-height: 32px;
height: 32px;
padding: 0 8px;
font-size: 11px;
font-weight: 850;
letter-spacing: 0.04em;
}
.store-ranking li > button.store-action-button--icon:not(.store-ranking__detail-link),
.store-browse-feature
footer
> button.store-action-button--icon:not(.store-browse-feature__details),
.store-search__recommendations
article
> button.store-action-button--icon:not(.store-search__detail-link),
.store-list article > button.store-action-button--icon:not(.store-list__detail-link) {
width: 56px;
min-width: 56px;
min-height: 32px;
height: 32px;
}
.store-installing {
color: var(--sky-app-accent);
}
.app-store-navigation__item {
min-width: 0;
display: flex;
align-items: center;
flex-direction: column;
gap: 2px;
line-height: 1;
}
@media (hover: hover) and (pointer: fine) {
.app-store-page
:deep(
button:not(.store-ranking__detail-link):not(
.store-browse-feature__details
):not(.store-list__detail-link):not(.store-search__detail-link):not(
:disabled
):hover
) {
filter: brightness(1.08);
transform: translateY(-1px);
}
.store-list--browse article:hover {
background-color: var(--sky-surface-muted);
}
.store-ranking
li
> button.store-action-button--get:not(.store-ranking__detail-link):hover,
.store-browse-feature
footer
> button.store-action-button--get:not(.store-browse-feature__details):hover,
.store-search__recommendations
article
> button.store-action-button--get:not(.store-search__detail-link):hover,
.store-list
article
> button.store-action-button--get:not(.store-list__detail-link):hover {
background: var(--sky-app-accent-soft);
filter: none;
transform: none;
}
.app-store-page :deep(button:not(:disabled):active) {
filter: brightness(0.98);
transform: scale(0.97);
}
.app-store-page .store-action-button--icon:hover {
filter: brightness(1.16) drop-shadow(0 3px 7px var(--sky-app-accent-soft));
}
.store-account__primary-action:not(:disabled):hover {
background: var(--sky-app-accent-soft);
}
.store-account__remove:not(:disabled):hover {
background: var(--sky-danger-soft);
}
}
@media (prefers-reduced-motion: reduce) {
.app-store-page :deep(button) {
transition: none;
}
}
</style>