mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 01:08:59 +00:00
ENH - overhaul App Store experience
This commit is contained in:
@@ -627,6 +627,24 @@ export const BUILTIN_PHONE_APP_IDS: ReadonlySet<BuiltinPhoneAppId> = new Set(
|
||||
BUILTIN_PHONE_APPS.map((app) => app.id),
|
||||
)
|
||||
|
||||
export const DEFAULT_INSTALLED_PHONE_APP_IDS: ReadonlySet<BuiltinPhoneAppId> =
|
||||
new Set([
|
||||
'phone',
|
||||
'messages',
|
||||
'calculator',
|
||||
'camera',
|
||||
'clock',
|
||||
'weather',
|
||||
'mail',
|
||||
'notes',
|
||||
'memos',
|
||||
'photos',
|
||||
'app-store',
|
||||
'settings',
|
||||
'map',
|
||||
'calendar',
|
||||
])
|
||||
|
||||
export const NON_REMOVABLE_PHONE_APP_IDS: ReadonlySet<LaunchablePhoneAppId> =
|
||||
new Set([
|
||||
'app-store',
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { NON_REMOVABLE_PHONE_APP_IDS } from '@/config/apps'
|
||||
import {
|
||||
DEFAULT_INSTALLED_PHONE_APP_IDS,
|
||||
NON_REMOVABLE_PHONE_APP_IDS,
|
||||
} from '@/config/apps'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import {
|
||||
getHomeFolder,
|
||||
@@ -32,6 +35,53 @@ describe('app store', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('installs only the fourteen standard built-in apps by default', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate(null)
|
||||
|
||||
expect([...DEFAULT_INSTALLED_PHONE_APP_IDS]).toEqual([
|
||||
'phone',
|
||||
'messages',
|
||||
'calculator',
|
||||
'camera',
|
||||
'clock',
|
||||
'weather',
|
||||
'mail',
|
||||
'notes',
|
||||
'memos',
|
||||
'photos',
|
||||
'app-store',
|
||||
'settings',
|
||||
'map',
|
||||
'calendar',
|
||||
])
|
||||
for (const appId of DEFAULT_INSTALLED_PHONE_APP_IDS) {
|
||||
expect(apps.isInstalled(appId)).toBe(true)
|
||||
}
|
||||
expect(apps.isInstalled('banking')).toBe(false)
|
||||
expect(apps.isInstalled('feather')).toBe(false)
|
||||
expect(apps.isInstalled('snake')).toBe(false)
|
||||
})
|
||||
|
||||
it('removes old automatic apps unless the player installed them', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({
|
||||
claimedApps: ['feather'],
|
||||
homeLayout: {
|
||||
dock: [],
|
||||
grid: ['banking', 'feather', 'phone'],
|
||||
hidden: [],
|
||||
version: 5,
|
||||
},
|
||||
})
|
||||
|
||||
expect(apps.homeLayout.grid).not.toContain('banking')
|
||||
expect(apps.homeLayout.grid).toContain('feather')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('hydrates valid launch counts and persists launches with claimed apps', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -129,6 +179,79 @@ describe('app store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('fully uninstalls removable apps and allows downloading them again', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.uninstallApp('calculator')).toBe(true)
|
||||
expect(apps.isInstalled('calculator')).toBe(false)
|
||||
expect(apps.uninstalledApps).toEqual(['calculator'])
|
||||
expect(apps.homeLayout.hidden).toContain('calculator')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
uninstalledApps: ['calculator'],
|
||||
})
|
||||
|
||||
apps.installApp('calculator')
|
||||
vi.advanceTimersByTime(3000)
|
||||
|
||||
expect(apps.isInstalled('calculator')).toBe(true)
|
||||
expect(apps.uninstalledApps).toEqual([])
|
||||
expect(apps.homeLayout.hidden).not.toContain('calculator')
|
||||
})
|
||||
|
||||
it('hydrates persisted removals while rejecting protected and invalid ids', () => {
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
apps.hydrate({
|
||||
uninstalledApps: ['calculator', 'phone', 'not-an-app'],
|
||||
updatedAppReleases: {
|
||||
calculator: '2026-08-14',
|
||||
phone: '2026-08-14',
|
||||
snake: 'x'.repeat(64),
|
||||
},
|
||||
})
|
||||
|
||||
expect(apps.uninstalledApps).toEqual(['calculator'])
|
||||
expect(apps.isInstalled('calculator')).toBe(false)
|
||||
expect(apps.isInstalled('phone')).toBe(true)
|
||||
expect(apps.updatedAppReleases).toEqual({ phone: '2026-08-14' })
|
||||
})
|
||||
|
||||
it('protects system apps from full uninstallation', () => {
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
mocks.phone.saveDeviceNamespace.mockClear()
|
||||
|
||||
expect(apps.uninstallApp('phone')).toBe(false)
|
||||
expect(apps.isInstalled('phone')).toBe(true)
|
||||
expect(mocks.phone.saveDeviceNamespace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates installed apps and persists the current release', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
apps.hydrate(null)
|
||||
|
||||
apps.updateApp('phone', '2026-08-14')
|
||||
expect(apps.updatingApps.phone).toBe(true)
|
||||
|
||||
vi.advanceTimersByTime(1800)
|
||||
|
||||
expect(apps.updatingApps.phone).toBeUndefined()
|
||||
expect(apps.updatedAppReleases.phone).toBe('2026-08-14')
|
||||
expect(mocks.phone.saveDeviceNamespace).toHaveBeenLastCalledWith('apps', {
|
||||
claimedApps: [],
|
||||
homeLayout: apps.homeLayout,
|
||||
launchCounts: {},
|
||||
updatedAppReleases: { phone: '2026-08-14' },
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores duplicate installation requests and invalid persisted ids', () => {
|
||||
vi.useFakeTimers()
|
||||
const apps = useAppStoreStore()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import {
|
||||
DEFAULT_INSTALLED_PHONE_APP_IDS,
|
||||
getPhoneApp,
|
||||
isExternalPhoneApp,
|
||||
isPhoneAppId,
|
||||
@@ -30,6 +31,7 @@ import {
|
||||
import { nuiCall } from '@/utils/nui'
|
||||
|
||||
const INSTALL_DURATION_MS = 3000
|
||||
const UPDATE_DURATION_MS = 1800
|
||||
|
||||
type PendingInstallation = {
|
||||
deviceImei: string
|
||||
@@ -37,10 +39,19 @@ type PendingInstallation = {
|
||||
token: symbol
|
||||
}
|
||||
|
||||
type PendingUpdate = {
|
||||
deviceImei: string
|
||||
timer: ReturnType<typeof globalThis.setTimeout>
|
||||
}
|
||||
|
||||
const pendingInstallations = new WeakMap<
|
||||
object,
|
||||
Map<LaunchablePhoneAppId, PendingInstallation>
|
||||
>()
|
||||
const pendingUpdates = new WeakMap<
|
||||
object,
|
||||
Map<LaunchablePhoneAppId, PendingUpdate>
|
||||
>()
|
||||
|
||||
function getDefaultGridIds(): LaunchablePhoneAppId[] {
|
||||
return [...PHONE_APPS]
|
||||
@@ -56,7 +67,9 @@ function getDefaultDockIds(): LaunchablePhoneAppId[] {
|
||||
|
||||
function getDefaultInstalledIds(): LaunchablePhoneAppId[] {
|
||||
return PHONE_APPS.filter((app) =>
|
||||
isExternalPhoneApp(app) ? app.defaultInstalled : app.category !== 'games',
|
||||
isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id),
|
||||
).map((app) => app.id)
|
||||
}
|
||||
|
||||
@@ -67,9 +80,39 @@ function isProtectedHomeApp(appId: LaunchablePhoneAppId): boolean {
|
||||
: NON_REMOVABLE_PHONE_APP_IDS.has(appId)
|
||||
}
|
||||
|
||||
function hasUninstalledBuiltinApp(
|
||||
layout: unknown,
|
||||
installedIds: readonly LaunchablePhoneAppId[],
|
||||
): boolean {
|
||||
if (!layout || typeof layout !== 'object') return false
|
||||
const installed = new Set(installedIds)
|
||||
const source = layout as {
|
||||
dock?: unknown
|
||||
grid?: unknown
|
||||
hidden?: unknown
|
||||
}
|
||||
const items = [source.dock, source.grid, source.hidden]
|
||||
.filter(Array.isArray)
|
||||
.flat(2)
|
||||
|
||||
return items.some((item) => {
|
||||
const ids =
|
||||
item && typeof item === 'object' && Array.isArray(item.apps)
|
||||
? item.apps
|
||||
: [item]
|
||||
return ids.some((id: unknown) => {
|
||||
if (typeof id !== 'string') return false
|
||||
const app = getPhoneApp(id)
|
||||
return !!app && !installed.has(app.id) && !isExternalPhoneApp(app)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const useAppStoreStore = defineStore('app-store', {
|
||||
state: () => ({
|
||||
claimedApps: [] as LaunchablePhoneAppId[],
|
||||
uninstalledApps: [] as LaunchablePhoneAppId[],
|
||||
updatedAppReleases: {} as Partial<Record<LaunchablePhoneAppId, string>>,
|
||||
homeLayout: createDefaultHomeLayout(
|
||||
getDefaultInstalledIds(),
|
||||
getDefaultGridIds(),
|
||||
@@ -77,6 +120,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
),
|
||||
hydrated: false,
|
||||
installingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
updatingApps: {} as Partial<Record<LaunchablePhoneAppId, boolean>>,
|
||||
launchCounts: {} as Partial<Record<LaunchablePhoneAppId, number>>,
|
||||
}),
|
||||
actions: {
|
||||
@@ -95,6 +139,9 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
return true
|
||||
},
|
||||
claimApp(id: LaunchablePhoneAppId): void {
|
||||
this.uninstalledApps = this.uninstalledApps.filter(
|
||||
(appId) => appId !== id,
|
||||
)
|
||||
if (!this.claimedApps.includes(id)) {
|
||||
this.claimedApps.push(id)
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, id)
|
||||
@@ -110,6 +157,14 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
pendingInstallations.delete(this)
|
||||
}
|
||||
this.installingApps = {}
|
||||
const updates = pendingUpdates.get(this)
|
||||
if (updates) {
|
||||
for (const update of updates.values()) {
|
||||
globalThis.clearTimeout(update.timer)
|
||||
}
|
||||
pendingUpdates.delete(this)
|
||||
}
|
||||
this.updatingApps = {}
|
||||
},
|
||||
installApp(id: LaunchablePhoneAppId): void {
|
||||
const installed = this.isInstalled(id)
|
||||
@@ -188,6 +243,8 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
claimedApps?: unknown
|
||||
homeLayout?: unknown
|
||||
launchCounts?: unknown
|
||||
uninstalledApps?: unknown
|
||||
updatedAppReleases?: unknown
|
||||
} | null
|
||||
const layoutVersion =
|
||||
data?.homeLayout && typeof data.homeLayout === 'object'
|
||||
@@ -204,9 +261,40 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
isValidExternalPhoneAppId(id))),
|
||||
)
|
||||
: []
|
||||
this.uninstalledApps = Array.isArray(data?.uninstalledApps)
|
||||
? data.uninstalledApps.filter((id): id is LaunchablePhoneAppId => {
|
||||
if (typeof id !== 'string' || !isPhoneAppId(id)) return false
|
||||
const app = getPhoneApp(id)
|
||||
return !!app && isPhoneAppRemovable(app)
|
||||
})
|
||||
: []
|
||||
this.updatedAppReleases = {}
|
||||
if (
|
||||
data?.updatedAppReleases &&
|
||||
typeof data.updatedAppReleases === 'object'
|
||||
) {
|
||||
for (const [id, release] of Object.entries(data.updatedAppReleases)) {
|
||||
if (
|
||||
isPhoneAppId(id) &&
|
||||
typeof release === 'string' &&
|
||||
release.length <= 32
|
||||
) {
|
||||
this.updatedAppReleases[id] = release
|
||||
}
|
||||
}
|
||||
}
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
]
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
for (const appId of Object.keys(this.updatedAppReleases)) {
|
||||
if (!isPhoneAppId(appId) || !installedIds.includes(appId)) {
|
||||
delete this.updatedAppReleases[appId as LaunchablePhoneAppId]
|
||||
}
|
||||
}
|
||||
const removedLegacyDefaults = hasUninstalledBuiltinApp(
|
||||
data?.homeLayout,
|
||||
installedIds,
|
||||
)
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
@@ -216,6 +304,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
data?.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
false,
|
||||
)
|
||||
const protectedHiddenAppIds =
|
||||
this.homeLayout.hidden.filter(isProtectedHomeApp)
|
||||
@@ -240,6 +329,7 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.hydrated = true
|
||||
if (
|
||||
protectedHiddenAppIds.length ||
|
||||
removedLegacyDefaults ||
|
||||
layoutVersion === 2 ||
|
||||
layoutVersion === 3 ||
|
||||
layoutVersion === 4
|
||||
@@ -248,24 +338,30 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
}
|
||||
},
|
||||
isInstalled(appId: LaunchablePhoneAppId): boolean {
|
||||
if (this.uninstalledApps.includes(appId)) return false
|
||||
if (this.claimedApps.includes(appId)) return true
|
||||
const app = getPhoneApp(appId)
|
||||
if (!app) return false
|
||||
return isExternalPhoneApp(app)
|
||||
? app.defaultInstalled
|
||||
: app.category !== 'games'
|
||||
: DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)
|
||||
},
|
||||
reconcileCatalog(): void {
|
||||
const installedIds = [
|
||||
...new Set([...getDefaultInstalledIds(), ...this.claimedApps]),
|
||||
]
|
||||
].filter((id) => !this.uninstalledApps.includes(id))
|
||||
const defaults = createDefaultHomeLayout(
|
||||
installedIds,
|
||||
getDefaultGridIds(),
|
||||
getDefaultDockIds(),
|
||||
)
|
||||
const previous = JSON.stringify(this.homeLayout)
|
||||
this.homeLayout = parseHomeLayout(this.homeLayout, defaults, installedIds)
|
||||
this.homeLayout = parseHomeLayout(
|
||||
this.homeLayout,
|
||||
defaults,
|
||||
installedIds,
|
||||
false,
|
||||
)
|
||||
|
||||
for (const appId of [...this.homeLayout.hidden]) {
|
||||
if (isProtectedHomeApp(appId)) {
|
||||
@@ -406,11 +502,73 @@ export const useAppStoreStore = defineStore('app-store', {
|
||||
this.homeLayout = restoreHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
},
|
||||
uninstallApp(appId: LaunchablePhoneAppId): boolean {
|
||||
const app = getPhoneApp(appId)
|
||||
if (!app || !this.isInstalled(appId) || !isPhoneAppRemovable(app)) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.claimedApps = this.claimedApps.filter((id) => id !== appId)
|
||||
if (!this.uninstalledApps.includes(appId)) {
|
||||
this.uninstalledApps.push(appId)
|
||||
}
|
||||
delete this.updatedAppReleases[appId]
|
||||
this.homeLayout = removeHomeApp(this.homeLayout, appId)
|
||||
this.persist()
|
||||
|
||||
return true
|
||||
},
|
||||
updateApp(appId: LaunchablePhoneAppId, release: string): void {
|
||||
if (
|
||||
!this.isInstalled(appId) ||
|
||||
this.updatingApps[appId] ||
|
||||
!release.trim()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const deviceImei = phone.device?.imei
|
||||
if (!phone.isOpen || !deviceImei) {
|
||||
console.error(
|
||||
`[App store] Update cancelled because no phone device is open for ${appId}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const updates =
|
||||
pendingUpdates.get(this) ??
|
||||
new Map<LaunchablePhoneAppId, PendingUpdate>()
|
||||
this.updatingApps[appId] = true
|
||||
const timer = globalThis.setTimeout(() => {
|
||||
updates.delete(appId)
|
||||
if (!updates.size) pendingUpdates.delete(this)
|
||||
delete this.updatingApps[appId]
|
||||
const activePhone = usePhoneStore()
|
||||
if (!activePhone.isOpen || activePhone.device?.imei !== deviceImei) {
|
||||
console.error(
|
||||
`[App store] Update cancelled because the active phone changed for ${appId}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (!this.isInstalled(appId)) return
|
||||
this.updatedAppReleases[appId] = release
|
||||
this.persist()
|
||||
}, UPDATE_DURATION_MS)
|
||||
updates.set(appId, { deviceImei, timer })
|
||||
pendingUpdates.set(this, updates)
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('apps', {
|
||||
claimedApps: this.claimedApps,
|
||||
homeLayout: this.homeLayout,
|
||||
launchCounts: this.launchCounts,
|
||||
...(this.uninstalledApps.length
|
||||
? { uninstalledApps: this.uninstalledApps }
|
||||
: {}),
|
||||
...(Object.keys(this.updatedAppReleases).length
|
||||
? { updatedAppReleases: this.updatedAppReleases }
|
||||
: {}),
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
@@ -35,6 +35,7 @@ export type PhoneOpenPayload = {
|
||||
locales?: LocaleTree
|
||||
memos?: DeviceBootstrap['memos']
|
||||
notes?: DeviceBootstrap['notes']
|
||||
player?: DeviceBootstrap['player']
|
||||
security?: DeviceSecurity
|
||||
token?: string
|
||||
}
|
||||
@@ -1756,15 +1757,116 @@ const defaultLocales: LocaleTree = {
|
||||
get: 'GET',
|
||||
open: 'OPEN',
|
||||
installing: 'Installing',
|
||||
searchPlaceholder: 'Search apps and games',
|
||||
player: 'Player',
|
||||
profileLabel: '{name} profile',
|
||||
searchPlaceholder: 'Games, apps, stories and more',
|
||||
appsTitle: 'Built-in Apps',
|
||||
gamesTitle: 'Games',
|
||||
selected: 'Apps available for your Sky Phone',
|
||||
tabs: {
|
||||
today: 'Today',
|
||||
apps: 'Apps',
|
||||
games: 'Games',
|
||||
search: 'Search',
|
||||
},
|
||||
today: {
|
||||
freshDaily: 'A fresh selection every day',
|
||||
dailyEdition: 'Daily Edition',
|
||||
featured: 'Featured today',
|
||||
gameHighlight: 'Game highlight',
|
||||
appHighlight: 'App highlight',
|
||||
curatedForYou: 'Curated for you',
|
||||
editorsChoice: "Editor's Choice",
|
||||
discoverTitle: 'Discover {app}',
|
||||
playTitle: 'Play something new with {app}',
|
||||
description: '{app} is in the spotlight today on Sky Phone.',
|
||||
categoryDescription: '{category} · Made for Sky Phone',
|
||||
moreHighlights: "Today's Highlights",
|
||||
topToday: 'Top Today',
|
||||
topTodayDescription: 'Popular picks from today’s edition',
|
||||
oneMoreThing: 'One more thing',
|
||||
},
|
||||
filters: {
|
||||
all: 'All',
|
||||
social: 'Social',
|
||||
utilities: 'Utilities',
|
||||
shopping: 'Shopping',
|
||||
productivity: 'Productivity',
|
||||
arcade: 'Arcade',
|
||||
puzzle: 'Puzzle',
|
||||
classic: 'Classics',
|
||||
},
|
||||
browse: {
|
||||
categories: 'Store categories',
|
||||
availableNow: 'Available now',
|
||||
featureTitle: 'Discover {app}',
|
||||
featuredPages: 'Featured apps',
|
||||
handPicked: 'Hand-picked for you',
|
||||
essentialApps: 'Essential Apps',
|
||||
essentialGames: 'Essential Games',
|
||||
},
|
||||
search: {
|
||||
recommended: 'Recommended',
|
||||
discover: 'Discover',
|
||||
results: 'Search Results',
|
||||
noResults: 'No matching apps or games found.',
|
||||
ad: 'Ad',
|
||||
topApps: 'Top Apps',
|
||||
topGames: 'Top Games',
|
||||
bestApps: 'Bestselling Apps',
|
||||
bestGames: 'Bestselling Games',
|
||||
productivity: 'Productivity',
|
||||
photoVideo: 'Photo & Video',
|
||||
},
|
||||
account: {
|
||||
account: 'Account',
|
||||
title: 'App Management',
|
||||
skyAccount: 'Sky Phone Account',
|
||||
apps: 'Installed Apps',
|
||||
games: 'Games',
|
||||
library: 'Your Library',
|
||||
myApps: 'My Apps',
|
||||
update: 'UPDATE',
|
||||
uninstall: 'Uninstall',
|
||||
uninstallApp: 'Uninstall {app}',
|
||||
uninstallTitle: 'Uninstall this app?',
|
||||
uninstallBody:
|
||||
'{app} will be removed from this phone. You can download it again from the App Store.',
|
||||
},
|
||||
details: {
|
||||
skyStudios: 'Sky Studios',
|
||||
share: 'Share app',
|
||||
openDetails: 'View {app}',
|
||||
shareReady: 'App information is ready to share.',
|
||||
shareCopy: 'Take a look at {app} in the App Store.',
|
||||
ratings: '{count} ratings',
|
||||
age: 'Age Rating',
|
||||
years: 'Years',
|
||||
chart: 'Chart',
|
||||
whatsNew: "What's New",
|
||||
version: 'Version {version}',
|
||||
updatedToday: 'Today',
|
||||
releaseNotes:
|
||||
'{app} now includes fresh content, faster loading and several interface improvements.',
|
||||
preview: 'Preview',
|
||||
testData: 'Preview data',
|
||||
previewLive: 'Live Preview',
|
||||
previousPreview: 'Previous preview',
|
||||
nextPreview: 'Next preview',
|
||||
previewOverview: 'Everything important at a glance',
|
||||
previewCommunity: 'Community',
|
||||
previewInsights: 'Insights',
|
||||
today: 'Today',
|
||||
progress: 'Progress',
|
||||
communityTitle: 'Together in {app}',
|
||||
communityBody: 'Discover activity from players around Los Santos.',
|
||||
featuredMoment: 'Featured moment from today',
|
||||
weeklyActivity: 'Weekly Activity',
|
||||
fromLastWeek: 'from last week',
|
||||
about: 'About this App',
|
||||
description:
|
||||
'{app} is a detailed {category} experience made for Sky Phone. Explore its features, connect with the city and keep everything close at hand.',
|
||||
},
|
||||
},
|
||||
phone: {
|
||||
name: 'Phone',
|
||||
@@ -3637,7 +3739,8 @@ const defaultLocales: LocaleTree = {
|
||||
shareCopy: '{count} photos and videos shared from Photos.',
|
||||
delete: 'Delete Selected',
|
||||
deleteTitle: 'Delete Selected Media?',
|
||||
deleteBody: 'The selected photos and videos will be permanently deleted.',
|
||||
deleteBody:
|
||||
'The selected photos and videos will be permanently deleted.',
|
||||
deleted: '{count} media items deleted.',
|
||||
limit: 'You can select up to 50 media items.',
|
||||
},
|
||||
@@ -4101,6 +4204,10 @@ export const usePhoneStore = defineStore('phone', {
|
||||
preferences: cloneJsonData(DEFAULT_PHONE_PREFERENCES),
|
||||
persistenceGeneration: 0,
|
||||
persistenceSession: ++nextPersistenceSession,
|
||||
player: {
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
} as DeviceBootstrap['player'],
|
||||
security: {
|
||||
enabled: false,
|
||||
length: null,
|
||||
@@ -4133,6 +4240,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
this.lang = payload.lang ?? 'en'
|
||||
this.locales = payload.locales ?? defaultLocales
|
||||
if (payload.device) this.hydrateDevice(payload.device)
|
||||
if (payload.player) this.player = payload.player
|
||||
this.security = payload.security ?? {
|
||||
enabled: false,
|
||||
length: null,
|
||||
@@ -4142,6 +4250,7 @@ export const usePhoneStore = defineStore('phone', {
|
||||
},
|
||||
endDeviceSession(): void {
|
||||
this.close()
|
||||
this.player = { firstName: '', lastName: '' }
|
||||
if (this.deviceSessionToken !== null) {
|
||||
this.deviceSessionToken = null
|
||||
this.persistenceGeneration += 1
|
||||
|
||||
@@ -14,6 +14,11 @@ export type PhoneDevice = {
|
||||
sim: PhoneSim | null
|
||||
}
|
||||
|
||||
export type PhonePlayerIdentity = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
}
|
||||
|
||||
export type PhoneNotificationDevicePayload = {
|
||||
imei: string
|
||||
name: string
|
||||
@@ -45,6 +50,7 @@ export type DeviceBootstrap = {
|
||||
device: PhoneDevice
|
||||
memos: MemoDto[]
|
||||
notes: Note[]
|
||||
player: PhonePlayerIdentity
|
||||
security: DeviceSecurity
|
||||
token: string
|
||||
}
|
||||
|
||||
@@ -1203,6 +1203,15 @@ label.sky-list-item__row {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.sky-searchbar__suffix {
|
||||
width: var(--sky-touch-target, 44px);
|
||||
height: var(--sky-touch-target, 44px);
|
||||
display: grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--sky-muted, rgba(0, 0, 0, 0.55));
|
||||
}
|
||||
|
||||
.sky-searchbar__clear {
|
||||
width: var(--sky-touch-target, 44px);
|
||||
height: var(--sky-touch-target, 44px);
|
||||
|
||||
@@ -160,6 +160,9 @@ function disable(event: MouseEvent): void {
|
||||
@focus="handleFocus"
|
||||
@input="handleInput"
|
||||
/>
|
||||
<span v-if="$slots.suffix" class="sky-searchbar__suffix">
|
||||
<slot name="suffix" />
|
||||
</span>
|
||||
<button
|
||||
v-if="clearButton && localValue"
|
||||
class="sky-searchbar__clear"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getDailyHighlights } from '@/utils/appStoreHighlights'
|
||||
|
||||
const candidates = [
|
||||
{ id: 'banking' },
|
||||
{ id: 'feather' },
|
||||
{ id: 'snake' },
|
||||
{ id: 'music' },
|
||||
{ id: 'picstagram' },
|
||||
]
|
||||
|
||||
describe('daily App Store highlights', () => {
|
||||
it('keeps the generated order stable throughout one local day', () => {
|
||||
const morning = getDailyHighlights(candidates, new Date(2026, 7, 14, 8, 15))
|
||||
const evening = getDailyHighlights(
|
||||
candidates,
|
||||
new Date(2026, 7, 14, 22, 45),
|
||||
)
|
||||
|
||||
expect(evening).toEqual(morning)
|
||||
expect(morning).toHaveLength(candidates.length)
|
||||
})
|
||||
|
||||
it('generates a different curation for the next local day', () => {
|
||||
const today = getDailyHighlights(candidates, new Date(2026, 7, 14))
|
||||
const tomorrow = getDailyHighlights(candidates, new Date(2026, 7, 15))
|
||||
|
||||
expect(tomorrow.map((app) => app.id)).not.toEqual(
|
||||
today.map((app) => app.id),
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
type HighlightCandidate = {
|
||||
id: string
|
||||
}
|
||||
|
||||
function localDayNumber(date: Date): number {
|
||||
return Math.floor(
|
||||
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()) / 86_400_000,
|
||||
)
|
||||
}
|
||||
|
||||
function nextRandom(seed: number): number {
|
||||
return (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0
|
||||
}
|
||||
|
||||
export function getDailyHighlights<T extends HighlightCandidate>(
|
||||
candidates: readonly T[],
|
||||
date = new Date(),
|
||||
): T[] {
|
||||
const ordered = [...candidates].sort((left, right) =>
|
||||
left.id.localeCompare(right.id),
|
||||
)
|
||||
let seed = localDayNumber(date) >>> 0
|
||||
|
||||
for (let index = ordered.length - 1; index > 0; index -= 1) {
|
||||
seed = nextRandom(seed)
|
||||
const target = seed % (index + 1)
|
||||
const current = ordered[index]
|
||||
ordered[index] = ordered[target]
|
||||
ordered[target] = current
|
||||
}
|
||||
|
||||
if (ordered.length > 1) {
|
||||
const offset = localDayNumber(date) % ordered.length
|
||||
return [...ordered.slice(offset), ...ordered.slice(0, offset)]
|
||||
}
|
||||
|
||||
return ordered
|
||||
}
|
||||
@@ -453,12 +453,16 @@ export function parseHomeLayout(
|
||||
value: unknown,
|
||||
defaults: HomeLayout,
|
||||
installedIds: LaunchablePhoneAppId[],
|
||||
preservePersistedIds = true,
|
||||
): HomeLayout {
|
||||
if (!value || typeof value !== 'object') return defaults
|
||||
|
||||
const source = value as Partial<Record<keyof HomeLayout, unknown>>
|
||||
const availableIds = new Set(installedIds)
|
||||
if (source.version === 3 || source.version === 4 || source.version === 5) {
|
||||
if (
|
||||
preservePersistedIds &&
|
||||
(source.version === 3 || source.version === 4 || source.version === 5)
|
||||
) {
|
||||
for (const collection of [source.dock, source.grid, source.hidden]) {
|
||||
if (!Array.isArray(collection)) continue
|
||||
for (const item of collection) {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('./AppStoreAction.vue', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('AppStoreAction contract', () => {
|
||||
it('uses an Apple-style cloud download icon instead of GET text', () => {
|
||||
expect(source).toContain("import { CloudDownload } from 'lucide-vue-next'")
|
||||
expect(source).toContain('v-if="action === \'get\'"')
|
||||
expect(source).not.toContain("phone.t('Apps.appStore.get')")
|
||||
})
|
||||
|
||||
it('shows a timed circular installation progress with a center stop mark', () => {
|
||||
expect(source).toContain('class="app-store-action__progress"')
|
||||
expect(source).toContain('class="app-store-action__track"')
|
||||
expect(source).toContain('class="app-store-action__value"')
|
||||
expect(source).toContain(
|
||||
'animation: app-store-download-progress 3s linear forwards',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.app-store-action__progress i\s*\{[^}]*width:\s*7px/s,
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { CloudDownload } from 'lucide-vue-next'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
defineProps<{
|
||||
action: 'get' | 'installing' | 'open'
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="app-store-action"
|
||||
:class="{ 'app-store-action--icon': action !== 'open' }"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CloudDownload
|
||||
v-if="action === 'get'"
|
||||
class="app-store-action__download"
|
||||
:size="24"
|
||||
:stroke-width="1.9"
|
||||
/>
|
||||
<span
|
||||
v-else-if="action === 'installing'"
|
||||
class="app-store-action__progress"
|
||||
>
|
||||
<svg viewBox="0 0 28 28">
|
||||
<circle class="app-store-action__track" cx="14" cy="14" r="11" />
|
||||
<circle class="app-store-action__value" cx="14" cy="14" r="11" />
|
||||
</svg>
|
||||
<i></i>
|
||||
</span>
|
||||
<template v-else>{{ phone.t('Apps.appStore.open') }}</template>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-store-action {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-store-action--icon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
color: var(--sky-app-accent);
|
||||
}
|
||||
|
||||
.app-store-action__download {
|
||||
filter: drop-shadow(
|
||||
0 2px 5px color-mix(in srgb, var(--sky-app-accent) 30%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
.app-store-action__progress {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.app-store-action__progress svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: visible;
|
||||
fill: none;
|
||||
stroke-width: 2.2;
|
||||
}
|
||||
|
||||
.app-store-action__track {
|
||||
stroke: color-mix(in srgb, var(--sky-app-accent) 24%, transparent);
|
||||
}
|
||||
|
||||
.app-store-action__value {
|
||||
stroke: var(--sky-app-accent);
|
||||
stroke-linecap: round;
|
||||
stroke-dasharray: 69.2;
|
||||
stroke-dashoffset: 69.2;
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: 50% 50%;
|
||||
animation: app-store-download-progress 3s linear forwards;
|
||||
}
|
||||
|
||||
.app-store-action__progress i {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 1.5px;
|
||||
background: var(--sky-app-accent);
|
||||
}
|
||||
|
||||
@keyframes app-store-download-progress {
|
||||
from {
|
||||
stroke-dashoffset: 69.2;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 4;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.app-store-action__value {
|
||||
animation: none;
|
||||
stroke-dashoffset: 34.6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -23,7 +23,8 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(source.match(/<SkyScrollArea/g)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders the Apps, Games and Search glass navigation', () => {
|
||||
it('renders the Today, Apps, Games and Search glass navigation', () => {
|
||||
expect(source).toContain("{ id: 'today', icon: Newspaper }")
|
||||
expect(source).toContain("{ id: 'apps', icon: Grid2X2 }")
|
||||
expect(source).toContain("{ id: 'games', icon: Gamepad2 }")
|
||||
expect(source).toContain("{ id: 'search', icon: Search }")
|
||||
@@ -40,8 +41,75 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
expect(navigationSource).not.toContain('compact')
|
||||
})
|
||||
|
||||
it('renders an Apple-style Search landing page and separate results state', () => {
|
||||
expect(source).toContain('const hasSearchQuery = computed')
|
||||
expect(source).toContain('const searchRecommendations = computed')
|
||||
expect(source).toContain('const searchDiscoverCards = computed')
|
||||
expect(source).toContain('v-if="!hasSearchQuery"')
|
||||
expect(source).toContain('class="store-search__recommendations"')
|
||||
expect(source).toContain('class="store-search__discover-grid"')
|
||||
expect(source).toContain(
|
||||
'class="store-list store-list--browse store-list--search-results"',
|
||||
)
|
||||
expect(source).not.toContain('.store-list--search-results article > button')
|
||||
expect(source).not.toContain('<template #suffix>')
|
||||
expect(source).not.toMatch(/\bMic\b/)
|
||||
expect(source).toContain('selectSearchDiscovery(card.app)')
|
||||
expect(source).toMatch(
|
||||
/\.store-search__discover-grid > button\s*\{[^}]*min-height:\s*124px/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('opens profile app management with update and uninstall actions', () => {
|
||||
expect(source).toContain('@click="profileOpened = true"')
|
||||
expect(source).toContain('const installedApps = computed')
|
||||
expect(source).toContain('return !appStore.isInstalled(app.id)')
|
||||
expect(source).toContain('class="store-account__apps"')
|
||||
expect(source).toContain('appStore.updateApp(app.id, currentRelease)')
|
||||
expect(source).toContain(
|
||||
'appStore.uninstallApp(uninstallCandidate.value.id)',
|
||||
)
|
||||
expect(source).toContain('v-if="isPhoneAppRemovable(app)"')
|
||||
expect(source).toContain(':opened="Boolean(uninstallCandidate)"')
|
||||
expect(source).toContain('class="store-account__grabber"')
|
||||
expect(source).toContain('@pointerdown="beginProfileDrag"')
|
||||
expect(source).toContain('@pointermove="moveProfileDrag"')
|
||||
expect(source).toContain('@pointerup="endProfileDrag"')
|
||||
expect(source).toContain('profileDragOffset.value >= 72')
|
||||
expect(source).not.toContain('<X :size="20"')
|
||||
})
|
||||
|
||||
it('opens App Store details from app and game lists without replacing direct actions', () => {
|
||||
expect(source).toContain(
|
||||
"import AppStoreDetail from './AppStoreDetail.vue'",
|
||||
)
|
||||
expect(source).toContain(
|
||||
'const selectedApp = ref<LaunchablePhoneAppDefinition | null>(null)',
|
||||
)
|
||||
expect(source).toContain('<AppStoreDetail')
|
||||
expect(source).toContain('@click="openAppDetail(app)"')
|
||||
expect(source).toContain('@action="handleApp(selectedApp)"')
|
||||
expect(source).toContain('@back="selectedApp = null"')
|
||||
expect(source).toContain('@click="selectStoreTab(item.id)"')
|
||||
expect(source).toContain('scrollElement?.scrollTo({ top: 0 })')
|
||||
})
|
||||
|
||||
it('shares app details through EasyShare and restores shared detail links', () => {
|
||||
expect(source).toContain(
|
||||
"import { useEasyShareStore } from '@/stores/easyshare'",
|
||||
)
|
||||
expect(source).toContain("appId: 'app-store'")
|
||||
expect(source).toContain("kind: 'link'")
|
||||
expect(source).toContain('link: `skyphone://app-store/${app.id}`')
|
||||
expect(source).toContain('easyShare.open({')
|
||||
expect(source).toContain('route.query.easyShareId')
|
||||
expect(source).not.toContain('shareToastOpened')
|
||||
})
|
||||
|
||||
it('keeps one scroll owner and accessible 44px app actions', () => {
|
||||
expect(source).toContain('<SkyScrollArea class="store-scroll" with-tabbar>')
|
||||
expect(source).toMatch(
|
||||
/<SkyScrollArea\s+ref="storeScroll"\s+class="store-scroll"[\s\S]*?with-tabbar\s*>/,
|
||||
)
|
||||
expect(source).toMatch(/\.store-scroll\s*\{[^}]*overflow-y:\s*auto/s)
|
||||
expect(source).not.toMatch(/\.store-scroll\s*\{[^}]*padding\s*:/s)
|
||||
expect(source).toMatch(/\.store-scroll\s*\{[^}]*padding-top:\s*0/s)
|
||||
@@ -52,11 +120,101 @@ describe('AppStoreApp Sky navigation contract', () => {
|
||||
/\.store-scroll\s*\{[^}]*padding-left:\s*calc\(var\(--sky-page-gutter\) \+ var\(--sky-safe-area-left\)\)/s,
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-list article > button\s*\{[^}]*min-height:\s*var\(--sky-touch-target\)/s,
|
||||
/\.store-list article > button:not\(\.store-list__detail-link\)\s*\{[^}]*min-height:\s*var\(--sky-touch-target\)/s,
|
||||
)
|
||||
expect(source).toContain(':clear-label="phone.t(\'Common.clear\')"')
|
||||
expect(source).toContain(
|
||||
':label="phone.t(\'Apps.appStore.searchPlaceholder\')"',
|
||||
)
|
||||
expect(source).toContain(
|
||||
':class="{ \'store-scroll--detail\': selectedApp }"',
|
||||
)
|
||||
expect(source).toMatch(
|
||||
/\.store-scroll--detail\s*\{[^}]*margin-top:\s*var\(--sky-safe-area-top\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('gives interactive download, update and remove buttons subtle pointer feedback', () => {
|
||||
expect(source).toContain('@media (hover: hover) and (pointer: fine)')
|
||||
expect(source).toMatch(
|
||||
/button:not\(\.store-ranking__detail-link\):not\(:disabled\):hover\)\s*\{[^}]*brightness\(1\.08\)[^}]*translateY\(-1px\)/s,
|
||||
)
|
||||
expect(source).toContain(
|
||||
'.app-store-page :deep(button:has(.app-store-action--icon):hover)',
|
||||
)
|
||||
expect(source).toContain(
|
||||
'.store-account__primary-action:not(:disabled):hover',
|
||||
)
|
||||
expect(source).toContain('.store-account__remove:not(:disabled):hover')
|
||||
expect(source).toContain('@media (prefers-reduced-motion: reduce)')
|
||||
})
|
||||
|
||||
it('builds daily Today cards from non-standard internal apps', () => {
|
||||
expect(source).toContain(
|
||||
"ref<'today' | 'apps' | 'games' | 'search'>('today')",
|
||||
)
|
||||
expect(source).toContain('getDailyHighlights(')
|
||||
expect(source).toContain('!DEFAULT_INSTALLED_PHONE_APP_IDS.has(app.id)')
|
||||
expect(source).toContain('v-else-if="tab === \'today\'"')
|
||||
expect(source).toContain('class="store-highlight store-highlight--hero"')
|
||||
expect(source).toContain('class="store-highlight store-highlight--compact"')
|
||||
expect(source).toContain('class="store-today__edition"')
|
||||
expect(source).toContain('class="store-ranking"')
|
||||
expect(source).toContain('class="store-final-pick"')
|
||||
expect(source).toContain('editorialHighlights')
|
||||
expect(source).toContain('topToday')
|
||||
expect(source).toContain('var(--sky-touch-target)')
|
||||
})
|
||||
|
||||
it('opens app details from every Today banner without hijacking app actions', () => {
|
||||
expect(source.match(/class="store-highlight__detail-link"/g)).toHaveLength(
|
||||
3,
|
||||
)
|
||||
expect(source).toContain('@click="openAppDetail(dailyHighlights[0])"')
|
||||
expect(source).toContain('@click="openAppDetail(app)"')
|
||||
expect(source).toContain('@click="openAppDetail(finalHighlight)"')
|
||||
expect(source).toContain('@click.stop="handleApp(dailyHighlights[0])"')
|
||||
expect(source).toContain('@click.stop="handleApp(finalHighlight)"')
|
||||
expect(source).toContain(
|
||||
'.store-highlight:has(.store-highlight__detail-link:hover)',
|
||||
)
|
||||
})
|
||||
|
||||
it('opens Top Today apps while keeping their direct app actions separate', () => {
|
||||
expect(source).toContain('class="store-ranking__detail-link"')
|
||||
expect(source).toContain('@click="openAppDetail(app)"')
|
||||
expect(source).toContain('@click.stop="handleApp(app)"')
|
||||
expect(source).toContain(
|
||||
'.store-ranking li > button:not(.store-ranking__detail-link)',
|
||||
)
|
||||
expect(source).not.toContain('.store-ranking__detail-link:hover')
|
||||
expect(source).toContain(
|
||||
'button:not(.store-ranking__detail-link):not(:disabled):hover',
|
||||
)
|
||||
})
|
||||
|
||||
it('builds clean Apps and Games pages with rotating features', () => {
|
||||
expect(source).not.toContain('class="store-browse__filters"')
|
||||
expect(source).not.toContain('browseFilter')
|
||||
expect(source).toContain('class="store-browse-feature"')
|
||||
expect(source).toContain('class="store-list store-list--browse"')
|
||||
expect(source).toContain('featuredCandidates')
|
||||
expect(source).toContain('globalThis.setInterval')
|
||||
expect(source).toContain('}, 6500)')
|
||||
expect(source).toContain('globalThis.clearInterval')
|
||||
expect(source).toMatch(
|
||||
/\.store-browse__pages button\s*\{[^}]*width:\s*var\(--sky-touch-target\)[^}]*height:\s*var\(--sky-touch-target\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('matches the Photos large header with the player initials profile', () => {
|
||||
expect(source).toContain('class="app-store-navbar"')
|
||||
expect(source).toContain(':scroll-el="null"')
|
||||
expect(source).toContain('<template #right>')
|
||||
expect(source).toContain('class="app-store-profile"')
|
||||
expect(source).toContain('phone.player.firstName')
|
||||
expect(source).toContain('phone.player.lastName')
|
||||
expect(source).toContain('{{ profileInitials }}')
|
||||
expect(source).toContain('var(--sky-navbar-large-title-height) - 30px')
|
||||
})
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const source = readFileSync(
|
||||
fileURLToPath(new URL('./AppStoreDetail.vue', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
describe('AppStoreDetail contract', () => {
|
||||
it('renders an Apple-style product page with metadata and direct action', () => {
|
||||
expect(source).toContain('class="store-detail__toolbar"')
|
||||
expect(source).toContain('class="store-detail__hero"')
|
||||
expect(source).toContain('class="store-detail__facts"')
|
||||
expect(source).toContain('class="store-detail__whats-new"')
|
||||
expect(source).toContain("emit('action')")
|
||||
expect(source).toContain("emit('back')")
|
||||
expect(source).toContain("emit('share')")
|
||||
expect(source).toContain('<AppStoreAction :action="action" />')
|
||||
expect(source).toMatch(
|
||||
/\.store-detail\s*\{[^}]*padding:\s*var\(--sky-space-3\) 0 var\(--sky-space-6\)/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('generates three app-specific preview panels with visible test data', () => {
|
||||
expect(source).toContain('class="store-detail__previews"')
|
||||
expect(source).toContain('store-detail-preview--overview')
|
||||
expect(source).toContain('store-detail-preview--community')
|
||||
expect(source).toContain('store-detail-preview--insights')
|
||||
expect(
|
||||
source.match(/:src="app\.iconImage"/g)?.length,
|
||||
).toBeGreaterThanOrEqual(4)
|
||||
expect(source).toContain('24')
|
||||
expect(source).toContain('87%')
|
||||
expect(source).toContain('+18%')
|
||||
expect(source).toMatch(
|
||||
/\.store-detail-preview\s*\{[^}]*width:\s*224px[^}]*height:\s*354px/s,
|
||||
)
|
||||
})
|
||||
|
||||
it('provides accessible previous and next controls for the preview gallery', () => {
|
||||
expect(source).toContain('ref="previews"')
|
||||
expect(source).toContain('@scroll.passive="updateActivePreview"')
|
||||
expect(source).toContain('scrollToPreview(activePreviewIndex - 1)')
|
||||
expect(source).toContain('scrollToPreview(activePreviewIndex + 1)')
|
||||
expect(source).toContain('details.previousPreview')
|
||||
expect(source).toContain('details.nextPreview')
|
||||
expect(source).toContain('.store-detail__toolbar button:hover')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,760 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Activity,
|
||||
BarChart3,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Share2,
|
||||
Sparkles,
|
||||
Star,
|
||||
Users,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { getPhoneAppLabel, isExternalPhoneApp } from '@/config/apps'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { LaunchablePhoneAppDefinition } from '@/types/apps'
|
||||
|
||||
import AppStoreAction from './AppStoreAction.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
action: 'get' | 'installing' | 'open'
|
||||
app: LaunchablePhoneAppDefinition
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
action: []
|
||||
back: []
|
||||
share: []
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const previews = ref<HTMLElement | null>(null)
|
||||
const activePreviewIndex = ref(0)
|
||||
const previewCount = 3
|
||||
const appName = computed(() => getPhoneAppLabel(props.app, phone.t))
|
||||
const developer = computed(() =>
|
||||
isExternalPhoneApp(props.app)
|
||||
? props.app.developer
|
||||
: phone.t('Apps.appStore.details.skyStudios'),
|
||||
)
|
||||
const rating = computed(() => (4.4 + (props.app.gridOrder % 5) / 10).toFixed(1))
|
||||
const ratingCount = computed(() =>
|
||||
new Intl.NumberFormat(phone.lang, { notation: 'compact' }).format(
|
||||
12400 + props.app.gridOrder * 1371,
|
||||
),
|
||||
)
|
||||
const ageRating = computed(() =>
|
||||
props.app.category === 'games' ? '12+' : '4+',
|
||||
)
|
||||
const chartRank = computed(() => `#${(props.app.gridOrder % 8) + 1}`)
|
||||
const version = computed(
|
||||
() => `1.${(props.app.gridOrder % 9) + 1}.${props.app.gridOrder % 5}`,
|
||||
)
|
||||
const detailStyle = computed(() => {
|
||||
const palettes = [
|
||||
['#2365d8', '#69c7ff'],
|
||||
['#7a42c2', '#d19dff'],
|
||||
['#0b8a78', '#60e2c0'],
|
||||
['#cf5b35', '#ffc06b'],
|
||||
['#4057b3', '#9baaff'],
|
||||
] as const
|
||||
const palette = palettes[props.app.gridOrder % palettes.length]!
|
||||
return {
|
||||
'--store-detail-accent': palette[0],
|
||||
'--store-detail-glow': palette[1],
|
||||
}
|
||||
})
|
||||
|
||||
function scrollToPreview(index: number): void {
|
||||
const row = previews.value
|
||||
if (!row) return
|
||||
const cards = Array.from(
|
||||
row.querySelectorAll<HTMLElement>('.store-detail-preview'),
|
||||
)
|
||||
const nextIndex = Math.min(cards.length - 1, Math.max(0, index))
|
||||
const card = cards[nextIndex]
|
||||
if (!card) return
|
||||
activePreviewIndex.value = nextIndex
|
||||
row.scrollTo({
|
||||
behavior: 'smooth',
|
||||
left: card.offsetLeft - row.offsetLeft,
|
||||
})
|
||||
}
|
||||
|
||||
function updateActivePreview(): void {
|
||||
const row = previews.value
|
||||
if (!row) return
|
||||
const cards = Array.from(
|
||||
row.querySelectorAll<HTMLElement>('.store-detail-preview'),
|
||||
)
|
||||
activePreviewIndex.value = cards.reduce((closestIndex, card, index) => {
|
||||
const closest = cards[closestIndex]
|
||||
if (!closest) return index
|
||||
const distance = Math.abs(card.offsetLeft - row.offsetLeft - row.scrollLeft)
|
||||
const closestDistance = Math.abs(
|
||||
closest.offsetLeft - row.offsetLeft - row.scrollLeft,
|
||||
)
|
||||
return distance < closestDistance ? index : closestIndex
|
||||
}, 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="store-detail" :style="detailStyle">
|
||||
<header class="store-detail__toolbar">
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
@click="emit('back')"
|
||||
>
|
||||
<ChevronLeft :size="26" :stroke-width="2.2" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.share')"
|
||||
@click="emit('share')"
|
||||
>
|
||||
<Share2 :size="21" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="store-detail__hero">
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
<div>
|
||||
<h1>{{ appName }}</h1>
|
||||
<p>{{ developer }}</p>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="action === 'installing'"
|
||||
@click="emit('action')"
|
||||
>
|
||||
<AppStoreAction :action="action" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<dl class="store-detail__facts">
|
||||
<div>
|
||||
<dt>
|
||||
{{ phone.t('Apps.appStore.details.ratings', { count: ratingCount }) }}
|
||||
</dt>
|
||||
<dd>{{ rating }}</dd>
|
||||
<span class="store-detail__stars" aria-hidden="true">
|
||||
<Star v-for="star in 5" :key="star" :size="11" fill="currentColor" />
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ phone.t('Apps.appStore.details.age') }}</dt>
|
||||
<dd>{{ ageRating }}</dd>
|
||||
<span>{{ phone.t('Apps.appStore.details.years') }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{{ phone.t('Apps.appStore.details.chart') }}</dt>
|
||||
<dd>{{ chartRank }}</dd>
|
||||
<span>{{ phone.t(`Home.groups.${app.category}`) }}</span>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section class="store-detail__whats-new">
|
||||
<header>
|
||||
<h2>{{ phone.t('Apps.appStore.details.whatsNew') }}</h2>
|
||||
<ChevronRight :size="22" :stroke-width="2.4" aria-hidden="true" />
|
||||
</header>
|
||||
<div class="store-detail__version">
|
||||
<span>{{ phone.t('Apps.appStore.details.version', { version }) }}</span>
|
||||
<span>{{ phone.t('Apps.appStore.details.updatedToday') }}</span>
|
||||
</div>
|
||||
<p>
|
||||
{{
|
||||
phone.t('Apps.appStore.details.releaseNotes', {
|
||||
app: appName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="store-detail__preview-section">
|
||||
<header>
|
||||
<h2>{{ phone.t('Apps.appStore.details.preview') }}</h2>
|
||||
<div class="store-detail__preview-navigation">
|
||||
<span>{{ activePreviewIndex + 1 }} / {{ previewCount }}</span>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.previousPreview')"
|
||||
:disabled="activePreviewIndex === 0"
|
||||
@click="scrollToPreview(activePreviewIndex - 1)"
|
||||
>
|
||||
<ChevronLeft :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:aria-label="phone.t('Apps.appStore.details.nextPreview')"
|
||||
:disabled="activePreviewIndex === previewCount - 1"
|
||||
@click="scrollToPreview(activePreviewIndex + 1)"
|
||||
>
|
||||
<ChevronRight :size="17" :stroke-width="2.4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<div
|
||||
ref="previews"
|
||||
class="store-detail__previews"
|
||||
@scroll.passive="updateActivePreview"
|
||||
>
|
||||
<article class="store-detail-preview store-detail-preview--overview">
|
||||
<div class="store-detail-preview__status">
|
||||
<span>{{ phone.t('Apps.appStore.details.previewLive') }}</span>
|
||||
<Activity :size="14" aria-hidden="true" />
|
||||
</div>
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
<strong>{{ appName }}</strong>
|
||||
<small>{{ phone.t('Apps.appStore.details.previewOverview') }}</small>
|
||||
<div class="store-detail-preview__metrics">
|
||||
<span><b>24</b>{{ phone.t('Apps.appStore.details.today') }}</span>
|
||||
<span
|
||||
><b>87%</b>{{ phone.t('Apps.appStore.details.progress') }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="store-detail-preview__activity">
|
||||
<span></span><span></span><span></span><span></span><span></span>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="store-detail-preview store-detail-preview--community">
|
||||
<div class="store-detail-preview__status">
|
||||
<span>{{ phone.t('Apps.appStore.details.previewCommunity') }}</span>
|
||||
<Users :size="14" aria-hidden="true" />
|
||||
</div>
|
||||
<div class="store-detail-preview__avatars">
|
||||
<span>AM</span><span>JS</span><span>RK</span>
|
||||
</div>
|
||||
<strong>{{
|
||||
phone.t('Apps.appStore.details.communityTitle', { app: appName })
|
||||
}}</strong>
|
||||
<small>{{ phone.t('Apps.appStore.details.communityBody') }}</small>
|
||||
<div class="store-detail-preview__message">
|
||||
<Sparkles :size="15" aria-hidden="true" />
|
||||
<span>{{ phone.t('Apps.appStore.details.featuredMoment') }}</span>
|
||||
</div>
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
</article>
|
||||
|
||||
<article class="store-detail-preview store-detail-preview--insights">
|
||||
<div class="store-detail-preview__status">
|
||||
<span>{{ phone.t('Apps.appStore.details.previewInsights') }}</span>
|
||||
<BarChart3 :size="14" aria-hidden="true" />
|
||||
</div>
|
||||
<strong>{{ phone.t('Apps.appStore.details.weeklyActivity') }}</strong>
|
||||
<div class="store-detail-preview__chart" aria-hidden="true">
|
||||
<span
|
||||
v-for="height in [38, 64, 48, 82, 56, 92, 73]"
|
||||
:key="height"
|
||||
:style="{ height: `${height}%` }"
|
||||
></span>
|
||||
</div>
|
||||
<div class="store-detail-preview__insight">
|
||||
<b>+18%</b>
|
||||
<span>{{ phone.t('Apps.appStore.details.fromLastWeek') }}</span>
|
||||
</div>
|
||||
<img :src="app.iconImage" alt="" draggable="false" />
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="store-detail__description">
|
||||
<h2>{{ phone.t('Apps.appStore.details.about') }}</h2>
|
||||
<p>
|
||||
{{
|
||||
phone.t('Apps.appStore.details.description', {
|
||||
app: appName,
|
||||
category: phone.t(`Home.groups.${app.category}`),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.store-detail {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
overflow-x: hidden;
|
||||
gap: var(--sky-space-5);
|
||||
padding: var(--sky-space-3) 0 var(--sky-space-6);
|
||||
color: var(--sky-text);
|
||||
}
|
||||
|
||||
.store-detail__preview-section {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-detail__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.store-detail__toolbar button {
|
||||
width: var(--sky-touch-target);
|
||||
height: var(--sky-touch-target);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: 50%;
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-surface-variant);
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
border-color 100ms ease,
|
||||
box-shadow 100ms ease,
|
||||
transform 100ms ease;
|
||||
}
|
||||
|
||||
.store-detail__toolbar button:active {
|
||||
transform: scale(0.94);
|
||||
}
|
||||
|
||||
.store-detail__hero {
|
||||
display: grid;
|
||||
grid-template-columns: 116px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: var(--sky-space-4);
|
||||
}
|
||||
|
||||
.store-detail__hero > img {
|
||||
width: 116px;
|
||||
height: 116px;
|
||||
border-radius: calc(var(--sky-radius-card) + var(--sky-space-1));
|
||||
object-fit: cover;
|
||||
box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.store-detail__hero > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.store-detail__hero h1 {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--sky-text);
|
||||
font-size: 24px;
|
||||
line-height: 1.02;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.store-detail__hero p {
|
||||
margin: 6px 0 12px;
|
||||
overflow: hidden;
|
||||
color: var(--sky-muted);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail__hero button {
|
||||
min-width: 72px;
|
||||
min-height: var(--sky-touch-target);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 0;
|
||||
border-radius: var(--sky-radius-pill);
|
||||
padding: 0 var(--sky-space-4);
|
||||
color: #fff;
|
||||
background: var(--sky-app-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.store-detail__hero button:has(.app-store-action--icon) {
|
||||
width: var(--sky-touch-target);
|
||||
min-width: var(--sky-touch-target);
|
||||
padding: 0;
|
||||
color: var(--sky-app-accent);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.store-detail__facts {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--sky-hairline);
|
||||
border-bottom: 1px solid var(--sky-hairline);
|
||||
padding: var(--sky-space-3) 0;
|
||||
}
|
||||
|
||||
.store-detail__facts > div {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid var(--sky-hairline);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-detail__facts > div:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.store-detail__facts dt {
|
||||
overflow: hidden;
|
||||
color: var(--sky-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.04em;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail__facts dd {
|
||||
margin: 5px 0 0;
|
||||
color: var(--sky-text);
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.store-detail__facts span {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--sky-muted);
|
||||
font-size: 10px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.store-detail__facts .store-detail__stars {
|
||||
display: flex;
|
||||
color: var(--sky-muted);
|
||||
}
|
||||
|
||||
.store-detail__whats-new,
|
||||
.store-detail__description {
|
||||
border-bottom: 1px solid var(--sky-hairline);
|
||||
padding-bottom: var(--sky-space-5);
|
||||
}
|
||||
|
||||
.store-detail__whats-new header,
|
||||
.store-detail__preview-section > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--sky-space-2);
|
||||
}
|
||||
|
||||
.store-detail__whats-new h2,
|
||||
.store-detail__preview-section h2,
|
||||
.store-detail__description h2 {
|
||||
margin: 0;
|
||||
font-size: var(--sky-font-medium-title);
|
||||
}
|
||||
|
||||
.store-detail__version {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--sky-space-3);
|
||||
margin-top: var(--sky-space-3);
|
||||
color: var(--sky-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.store-detail__whats-new p,
|
||||
.store-detail__description p {
|
||||
margin: var(--sky-space-3) 0 0;
|
||||
color: var(--sky-text);
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.store-detail__preview-navigation {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.store-detail__preview-navigation > span {
|
||||
min-width: 30px;
|
||||
color: var(--sky-muted);
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.store-detail__preview-navigation button {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--sky-hairline);
|
||||
border-radius: 50%;
|
||||
padding: 0;
|
||||
color: var(--sky-text);
|
||||
background: var(--sky-surface-variant);
|
||||
transition:
|
||||
background-color 100ms ease,
|
||||
color 100ms ease,
|
||||
transform 100ms ease;
|
||||
}
|
||||
|
||||
.store-detail__preview-navigation button:disabled {
|
||||
opacity: 0.34;
|
||||
}
|
||||
|
||||
.store-detail__preview-navigation button:active:not(:disabled) {
|
||||
transform: scale(0.92);
|
||||
}
|
||||
|
||||
.store-detail__previews {
|
||||
display: flex;
|
||||
gap: var(--sky-space-3);
|
||||
margin-right: calc(0px - var(--sky-page-gutter));
|
||||
overflow-x: auto;
|
||||
padding: var(--sky-space-3) var(--sky-page-gutter) var(--sky-space-2) 0;
|
||||
scroll-snap-type: x mandatory;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.store-detail__previews::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.store-detail-preview {
|
||||
width: 224px;
|
||||
height: 354px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
flex: 0 0 224px;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: var(--sky-radius-card);
|
||||
padding: var(--sky-space-4);
|
||||
color: #fff;
|
||||
background:
|
||||
radial-gradient(
|
||||
circle at 78% 12%,
|
||||
var(--store-detail-glow),
|
||||
transparent 32%
|
||||
),
|
||||
linear-gradient(155deg, var(--store-detail-accent), #101523 118%);
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.18);
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.store-detail-preview::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: repeating-linear-gradient(
|
||||
-35deg,
|
||||
transparent 0 20px,
|
||||
rgba(255, 255, 255, 0.025) 21px 22px
|
||||
);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.store-detail-preview__status {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.store-detail-preview--overview > img {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
z-index: 1;
|
||||
align-self: center;
|
||||
margin: 26px 0 16px;
|
||||
border-radius: 24px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 14px 24px rgba(0, 0, 0, 0.28);
|
||||
transform: rotate(-4deg);
|
||||
}
|
||||
|
||||
.store-detail-preview > strong,
|
||||
.store-detail-preview > small {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.store-detail-preview > strong {
|
||||
font-size: 20px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
|
||||
.store-detail-preview > small {
|
||||
margin-top: 5px;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.store-detail-preview__metrics {
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--sky-space-2);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.store-detail-preview__metrics span {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: var(--sky-radius-control);
|
||||
padding: var(--sky-space-3);
|
||||
background: rgba(255, 255, 255, 0.11);
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.store-detail-preview__metrics b {
|
||||
color: #fff;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.store-detail-preview__activity {
|
||||
z-index: 1;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 5px;
|
||||
margin-top: var(--sky-space-3);
|
||||
}
|
||||
|
||||
.store-detail-preview__activity span {
|
||||
width: 20%;
|
||||
border-radius: var(--sky-radius-pill);
|
||||
background: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
|
||||
.store-detail-preview__activity span:nth-child(1) {
|
||||
height: 42%;
|
||||
}
|
||||
.store-detail-preview__activity span:nth-child(2) {
|
||||
height: 72%;
|
||||
}
|
||||
.store-detail-preview__activity span:nth-child(3) {
|
||||
height: 55%;
|
||||
}
|
||||
.store-detail-preview__activity span:nth-child(4) {
|
||||
height: 92%;
|
||||
}
|
||||
.store-detail-preview__activity span:nth-child(5) {
|
||||
height: 68%;
|
||||
}
|
||||
|
||||
.store-detail-preview__avatars {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
margin: 42px 0 22px;
|
||||
}
|
||||
|
||||
.store-detail-preview__avatars span {
|
||||
width: 54px;
|
||||
height: 54px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
margin-right: -12px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.82);
|
||||
border-radius: 50%;
|
||||
background: rgba(17, 22, 38, 0.74);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.store-detail-preview__message {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--sky-space-2);
|
||||
margin-top: auto;
|
||||
border-radius: var(--sky-radius-control);
|
||||
padding: var(--sky-space-3);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.store-detail-preview--community > img,
|
||||
.store-detail-preview--insights > img {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
z-index: 1;
|
||||
position: absolute;
|
||||
right: var(--sky-space-4);
|
||||
bottom: var(--sky-space-4);
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 8px 14px rgba(0, 0, 0, 0.24);
|
||||
}
|
||||
|
||||
.store-detail-preview--insights > strong {
|
||||
margin-top: 36px;
|
||||
}
|
||||
|
||||
.store-detail-preview__chart {
|
||||
height: 145px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 7px;
|
||||
margin-top: var(--sky-space-5);
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.3);
|
||||
padding: 0 var(--sky-space-2);
|
||||
}
|
||||
|
||||
.store-detail-preview__chart span {
|
||||
width: 14px;
|
||||
border-radius: 7px 7px 0 0;
|
||||
background: linear-gradient(180deg, #fff, rgba(255, 255, 255, 0.24));
|
||||
}
|
||||
|
||||
.store-detail-preview__insight {
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--sky-space-4);
|
||||
}
|
||||
|
||||
.store-detail-preview__insight b {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.store-detail-preview__insight span {
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
:global(.phone-app--performance) .store-detail-preview,
|
||||
:global(.phone-app--performance) .store-detail__hero > img {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.store-detail__toolbar button:hover {
|
||||
border-color: rgba(255, 255, 255, 0.16);
|
||||
background: var(--sky-surface-tint);
|
||||
box-shadow: 0 7px 16px rgba(0, 0, 0, 0.18);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.store-detail__preview-navigation button:hover:not(:disabled) {
|
||||
color: var(--sky-app-accent);
|
||||
background: var(--sky-surface-tint);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.store-detail__previews {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -8125,6 +8125,10 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
},
|
||||
memos: mockMemos,
|
||||
notes: mockNotes,
|
||||
player: {
|
||||
firstName: 'Alex',
|
||||
lastName: 'Morgan',
|
||||
},
|
||||
security: mockSecurity,
|
||||
token: 'development',
|
||||
},
|
||||
|
||||
@@ -1674,10 +1674,63 @@ Locales["en"] = {
|
||||
},
|
||||
appStore = {
|
||||
name = "App Store", get = "GET", open = "OPEN", installing = "Installing",
|
||||
searchPlaceholder = "Search apps and games", appsTitle = "Built-in Apps", gamesTitle = "Games",
|
||||
player = "Player", profileLabel = "{name} profile",
|
||||
searchPlaceholder = "Games, apps, stories and more", appsTitle = "Built-in Apps", gamesTitle = "Games",
|
||||
selected = "Apps available for your Sky Phone",
|
||||
tabs = { apps = "Apps", games = "Games", search = "Search" },
|
||||
},
|
||||
tabs = { today = "Today", apps = "Apps", games = "Games", search = "Search" },
|
||||
today = {
|
||||
freshDaily = "A fresh selection every day", dailyEdition = "Daily Edition",
|
||||
featured = "Featured today",
|
||||
gameHighlight = "Game highlight", appHighlight = "App highlight",
|
||||
curatedForYou = "Curated for you", editorsChoice = "Editor's Choice",
|
||||
discoverTitle = "Discover {app}", playTitle = "Play something new with {app}",
|
||||
description = "{app} is in the spotlight today on Sky Phone.",
|
||||
categoryDescription = "{category} · Made for Sky Phone",
|
||||
moreHighlights = "Today's Highlights", topToday = "Top Today",
|
||||
topTodayDescription = "Popular picks from today’s edition",
|
||||
oneMoreThing = "One more thing",
|
||||
},
|
||||
filters = {
|
||||
all = "All", social = "Social", utilities = "Utilities", shopping = "Shopping",
|
||||
productivity = "Productivity", arcade = "Arcade", puzzle = "Puzzle", classic = "Classics",
|
||||
},
|
||||
browse = {
|
||||
categories = "Store categories", availableNow = "Available now",
|
||||
featureTitle = "Discover {app}", featuredPages = "Featured apps",
|
||||
handPicked = "Hand-picked for you", essentialApps = "Essential Apps",
|
||||
essentialGames = "Essential Games",
|
||||
},
|
||||
search = {
|
||||
recommended = "Recommended", discover = "Discover", results = "Search Results",
|
||||
noResults = "No matching apps or games found.", ad = "Ad",
|
||||
topApps = "Top Apps", topGames = "Top Games", bestApps = "Bestselling Apps",
|
||||
bestGames = "Bestselling Games", productivity = "Productivity", photoVideo = "Photo & Video",
|
||||
},
|
||||
account = {
|
||||
account = "Account", title = "App Management", skyAccount = "Sky Phone Account",
|
||||
apps = "Installed Apps", games = "Games", library = "Your Library", myApps = "My Apps",
|
||||
update = "UPDATE", uninstall = "Uninstall", uninstallApp = "Uninstall {app}",
|
||||
uninstallTitle = "Uninstall this app?",
|
||||
uninstallBody = "{app} will be removed from this phone. You can download it again from the App Store.",
|
||||
},
|
||||
details = {
|
||||
skyStudios = "Sky Studios", share = "Share app", openDetails = "View {app}",
|
||||
shareReady = "App information is ready to share.", ratings = "{count} ratings",
|
||||
shareCopy = "Take a look at {app} in the App Store.",
|
||||
age = "Age Rating", years = "Years", chart = "Chart", whatsNew = "What's New",
|
||||
version = "Version {version}", updatedToday = "Today",
|
||||
releaseNotes = "{app} now includes fresh content, faster loading and several interface improvements.",
|
||||
preview = "Preview", testData = "Preview data", previewLive = "Live Preview",
|
||||
previousPreview = "Previous preview", nextPreview = "Next preview",
|
||||
previewOverview = "Everything important at a glance", previewCommunity = "Community",
|
||||
previewInsights = "Insights", today = "Today", progress = "Progress",
|
||||
communityTitle = "Together in {app}",
|
||||
communityBody = "Discover activity from players around Los Santos.",
|
||||
featuredMoment = "Featured moment from today", weeklyActivity = "Weekly Activity",
|
||||
fromLastWeek = "from last week", about = "About this App",
|
||||
description = "{app} is a detailed {category} experience made for Sky Phone. Explore its features, connect with the city and keep everything close at hand.",
|
||||
},
|
||||
},
|
||||
settings = {
|
||||
name = "Settings", searchPlaceholder = "Search", airplaneMode = "Airplane Mode", streamerMode = "Streamer Mode",
|
||||
wallpaper = "Wallpaper", on = "On", off = "Off", accountName = "Sky Cloud",
|
||||
|
||||
@@ -518,6 +518,10 @@ local function bootstrap(source, security, security_loaded)
|
||||
} or nil,
|
||||
notes = SkyPhoneNotes.List(device.account_id, device.imei),
|
||||
memos = SkyPhoneMemos.List(device.account_id, device.imei),
|
||||
player = {
|
||||
firstName = trim(Bridge.Framework.GetFirstname(source)) or "",
|
||||
lastName = trim(Bridge.Framework.GetLastname(source)) or "",
|
||||
},
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
Reference in New Issue
Block a user