ENH - add authentic App Store previews

This commit is contained in:
smx.pusha
2026-08-19 09:12:13 +02:00
parent ba37777435
commit 3f91fd3ab7
49 changed files with 733 additions and 293 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+61
View File
@@ -2390,6 +2390,8 @@ const defaultLocales: LocaleTree = {
essentialGames: 'Essential Games',
},
taglines: {
citywarn: 'Live city alerts and public safety updates',
crypto: 'Synthetic markets, portfolio and wallet transfers',
health: 'Health, activity and medical information',
'weazel-news': 'Local news and city stories',
companies: 'Businesses, jobs and services',
@@ -2429,6 +2431,48 @@ const defaultLocales: LocaleTree = {
citymarkt: 'Local marketplace and listings',
'neon-drop': 'Neon block-dropping puzzle',
},
previews: {
citywarn: { first: 'Live alerts', second: 'Safety zones', third: 'Incident updates' },
crypto: { first: 'Synthetic markets', second: 'Portfolio', third: 'Wallet transfers' },
health: { first: 'Activity rings', second: 'Medical ID', third: 'Health records' },
'weazel-news': { first: 'Top stories', second: 'Local reports', third: 'Breaking news' },
companies: { first: 'Business directory', second: 'Job requests', third: 'Services' },
music: { first: 'Now playing', second: 'Playlists', third: 'Music library' },
picstagram: { first: 'Photo feed', second: 'Stories', third: 'Profiles' },
feather: { first: 'Short posts', second: 'Following feed', third: 'Conversations' },
fliptok: { first: 'Video feed', second: 'Creator tools', third: 'Trends' },
flare: { first: 'Discover people', second: 'Matches', third: 'Live moments' },
calendar: { first: 'Upcoming events', second: 'Day planner', third: 'Reminders' },
radio: { first: 'Live channels', second: 'Team radio', third: 'Favorites' },
'local-pages': { first: 'Local pages', second: 'Reviews', third: 'City discovery' },
crewlink: { first: 'Crew roster', second: 'Shared locations', third: 'Coordination' },
phone: { first: 'Recent calls', second: 'Contacts', third: 'Voicemail' },
messages: { first: 'Conversations', second: 'Media sharing', third: 'Quick replies' },
darkchat: { first: 'Private chats', second: 'Secure groups', third: 'Invitations' },
garage: { first: 'Vehicle list', second: 'Parking locations', third: 'Valet' },
house: { first: 'Property access', second: 'Residents', third: 'Management' },
map: { first: 'Live navigation', second: 'Nearby places', third: 'Route guidance' },
skyride: { first: 'Ride booking', second: 'Driver tracking', third: 'Trip history' },
banking: { first: 'Account balance', second: 'Transfers', third: 'Transactions' },
billing: { first: 'Open invoices', second: 'Payment requests', third: 'Payment history' },
mail: { first: 'Inbox', second: 'Attachments', third: 'Mailboxes' },
notes: { first: 'Notes', second: 'Checklists', third: 'Pinned ideas' },
memos: { first: 'Voice recordings', second: 'Playback', third: 'Favorites' },
calculator: { first: 'Basic calculation', second: 'Scientific tools', third: 'History' },
camera: { first: 'Photo mode', second: 'Video capture', third: 'Zoom controls' },
clock: { first: 'World clock', second: 'Alarms', third: 'Timers' },
weather: { first: 'Current weather', second: 'Hourly forecast', third: 'Seven-day outlook' },
photos: { first: 'Media library', second: 'Albums', third: 'Shared media' },
settings: { first: 'Device controls', second: 'Privacy', third: 'Personalization' },
snake: { first: 'High score', second: 'Speed', third: 'Classic grid' },
memory: { first: 'Matched pairs', second: 'Best time', third: 'Card themes' },
'number-merge': { first: 'Highest tile', second: 'Score', third: 'Strategy grid' },
minesweeper: { first: 'Mine counter', second: 'Best time', third: 'Difficulty' },
'tower-stack': { first: 'Tower height', second: 'Perfect drops', third: 'High score' },
'sky-flappy': { first: 'Flight score', second: 'Best run', third: 'Obstacles' },
citymarkt: { first: 'Listings', second: 'Categories', third: 'Saved offers' },
'neon-drop': { first: 'Lines cleared', second: 'Level', third: 'Neon pieces' },
},
search: {
recommended: 'Recommended',
discover: 'Discover',
@@ -2491,6 +2535,23 @@ const defaultLocales: LocaleTree = {
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.',
crypto: {
releaseNotes:
'VaultX now offers clearer portfolio performance, live synthetic market updates and improved wallet transfers with server-validated quotes.',
previewOverview: 'Portfolio, balance and performance at a glance',
previewCommunity: 'Markets',
markets: 'Markets',
serverPrices: 'Server prices',
communityTitle: 'Trade synthetic markets',
communityBody:
'Follow fictional assets, review short-lived quotes and trade against available exchange liquidity.',
featuredMoment: 'Live market movers',
previewInsights: 'Portfolio',
weeklyActivity: 'Simulated performance',
fromLastWeek: 'fictional weekly portfolio change',
description:
"VaultX is Sky Phone's fictional crypto exchange and wallet. Track synthetic markets, manage your character-bound portfolio, deposit or withdraw funds, trade with short-lived server quotes and transfer assets using public VaultX keys. All prices are fictional and have no real-world value.",
},
},
},
phone: {
@@ -0,0 +1,28 @@
import type { BuiltinPhoneAppId } from '@/types/apps'
type PreviewableBuiltinAppId = Exclude<BuiltinPhoneAppId, 'app-store'>
const previewModules = import.meta.glob<string>(
'../assets/img/app-previews/*.jpg',
{
eager: true,
import: 'default',
query: '?url',
},
)
const APP_STORE_PREVIEW_IMAGES = Object.fromEntries(
Object.entries(previewModules).map(([path, imageUrl]) => {
const appId = path.split('/').at(-1)?.replace(/\.jpg$/, '')
if (!appId) throw new Error(`Invalid App Store preview path: ${path}`)
return [appId, imageUrl]
}),
) as Partial<Record<PreviewableBuiltinAppId, string>>
export function getAppStorePreviewImage(appId: string): string | null {
return APP_STORE_PREVIEW_IMAGES[appId as PreviewableBuiltinAppId] ?? null
}
export const APP_STORE_PREVIEW_IMAGE_IDS = Object.freeze(
Object.keys(APP_STORE_PREVIEW_IMAGES) as PreviewableBuiltinAppId[],
)
@@ -0,0 +1,68 @@
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { PHONE_APPS } from '@/config/apps'
import { APP_STORE_PREVIEW_IMAGE_IDS } from '@/utils/appStorePreviewImages'
import {
getAppStorePreviewVisual,
PREVIEWABLE_BUILTIN_APP_IDS,
} from '@/utils/appStorePreviews'
describe('App Store preview catalog', () => {
it('contains a real captured screenshot for every built-in store app', () => {
const storeAppIds = PHONE_APPS.filter(
(app) => app.kind !== 'external' && app.id !== 'app-store',
).map((app) => app.id)
expect([...APP_STORE_PREVIEW_IMAGE_IDS].sort()).toEqual(storeAppIds.sort())
})
it('provides specialized preview data for every built-in store app', () => {
const storeAppIds = PHONE_APPS.filter(
(app) => app.kind !== 'external' && app.id !== 'app-store',
).map((app) => app.id)
expect([...PREVIEWABLE_BUILTIN_APP_IDS].sort()).toEqual(
storeAppIds.sort(),
)
const visualSignatures = new Set<string>()
for (const appId of storeAppIds) {
const preview = getAppStorePreviewVisual(appId)
visualSignatures.add(`${preview.accent}:${preview.surface}`)
}
expect(visualSignatures.size).toBe(storeAppIds.length)
})
it('localizes every specialized preview in the fallback, English and German locales', () => {
const localeSources = [
readFileSync(
fileURLToPath(new URL('../stores/phone.ts', import.meta.url)),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/en.lua', import.meta.url),
),
'utf8',
),
readFileSync(
fileURLToPath(
new URL('../../../sky_phone/config/locales/de.lua', import.meta.url),
),
'utf8',
),
]
for (const appId of PREVIEWABLE_BUILTIN_APP_IDS) {
for (const source of localeSources) {
expect(source).toMatch(
new RegExp(
`(?:["']${appId}["']\\]?|${appId.replace(/-/g, '\\-')})\\s*[:=]\\s*\\{\\s*first\\s*[:=]`,
),
)
}
}
})
})
+68
View File
@@ -0,0 +1,68 @@
import type { BuiltinPhoneAppId } from '@/types/apps'
export type AppStorePreviewVisual = {
accent: string
surface: string
}
type PreviewableBuiltinAppId = Exclude<BuiltinPhoneAppId, 'app-store'>
const APP_STORE_PREVIEW_STYLES = {
citywarn: { accent: '#ff4f5e', surface: '#250e17' },
crypto: { accent: '#855cff', surface: '#120d26' },
health: { accent: '#ff3b6b', surface: '#250d18' },
'weazel-news': { accent: '#f4bf32', surface: '#201b0c' },
companies: { accent: '#4a92ff', surface: '#0c1728' },
music: { accent: '#fa3d71', surface: '#240b19' },
picstagram: { accent: '#e54cff', surface: '#210b27' },
feather: { accent: '#3c9cff', surface: '#091c2d' },
fliptok: { accent: '#24f0d2', surface: '#071d1b' },
flare: { accent: '#ff567f', surface: '#260d18' },
calendar: { accent: '#ff554d', surface: '#24100d' },
radio: { accent: '#47d9ff', surface: '#071c25' },
'local-pages': { accent: '#47c98a', surface: '#092118' },
crewlink: { accent: '#f3a73a', surface: '#221507' },
phone: { accent: '#35d36f', surface: '#082016' },
messages: { accent: '#42d66d', surface: '#082117' },
darkchat: { accent: '#845eff', surface: '#120c27' },
garage: { accent: '#4d9dff', surface: '#0a1828' },
house: { accent: '#d4a25c', surface: '#21170c' },
map: { accent: '#46c97c', surface: '#091f16' },
skyride: { accent: '#f6c43d', surface: '#211a08' },
banking: { accent: '#3dcc8b', surface: '#092019' },
billing: { accent: '#4aa2ff', surface: '#09192a' },
mail: { accent: '#52a8ff', surface: '#091a2b' },
notes: { accent: '#ffd34e', surface: '#211b08' },
memos: { accent: '#ff4f63', surface: '#250c13' },
calculator: { accent: '#ff9e36', surface: '#231407' },
camera: { accent: '#b7c0cd', surface: '#11151b' },
clock: { accent: '#ff9d32', surface: '#251507' },
weather: { accent: '#54baff', surface: '#091b29' },
photos: { accent: '#ff5f75', surface: '#251015' },
settings: { accent: '#8f99a8', surface: '#141820' },
snake: { accent: '#59e16d', surface: '#071f0c' },
memory: { accent: '#9c6cff', surface: '#160d29' },
'number-merge': { accent: '#f0a451', surface: '#241508' },
minesweeper: { accent: '#7a9dba', surface: '#101a23' },
'tower-stack': { accent: '#ff6f67', surface: '#260e0c' },
'sky-flappy': { accent: '#55cfff', surface: '#081d26' },
citymarkt: { accent: '#ff9a45', surface: '#241407' },
'neon-drop': { accent: '#f244ff', surface: '#240828' },
} satisfies Record<PreviewableBuiltinAppId, AppStorePreviewVisual>
const EXTERNAL_APP_PREVIEW: AppStorePreviewVisual = {
accent: '#4d9dff',
surface: '#0a1828',
}
export function getAppStorePreviewVisual(appId: string): AppStorePreviewVisual {
if (appId === 'app-store') return EXTERNAL_APP_PREVIEW
return (
APP_STORE_PREVIEW_STYLES[appId as PreviewableBuiltinAppId] ??
EXTERNAL_APP_PREVIEW
)
}
export const PREVIEWABLE_BUILTIN_APP_IDS = Object.freeze(
Object.keys(APP_STORE_PREVIEW_STYLES) as PreviewableBuiltinAppId[],
)
@@ -7,6 +7,10 @@ const source = readFileSync(
fileURLToPath(new URL('./AppStoreDetail.vue', import.meta.url)),
'utf8',
)
const previewSource = readFileSync(
fileURLToPath(new URL('./AppStorePreviewCard.vue', import.meta.url)),
'utf8',
)
describe('AppStoreDetail contract', () => {
it('renders an Apple-style product page with metadata and direct action', () => {
@@ -29,24 +33,34 @@ describe('AppStoreDetail contract', () => {
/\.store-detail__hero \.store-detail__action--icon\s*\{[^}]*width:\s*54px;[^}]*min-width:\s*54px;[^}]*min-height:\s*30px;[^}]*height:\s*30px;/s,
)
expect(source).not.toContain(':has(')
expect(previewSource).not.toContain('color-mix(')
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', () => {
it('renders five app-specific screenshot and information panels', () => {
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).toContain('<AppStorePreviewCard')
expect(source).toContain('v-for="screen in previewScreens"')
expect(source).toContain('getAppStorePreviewImage')
expect(source).toContain(':preview-image="previewImage"')
expect(previewSource).toContain('v-if="previewImage && screen < 3"')
expect(previewSource).toContain(':src="previewImage"')
expect(previewSource).toContain('store-detail-preview__screenshot')
expect(previewSource).not.toContain("visual.scene ===")
expect(source).toContain('getAppStorePreviewVisual')
expect(source).toContain('Apps.appStore.previews.${props.app.id}')
expect(previewSource).toContain(':src="iconImage"')
expect(source).toMatch(
/\.store-detail-preview\s*\{[^}]*width:\s*224px[^}]*height:\s*354px/s,
/previewScreens\s*=\s*\[0, 1, 2, 3, 4\] as const/,
)
expect(previewSource).toContain('screen === 3')
expect(previewSource).toContain('screen === 4')
expect(previewSource).toContain('store-detail-preview__details')
expect(previewSource).toContain('store-detail-preview__about')
expect(previewSource).toMatch(
/\.store-detail-preview\s*\{[^}]*width:\s*224px;[^}]*height:\s*354px;/s,
)
})
+43 -282
View File
@@ -1,21 +1,20 @@
<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 { getAppStorePreviewImage } from '@/utils/appStorePreviewImages'
import { getAppStorePreviewVisual } from '@/utils/appStorePreviews'
import AppStoreAction from './AppStoreAction.vue'
import AppStorePreviewCard from './AppStorePreviewCard.vue'
const props = defineProps<{
action: 'get' | 'installing' | 'open'
@@ -31,7 +30,8 @@ const emit = defineEmits<{
const phone = usePhoneStore()
const previews = ref<HTMLElement | null>(null)
const activePreviewIndex = ref(0)
const previewCount = 3
const previewCount = 5
const previewScreens = [0, 1, 2, 3, 4] as const
const appName = computed(() => getPhoneAppLabel(props.app, phone.t))
const developer = computed(() =>
isExternalPhoneApp(props.app)
@@ -51,6 +51,29 @@ const chartRank = computed(() => `#${(props.app.gridOrder % 8) + 1}`)
const version = computed(
() => `1.${(props.app.gridOrder % 9) + 1}.${props.app.gridOrder % 5}`,
)
const detailCopyPrefix = computed(() =>
props.app.id === 'crypto'
? 'Apps.appStore.details.crypto'
: 'Apps.appStore.details',
)
const previewVisual = computed(() => getAppStorePreviewVisual(props.app.id))
const previewImage = computed(() => getAppStorePreviewImage(props.app.id))
const previewFeatures = computed(() => {
if (isExternalPhoneApp(props.app)) {
return [props.app.name, props.app.developer, props.app.description]
}
const prefix = `Apps.appStore.previews.${props.app.id}`
return [
phone.t(`${prefix}.first`),
phone.t(`${prefix}.second`),
phone.t(`${prefix}.third`),
]
})
const appTagline = computed(() =>
isExternalPhoneApp(props.app)
? props.app.description.trim() || props.app.developer
: phone.t(`Apps.appStore.taglines.${props.app.id}`),
)
const detailStyle = computed(() => {
const palettes = [
['#2365d8', '#69c7ff'],
@@ -177,7 +200,7 @@ function updateActivePreview(): void {
</div>
<p>
{{
phone.t('Apps.appStore.details.releaseNotes', {
phone.t(`${detailCopyPrefix}.releaseNotes`, {
app: appName,
})
}}
@@ -212,69 +235,19 @@ function updateActivePreview(): void {
class="store-detail__previews"
@scroll.passive="updateActivePreview"
>
<article
class="store-detail-preview store-detail-preview--overview phone-effect--expensive-shadow"
>
<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 phone-effect--expensive-shadow"
>
<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 phone-effect--expensive-shadow"
>
<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>
<AppStorePreviewCard
v-for="screen in previewScreens"
:key="screen"
:app-name="appName"
:features="previewFeatures"
:icon-image="app.iconImage"
:category-label="phone.t(`Home.groups.${app.category}`)"
:developer="developer"
:preview-image="previewImage"
:screen="screen"
:tagline="appTagline"
:visual="previewVisual"
/>
</div>
</section>
@@ -282,7 +255,7 @@ function updateActivePreview(): void {
<h2>{{ phone.t('Apps.appStore.details.about') }}</h2>
<p>
{{
phone.t('Apps.appStore.details.description', {
phone.t(`${detailCopyPrefix}.description`, {
app: appName,
category: phone.t(`Home.groups.${app.category}`),
})
@@ -546,218 +519,6 @@ function updateActivePreview(): void {
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;
}
@media (hover: hover) {
.store-detail__toolbar button:hover {
border-color: rgba(255, 255, 255, 0.16);
@@ -0,0 +1,334 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { AppStorePreviewVisual } from '@/utils/appStorePreviews'
const props = defineProps<{
appName: string
categoryLabel: string
developer: string
features: readonly string[]
iconImage: string
previewImage: string | null
screen: 0 | 1 | 2 | 3 | 4
tagline: string
visual: AppStorePreviewVisual
}>()
const cardStyle = computed(() => ({
'--preview-accent': props.visual.accent,
'--preview-surface': props.visual.surface,
}))
</script>
<template>
<article
class="store-detail-preview phone-effect--expensive-shadow"
:class="`store-detail-preview--screen-${screen}`"
:style="cardStyle"
>
<img
v-if="previewImage && screen < 3"
class="store-detail-preview__screenshot"
:src="previewImage"
:alt="`${appName}: ${features[screen] ?? appName}`"
draggable="false"
/>
<section v-else-if="screen === 3" class="store-detail-preview__details">
<header>
<img :src="iconImage" alt="" draggable="false" />
<span><strong>{{ appName }}</strong><small>{{ categoryLabel }}</small></span>
</header>
<ul>
<li v-for="(feature, index) in features" :key="feature">
<b>{{ index + 1 }}</b>
<span>{{ feature }}</span>
</li>
</ul>
</section>
<section v-else-if="screen === 4" class="store-detail-preview__about">
<img :src="iconImage" alt="" draggable="false" />
<strong>{{ appName }}</strong>
<p>{{ tagline }}</p>
<dl>
<div><dt>{{ developer }}</dt><dd>{{ categoryLabel }}</dd></div>
</dl>
</section>
<div v-else class="store-detail-preview__fallback">
<img :src="iconImage" alt="" draggable="false" />
<strong>{{ appName }}</strong>
<p>{{ tagline }}</p>
</div>
<footer v-if="screen < 3" class="store-detail-preview__caption">
<img :src="iconImage" alt="" draggable="false" />
<span>
<strong>{{ features[screen] ?? appName }}</strong>
<small>{{ appName }}</small>
</span>
</footer>
</article>
</template>
<style scoped>
.store-detail-preview {
--preview-accent: #4d9dff;
--preview-surface: #0a1828;
position: relative;
width: 224px;
height: 354px;
overflow: hidden;
flex: 0 0 224px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--sky-radius-card);
background: var(--preview-surface);
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.24);
scroll-snap-align: start;
}
.store-detail-preview__screenshot {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
object-position: center top;
transition: transform 180ms ease;
}
.store-detail-preview--screen-1 .store-detail-preview__screenshot {
transform: scale(1.08);
transform-origin: center top;
}
.store-detail-preview--screen-2 .store-detail-preview__screenshot {
transform: scale(1.12);
transform-origin: center bottom;
}
.store-detail-preview__fallback {
height: 100%;
display: grid;
align-content: center;
justify-items: center;
gap: 10px;
padding: 24px;
color: #fff;
text-align: center;
background:
radial-gradient(circle at 80% 12%, var(--preview-accent), transparent 42%),
linear-gradient(155deg, var(--preview-surface), #070a11);
}
.store-detail-preview__fallback img {
width: 72px;
height: 72px;
border-radius: 18px;
}
.store-detail-preview__fallback strong {
font-size: 22px;
}
.store-detail-preview__fallback p {
margin: 0;
color: rgba(255, 255, 255, 0.74);
font-size: 13px;
line-height: 1.45;
}
.store-detail-preview__details,
.store-detail-preview__about {
height: 100%;
position: relative;
display: flex;
flex-direction: column;
padding: 18px;
color: #fff;
background:
radial-gradient(circle at 88% 4%, var(--preview-accent), transparent 42%),
linear-gradient(155deg, var(--preview-surface), #070a11);
}
.store-detail-preview__details::before,
.store-detail-preview__about::before {
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(120deg, rgba(255, 255, 255, 0.06), transparent 42%);
content: '';
pointer-events: none;
}
.store-detail-preview__details header {
z-index: 1;
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
align-items: center;
gap: 11px;
}
.store-detail-preview__details header img {
width: 44px;
height: 44px;
border-radius: 12px;
}
.store-detail-preview__details header span {
min-width: 0;
display: grid;
gap: 3px;
}
.store-detail-preview__details header strong {
overflow: hidden;
font-size: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-detail-preview__details header small,
.store-detail-preview__about dd {
color: rgba(255, 255, 255, 0.66);
font-size: 10px;
}
.store-detail-preview__details ul {
z-index: 1;
display: grid;
gap: 10px;
margin: 28px 0 0;
padding: 0;
list-style: none;
}
.store-detail-preview__details li {
display: grid;
grid-template-columns: 28px minmax(0, 1fr);
align-items: center;
gap: 10px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.07);
}
.store-detail-preview__details li b {
width: 28px;
height: 28px;
display: grid;
place-items: center;
border-radius: 9px;
color: #fff;
background: var(--preview-accent);
font-size: 11px;
}
.store-detail-preview__details li span {
font-size: 11px;
font-weight: 700;
line-height: 1.3;
}
.store-detail-preview__about {
align-items: center;
justify-content: center;
text-align: center;
}
.store-detail-preview__about > img {
z-index: 1;
width: 82px;
height: 82px;
border-radius: 22px;
box-shadow: 0 14px 30px rgba(0, 0, 0, 0.28);
}
.store-detail-preview__about > strong {
z-index: 1;
margin-top: 16px;
font-size: 22px;
}
.store-detail-preview__about > p {
z-index: 1;
margin: 8px 0 18px;
color: rgba(255, 255, 255, 0.76);
font-size: 11px;
line-height: 1.45;
}
.store-detail-preview__about dl {
z-index: 1;
width: 100%;
margin: 0;
}
.store-detail-preview__about dl div {
display: grid;
gap: 3px;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 12px;
padding: 10px;
background: rgba(255, 255, 255, 0.07);
}
.store-detail-preview__about dt,
.store-detail-preview__about dd {
margin: 0;
}
.store-detail-preview__about dt {
font-size: 11px;
font-weight: 800;
}
.store-detail-preview__caption {
position: absolute;
right: 10px;
bottom: 10px;
left: 10px;
display: grid;
grid-template-columns: 32px minmax(0, 1fr);
align-items: center;
gap: 9px;
padding: 8px;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 13px;
color: #fff;
background: rgba(7, 9, 14, 0.84);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.34);
backdrop-filter: blur(12px);
}
.store-detail-preview__caption img {
width: 32px;
height: 32px;
border-radius: 8px;
}
.store-detail-preview__caption span {
min-width: 0;
display: grid;
gap: 2px;
}
.store-detail-preview__caption strong,
.store-detail-preview__caption small {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.store-detail-preview__caption strong {
font-size: 11px;
line-height: 1.2;
}
.store-detail-preview__caption small {
color: rgba(255, 255, 255, 0.62);
font-size: 9px;
}
</style>
+53
View File
@@ -1913,6 +1913,8 @@ Locales["de"] = {
essentialGames = "Unverzichtbare Spiele",
},
taglines = {
citywarn = "Live-Stadtwarnungen und Hinweise zur öffentlichen Sicherheit",
crypto = "Synthetische Märkte, Portfolio und Wallet-Transfers",
health = "Gesundheit, Aktivität und medizinische Informationen",
["weazel-news"] = "Lokale Nachrichten und Stadtgeschichten",
companies = "Unternehmen, Arbeitsplätze und Dienstleistungen", music = "Lieder, Wiedergabelisten und Audio",
@@ -1934,6 +1936,48 @@ Locales["de"] = {
["tower-stack"] = "Präzises Turmbauspiel", ["sky-flappy"] = "Schnelle Arcade-Flugherausforderung",
citymarkt = "Lokaler Marktplatz und Inserate", ["neon-drop"] = "Neon-Blockrätsel",
},
previews = {
citywarn = { first = "Live-Warnungen", second = "Sicherheitszonen", third = "Vorfallmeldungen" },
crypto = { first = "Synthetische Märkte", second = "Portfolio", third = "Wallet-Transfers" },
health = { first = "Aktivitätsringe", second = "Notfallpass", third = "Gesundheitsdaten" },
["weazel-news"] = { first = "Topmeldungen", second = "Lokale Berichte", third = "Eilmeldungen" },
companies = { first = "Unternehmensverzeichnis", second = "Jobanfragen", third = "Dienstleistungen" },
music = { first = "Aktuelle Wiedergabe", second = "Playlists", third = "Musikmediathek" },
picstagram = { first = "Foto-Feed", second = "Stories", third = "Profile" },
feather = { first = "Kurzbeiträge", second = "Abonniert-Feed", third = "Unterhaltungen" },
fliptok = { first = "Video-Feed", second = "Creator-Werkzeuge", third = "Trends" },
flare = { first = "Personen entdecken", second = "Matches", third = "Live-Momente" },
calendar = { first = "Anstehende Termine", second = "Tagesplaner", third = "Erinnerungen" },
radio = { first = "Live-Kanäle", second = "Teamfunk", third = "Favoriten" },
["local-pages"] = { first = "Lokale Seiten", second = "Bewertungen", third = "Stadt entdecken" },
crewlink = { first = "Crew-Mitglieder", second = "Geteilte Standorte", third = "Koordination" },
phone = { first = "Letzte Anrufe", second = "Kontakte", third = "Voicemail" },
messages = { first = "Unterhaltungen", second = "Medien teilen", third = "Schnellantworten" },
darkchat = { first = "Private Chats", second = "Sichere Gruppen", third = "Einladungen" },
garage = { first = "Fahrzeugliste", second = "Parkorte", third = "Parkservice" },
house = { first = "Immobilienzugang", second = "Bewohner", third = "Verwaltung" },
map = { first = "Live-Navigation", second = "Orte in der Nähe", third = "Routenführung" },
skyride = { first = "Fahrt buchen", second = "Fahrer verfolgen", third = "Fahrtenverlauf" },
banking = { first = "Kontostand", second = "Überweisungen", third = "Transaktionen" },
billing = { first = "Offene Rechnungen", second = "Zahlungsanfragen", third = "Zahlungsverlauf" },
mail = { first = "Posteingang", second = "Anhänge", third = "Postfächer" },
notes = { first = "Notizen", second = "Checklisten", third = "Angeheftete Ideen" },
memos = { first = "Sprachaufnahmen", second = "Wiedergabe", third = "Favoriten" },
calculator = { first = "Grundrechenarten", second = "Wissenschaftliche Funktionen", third = "Verlauf" },
camera = { first = "Fotomodus", second = "Videoaufnahme", third = "Zoomsteuerung" },
clock = { first = "Weltuhr", second = "Wecker", third = "Timer" },
weather = { first = "Aktuelles Wetter", second = "Stündliche Vorhersage", third = "Sieben-Tage-Ausblick" },
photos = { first = "Medienmediathek", second = "Alben", third = "Geteilte Medien" },
settings = { first = "Gerätesteuerung", second = "Datenschutz", third = "Personalisierung" },
snake = { first = "Highscore", second = "Geschwindigkeit", third = "Klassisches Raster" },
memory = { first = "Gefundene Paare", second = "Bestzeit", third = "Kartendesigns" },
["number-merge"] = { first = "Höchste Kachel", second = "Punktestand", third = "Strategieraster" },
minesweeper = { first = "Minenzähler", second = "Bestzeit", third = "Schwierigkeit" },
["tower-stack"] = { first = "Turmhöhe", second = "Perfekte Ablagen", third = "Highscore" },
["sky-flappy"] = { first = "Flugpunkte", second = "Bester Lauf", third = "Hindernisse" },
citymarkt = { first = "Angebote", second = "Kategorien", third = "Gespeicherte Angebote" },
["neon-drop"] = { first = "Gelöschte Linien", second = "Level", third = "Neon-Steine" },
},
search = {
recommended = "Empfohlen", discover = "Entdecken", results = "Suchergebnisse",
noResults = "Keine Ergebnisse", noResultsBody = "Versuche nach einer anderen App oder einem anderen Spiel zu suchen.", ad = "Anzeige",
@@ -1963,6 +2007,15 @@ Locales["de"] = {
featuredMoment = "Vorgestellter Moment von heute", weeklyActivity = "Wöchentliche Aktivität",
fromLastWeek = "von letzter Woche", about = "Über diese App",
description = "{app} ist ein umfangreiches {category}-Erlebnis für Sky Phone. Entdecke die Funktionen, verbinde dich mit der Stadt und behalte alles griffbereit.",
crypto = {
releaseNotes = "VaultX bietet jetzt eine klarere Portfolio-Entwicklung, aktuelle synthetische Märkte und verbesserte Wallet-Transfers mit servervalidierten Angeboten.",
previewOverview = "Portfolio, Guthaben und Entwicklung auf einen Blick", previewCommunity = "Märkte",
markets = "Märkte", serverPrices = "Serverkurse", communityTitle = "Synthetische Märkte handeln",
communityBody = "Verfolge fiktive Assets, prüfe kurzlebige Angebote und handle gegen die verfügbare Börsenliquidität.",
featuredMoment = "Aktuelle Marktbewegungen", previewInsights = "Portfolio",
weeklyActivity = "Simulierte Entwicklung", fromLastWeek = "fiktive wöchentliche Portfolio-Änderung",
description = "VaultX ist die fiktive Krypto-Börse und Wallet für Sky Phone. Verfolge synthetische Märkte, verwalte dein charaktergebundenes Portfolio, zahle Guthaben ein oder aus, handle mit kurzlebigen Serverangeboten und übertrage Assets über öffentliche VaultX-Keys. Alle Kurse sind fiktiv und haben keinen realen Gegenwert.",
},
},
},
settings = {
+53
View File
@@ -1913,6 +1913,8 @@ Locales["en"] = {
essentialGames = "Essential Games",
},
taglines = {
citywarn = "Live city alerts and public safety updates",
crypto = "Synthetic markets, portfolio and wallet transfers",
health = "Health, activity and medical information",
["weazel-news"] = "Local news and city stories",
companies = "Businesses, jobs and services", music = "Songs, playlists and audio",
@@ -1934,6 +1936,48 @@ Locales["en"] = {
["tower-stack"] = "Precision tower-building game", ["sky-flappy"] = "Fast arcade flying challenge",
citymarkt = "Local marketplace and listings", ["neon-drop"] = "Neon block-dropping puzzle",
},
previews = {
citywarn = { first = "Live alerts", second = "Safety zones", third = "Incident updates" },
crypto = { first = "Synthetic markets", second = "Portfolio", third = "Wallet transfers" },
health = { first = "Activity rings", second = "Medical ID", third = "Health records" },
["weazel-news"] = { first = "Top stories", second = "Local reports", third = "Breaking news" },
companies = { first = "Business directory", second = "Job requests", third = "Services" },
music = { first = "Now playing", second = "Playlists", third = "Music library" },
picstagram = { first = "Photo feed", second = "Stories", third = "Profiles" },
feather = { first = "Short posts", second = "Following feed", third = "Conversations" },
fliptok = { first = "Video feed", second = "Creator tools", third = "Trends" },
flare = { first = "Discover people", second = "Matches", third = "Live moments" },
calendar = { first = "Upcoming events", second = "Day planner", third = "Reminders" },
radio = { first = "Live channels", second = "Team radio", third = "Favorites" },
["local-pages"] = { first = "Local pages", second = "Reviews", third = "City discovery" },
crewlink = { first = "Crew roster", second = "Shared locations", third = "Coordination" },
phone = { first = "Recent calls", second = "Contacts", third = "Voicemail" },
messages = { first = "Conversations", second = "Media sharing", third = "Quick replies" },
darkchat = { first = "Private chats", second = "Secure groups", third = "Invitations" },
garage = { first = "Vehicle list", second = "Parking locations", third = "Valet" },
house = { first = "Property access", second = "Residents", third = "Management" },
map = { first = "Live navigation", second = "Nearby places", third = "Route guidance" },
skyride = { first = "Ride booking", second = "Driver tracking", third = "Trip history" },
banking = { first = "Account balance", second = "Transfers", third = "Transactions" },
billing = { first = "Open invoices", second = "Payment requests", third = "Payment history" },
mail = { first = "Inbox", second = "Attachments", third = "Mailboxes" },
notes = { first = "Notes", second = "Checklists", third = "Pinned ideas" },
memos = { first = "Voice recordings", second = "Playback", third = "Favorites" },
calculator = { first = "Basic calculation", second = "Scientific tools", third = "History" },
camera = { first = "Photo mode", second = "Video capture", third = "Zoom controls" },
clock = { first = "World clock", second = "Alarms", third = "Timers" },
weather = { first = "Current weather", second = "Hourly forecast", third = "Seven-day outlook" },
photos = { first = "Media library", second = "Albums", third = "Shared media" },
settings = { first = "Device controls", second = "Privacy", third = "Personalization" },
snake = { first = "High score", second = "Speed", third = "Classic grid" },
memory = { first = "Matched pairs", second = "Best time", third = "Card themes" },
["number-merge"] = { first = "Highest tile", second = "Score", third = "Strategy grid" },
minesweeper = { first = "Mine counter", second = "Best time", third = "Difficulty" },
["tower-stack"] = { first = "Tower height", second = "Perfect drops", third = "High score" },
["sky-flappy"] = { first = "Flight score", second = "Best run", third = "Obstacles" },
citymarkt = { first = "Listings", second = "Categories", third = "Saved offers" },
["neon-drop"] = { first = "Lines cleared", second = "Level", third = "Neon pieces" },
},
search = {
recommended = "Recommended", discover = "Discover", results = "Search Results",
noResults = "No Results", noResultsBody = "Try searching for a different app or game.", ad = "Ad",
@@ -1963,6 +2007,15 @@ Locales["en"] = {
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.",
crypto = {
releaseNotes = "VaultX now offers clearer portfolio performance, live synthetic market updates and improved wallet transfers with server-validated quotes.",
previewOverview = "Portfolio, balance and performance at a glance", previewCommunity = "Markets",
markets = "Markets", serverPrices = "Server prices", communityTitle = "Trade synthetic markets",
communityBody = "Follow fictional assets, review short-lived quotes and trade against available exchange liquidity.",
featuredMoment = "Live market movers", previewInsights = "Portfolio",
weeklyActivity = "Simulated performance", fromLastWeek = "fictional weekly portfolio change",
description = "VaultX is Sky Phone's fictional crypto exchange and wallet. Track synthetic markets, manage your character-bound portfolio, deposit or withdraw funds, trade with short-lived server quotes and transfer assets using public VaultX keys. All prices are fictional and have no real-world value.",
},
},
},
settings = {