Files
sky_phone/frontend/src/views/apps/AppStoreApp.vue
T
smx.pusha cc7667f037 FIX - match App Store install action size
Align the installing progress action with the GET button dimensions in App Store lists and the detail view. Add contract coverage for both layouts.
2026-08-16 19:48:56 +02:00

2889 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') {
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) &&
!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>