mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - integrate calendar games and local marketplaces
This commit is contained in:
@@ -4,6 +4,7 @@ import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { PhoneAppDefinition } from '@/types/apps'
|
||||
|
||||
@@ -21,11 +22,14 @@ const props = withDefaults(
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const mail = useMailStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const router = useRouter()
|
||||
const iconFailed = ref(false)
|
||||
const unreadCount = computed(() =>
|
||||
props.app.id === 'mail' ? mail.counts.unread : 0,
|
||||
)
|
||||
const unreadCount = computed(() => {
|
||||
if (props.app.id === 'mail') return mail.counts.unread
|
||||
if (props.app.id === 'citymarkt') return marketplace.counts.unread
|
||||
return 0
|
||||
})
|
||||
const notificationBadgeColors = {
|
||||
bg: 'bg-[#ff3b30]',
|
||||
text: 'text-white',
|
||||
|
||||
@@ -19,7 +19,8 @@ function goHome(): void {
|
||||
:class="{ 'phone-home-indicator--interactive': isApp }"
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.home')"
|
||||
@click="goHome"
|
||||
@pointerdown.stop="goHome"
|
||||
@click.stop="goHome"
|
||||
>
|
||||
<span aria-hidden="true"></span>
|
||||
</button>
|
||||
|
||||
@@ -16,11 +16,15 @@ import {
|
||||
import { computed, onBeforeUnmount, onMounted, ref, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useCalendarStore } from '@/stores/calendar'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { useWeatherStore } from '@/stores/weather'
|
||||
import type { WeatherConditionId } from '@/types/weather'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const calendar = useCalendarStore()
|
||||
const weather = useWeatherStore()
|
||||
const router = useRouter()
|
||||
const now = ref(new Date())
|
||||
@@ -57,15 +61,46 @@ const date = computed(() =>
|
||||
}).format(now.value),
|
||||
)
|
||||
const day = computed(() => now.value.getDate())
|
||||
const nextCalendarEvent = computed(() =>
|
||||
calendar.events
|
||||
.filter((event) => event.endsAt >= now.value.getTime())
|
||||
.sort((left, right) => left.startsAt - right.startsAt)
|
||||
.at(0),
|
||||
)
|
||||
const calendarEventLabel = computed(
|
||||
() =>
|
||||
nextCalendarEvent.value?.title ?? phone.t('Home.widgets.calendar.event'),
|
||||
)
|
||||
|
||||
async function loadCalendarDay(): Promise<void> {
|
||||
if (!account.email) {
|
||||
calendar.events = []
|
||||
return
|
||||
}
|
||||
|
||||
const start = new Date(now.value)
|
||||
start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(start)
|
||||
end.setDate(end.getDate() + 1)
|
||||
await calendar.load(start.getTime(), end.getTime())
|
||||
}
|
||||
|
||||
function openWeather(): void {
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/weather')
|
||||
}
|
||||
|
||||
function openCalendar(): void {
|
||||
phone.setLaunchOrigin(null)
|
||||
void router.push('/apps/calendar')
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCalendarDay()
|
||||
intervalId = window.setInterval(() => {
|
||||
const previousDay = now.value.toDateString()
|
||||
now.value = new Date()
|
||||
if (now.value.toDateString() !== previousDay) void loadCalendarDay()
|
||||
}, 60_000)
|
||||
})
|
||||
|
||||
@@ -91,11 +126,16 @@ onBeforeUnmount(() => {
|
||||
</button>
|
||||
|
||||
<div class="widget-row">
|
||||
<article class="widget widget--calendar">
|
||||
<button
|
||||
type="button"
|
||||
class="widget widget--calendar"
|
||||
:aria-label="phone.t('Apps.calendar.name')"
|
||||
@click="openCalendar"
|
||||
>
|
||||
<span>{{ date }}</span>
|
||||
<strong>{{ day }}</strong>
|
||||
<small>{{ phone.t('Home.widgets.calendar.event') }}</small>
|
||||
</article>
|
||||
<small>{{ calendarEventLabel }}</small>
|
||||
</button>
|
||||
<article class="widget widget--battery">
|
||||
<BatteryCharging :size="25" aria-hidden="true" />
|
||||
<strong>78%</strong>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, ChevronRight, ImageOff } from 'lucide-vue-next'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import type { MarketplaceImage } from '@/types/marketplace'
|
||||
|
||||
const props = defineProps<{
|
||||
emptyBody: string
|
||||
emptyTitle: string
|
||||
images: MarketplaceImage[]
|
||||
nextLabel: string
|
||||
photoLabel: string
|
||||
previousLabel: string
|
||||
}>()
|
||||
|
||||
const activeIndex = ref(0)
|
||||
|
||||
watch(
|
||||
() => props.images,
|
||||
() => (activeIndex.value = 0),
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
function move(direction: number): void {
|
||||
if (props.images.length < 2) return
|
||||
activeIndex.value =
|
||||
(activeIndex.value + direction + props.images.length) % props.images.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="citymarkt-gallery" :class="{ 'citymarkt-gallery--empty': !images.length }">
|
||||
<div
|
||||
v-if="images.length"
|
||||
class="citymarkt-gallery__image"
|
||||
:style="{ background: images[activeIndex]?.gradient }"
|
||||
role="img"
|
||||
:aria-label="`${photoLabel} ${activeIndex + 1}`"
|
||||
/>
|
||||
<div v-else class="citymarkt-gallery__empty">
|
||||
<span><ImageOff :size="27" /></span>
|
||||
<strong>{{ emptyTitle }}</strong>
|
||||
<small>{{ emptyBody }}</small>
|
||||
</div>
|
||||
|
||||
<template v-if="images.length > 1">
|
||||
<button
|
||||
class="citymarkt-gallery__arrow citymarkt-gallery__arrow--left"
|
||||
type="button"
|
||||
:aria-label="previousLabel"
|
||||
@click.stop="move(-1)"
|
||||
>
|
||||
<ChevronLeft :size="19" />
|
||||
</button>
|
||||
<button
|
||||
class="citymarkt-gallery__arrow citymarkt-gallery__arrow--right"
|
||||
type="button"
|
||||
:aria-label="nextLabel"
|
||||
@click.stop="move(1)"
|
||||
>
|
||||
<ChevronRight :size="19" />
|
||||
</button>
|
||||
<div class="citymarkt-gallery__dots" aria-hidden="true">
|
||||
<i
|
||||
v-for="(_, index) in images"
|
||||
:key="index"
|
||||
:class="{ active: index === activeIndex }"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<span v-if="images.length" class="citymarkt-gallery__count">
|
||||
{{ activeIndex + 1 }} / {{ images.length }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-gallery{position:relative;overflow:hidden;background:#252724}.citymarkt-gallery__image{position:absolute;inset:0;background-position:center!important;background-size:cover!important;transition:background .2s ease}.citymarkt-gallery__empty{position:absolute;inset:0;padding:18px;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;color:var(--muted)}.citymarkt-gallery__empty span{width:48px;height:48px;margin-bottom:8px;border:1px solid #ffffff12;border-radius:16px;display:grid;place-items:center;background:#ffffff08;color:var(--yellow)}.citymarkt-gallery__empty strong{font-size:12px}.citymarkt-gallery__empty small{max-width:190px;margin-top:3px;font-size:8px;line-height:1.35}.citymarkt-gallery__arrow{position:absolute;z-index:2;top:50%;width:31px;height:31px;padding:0;border:1px solid #ffffff24;border-radius:50%;display:grid;place-items:center;background:#11120fba;color:#fff;box-shadow:0 4px 13px #0005;transform:translateY(-50%)}.citymarkt-gallery__arrow--left{left:9px}.citymarkt-gallery__arrow--right{right:9px}.citymarkt-gallery__dots{position:absolute;z-index:2;right:52px;bottom:12px;left:52px;display:flex;justify-content:center;gap:4px}.citymarkt-gallery__dots i{width:4px;height:4px;border-radius:50%;background:#ffffff66;box-shadow:0 1px 3px #0008;transition:width .18s ease,background .18s ease}.citymarkt-gallery__dots i.active{width:11px;border-radius:4px;background:var(--yellow)}.citymarkt-gallery__count{position:absolute;z-index:2;right:9px;bottom:8px;padding:4px 7px;border-radius:8px;background:#11120fc7;color:#fff;font-size:8px;font-weight:800}:global(.citymarkt--light) .citymarkt-gallery--empty{background:#e9eae5}:global(.citymarkt--light) .citymarkt-gallery__empty span{border-color:#00000012;background:#00000008}
|
||||
.citymarkt-gallery__empty strong{font-size:14px}
|
||||
.citymarkt-gallery__empty small{margin-top:4px;font-size:11.5px;line-height:1.4}
|
||||
.citymarkt-gallery__count{font-size:10.5px}
|
||||
</style>
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup lang="ts">
|
||||
import { BadgeDollarSign, Check, RefreshCw, X } from 'lucide-vue-next'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { MarketplaceOffer } from '@/types/marketplace'
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: number
|
||||
actionable: boolean
|
||||
isCounter: boolean
|
||||
offer: MarketplaceOffer
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
accept: []
|
||||
counter: []
|
||||
reject: []
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const isOwn = computed(() => props.offer.proposer_account_id === props.accountId)
|
||||
const statusKey = computed(() =>
|
||||
props.offer.status === 'rejected' ? 'declined' : props.offer.status,
|
||||
)
|
||||
const formattedAmount = computed(() =>
|
||||
phone.t('Apps.citymarkt.money', {
|
||||
price: new Intl.NumberFormat(phone.lang, { maximumFractionDigits: 0 }).format(
|
||||
Number(props.offer.amount),
|
||||
),
|
||||
}),
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article
|
||||
class="citymarkt-offer"
|
||||
:class="[`citymarkt-offer--${offer.status}`, { 'citymarkt-offer--own': isOwn }]"
|
||||
>
|
||||
<header>
|
||||
<span><BadgeDollarSign :size="16" /></span>
|
||||
<div>
|
||||
<small>{{ phone.t(isCounter ? 'Apps.citymarkt.counterOffer' : 'Apps.citymarkt.offer') }}</small>
|
||||
<strong>{{ formattedAmount }}</strong>
|
||||
</div>
|
||||
<i>{{ phone.t(`Apps.citymarkt.offerStatus.${statusKey}`) }}</i>
|
||||
</header>
|
||||
<p>
|
||||
{{ phone.t(isOwn ? 'Apps.citymarkt.offeredByYou' : 'Apps.citymarkt.offeredToYou') }}
|
||||
</p>
|
||||
<div v-if="actionable" class="citymarkt-offer__actions">
|
||||
<button type="button" class="accept" @click="$emit('accept')">
|
||||
<Check :size="14" />{{ phone.t('Apps.citymarkt.acceptOffer') }}
|
||||
</button>
|
||||
<button type="button" @click="$emit('counter')">
|
||||
<RefreshCw :size="13" />{{ phone.t('Apps.citymarkt.negotiateOffer') }}
|
||||
</button>
|
||||
<button type="button" class="reject" @click="$emit('reject')">
|
||||
<X :size="14" />{{ phone.t('Apps.citymarkt.declineOffer') }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-offer{width:92%;padding:11px;border:1px solid #ffc92842;border-radius:14px;align-self:flex-start;background:linear-gradient(145deg,#332d19,var(--panel));box-shadow:0 7px 18px #0003}.citymarkt-offer--own{align-self:flex-end}.citymarkt-offer header{display:flex;align-items:center;gap:8px}.citymarkt-offer header>span{width:32px;height:32px;flex:none;border-radius:10px;display:grid;place-items:center;background:var(--yellow);color:#171816}.citymarkt-offer header>div{min-width:0;flex:1}.citymarkt-offer header small,.citymarkt-offer header strong{display:block}.citymarkt-offer header small{color:var(--muted);font-size:9px;font-weight:800;letter-spacing:.02em;text-transform:uppercase}.citymarkt-offer header strong{margin-top:1px;font-size:18px}.citymarkt-offer header i{padding:5px 7px;border-radius:7px;background:#ffc92817;color:var(--yellow);font-size:8px;font-style:normal;font-weight:900;text-transform:uppercase}.citymarkt-offer>p{margin:7px 0 0;color:var(--muted);font-size:9px;line-height:1.35}.citymarkt-offer--accepted{border-color:#54d68173;background:linear-gradient(145deg,#193526,var(--panel))}.citymarkt-offer--accepted header>span{background:#54d681}.citymarkt-offer--accepted header i{background:#54d6811c;color:#67e494}.citymarkt-offer--rejected,.citymarkt-offer--countered{border-color:#ffffff14;filter:saturate(.65)}.citymarkt-offer--rejected header>span,.citymarkt-offer--countered header>span{background:#555750;color:#ddd}.citymarkt-offer--rejected header i,.citymarkt-offer--countered header i{background:#ffffff0c;color:var(--muted)}.citymarkt-offer__actions{margin-top:10px;display:grid;grid-template-columns:1fr 1fr;gap:6px}.citymarkt-offer__actions button{min-height:35px;padding:7px 6px;border:1px solid #ffffff12;border-radius:10px;display:flex;align-items:center;justify-content:center;gap:4px;background:#ffffff09;color:inherit;font-size:9.5px;font-weight:850;line-height:1.1}.citymarkt-offer__actions button.accept{border:0;background:#54d681;color:#102319}.citymarkt-offer__actions button.reject{grid-column:1/-1;color:#ff8078}:global(.citymarkt--light) .citymarkt-offer{background:linear-gradient(145deg,#fff8d9,#fff);box-shadow:0 7px 18px #0001}:global(.citymarkt--light) .citymarkt-offer--accepted{background:linear-gradient(145deg,#e6faed,#fff)}
|
||||
.citymarkt-offer{padding:12px}.citymarkt-offer header>span{width:35px;height:35px}.citymarkt-offer header small{font-size:10.5px}.citymarkt-offer header strong{font-size:20px}.citymarkt-offer header i{padding:5px 8px;font-size:9.5px}.citymarkt-offer>p{margin-top:8px;font-size:11.5px}.citymarkt-offer__actions{margin-top:11px;gap:7px}.citymarkt-offer__actions button{min-height:40px;padding:8px;font-size:12px;font-weight:850;gap:5px}.citymarkt-offer__actions button svg{width:15px;height:15px}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { Check, ChevronDown } from 'lucide-vue-next'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
type SelectOption = {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
options: SelectOption[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [value: string]
|
||||
}>()
|
||||
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const isOpen = ref(false)
|
||||
const highlightedIndex = ref(0)
|
||||
const selectedLabel = computed(
|
||||
() => props.options.find((option) => option.value === props.modelValue)?.label ?? '',
|
||||
)
|
||||
|
||||
function open(): void {
|
||||
highlightedIndex.value = Math.max(
|
||||
0,
|
||||
props.options.findIndex((option) => option.value === props.modelValue),
|
||||
)
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
function select(value: string): void {
|
||||
emit('change', value)
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen.value = false
|
||||
return
|
||||
}
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault()
|
||||
if (!isOpen.value) open()
|
||||
else select(props.options[highlightedIndex.value]?.value ?? props.modelValue)
|
||||
return
|
||||
}
|
||||
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
|
||||
event.preventDefault()
|
||||
if (!isOpen.value) open()
|
||||
const direction = event.key === 'ArrowDown' ? 1 : -1
|
||||
highlightedIndex.value =
|
||||
(highlightedIndex.value + direction + props.options.length) % props.options.length
|
||||
}
|
||||
|
||||
function handleOutsidePointer(event: PointerEvent): void {
|
||||
if (!root.value?.contains(event.target as Node)) isOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('pointerdown', handleOutsidePointer))
|
||||
onUnmounted(() => window.removeEventListener('pointerdown', handleOutsidePointer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root" class="citymarkt-select" @keydown="handleKeydown">
|
||||
<button
|
||||
class="citymarkt-select__trigger"
|
||||
type="button"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="isOpen"
|
||||
@click="isOpen ? (isOpen = false) : open()"
|
||||
>
|
||||
<span>{{ selectedLabel }}</span>
|
||||
<ChevronDown :size="14" :class="{ open: isOpen }" />
|
||||
</button>
|
||||
|
||||
<Transition name="citymarkt-select">
|
||||
<div v-if="isOpen" class="citymarkt-select__menu" role="listbox">
|
||||
<button
|
||||
v-for="(option, index) in options"
|
||||
:key="option.value"
|
||||
type="button"
|
||||
role="option"
|
||||
:aria-selected="option.value === modelValue"
|
||||
:class="{
|
||||
highlighted: index === highlightedIndex,
|
||||
selected: option.value === modelValue,
|
||||
}"
|
||||
@pointerenter="highlightedIndex = index"
|
||||
@click="select(option.value)"
|
||||
>
|
||||
<span>{{ option.label }}</span>
|
||||
<Check v-if="option.value === modelValue" :size="13" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-select{position:relative;min-width:0}.citymarkt-select__trigger{width:100%;height:36px;padding:0 10px;border:1px solid #ffffff0d;border-radius:10px;display:flex;align-items:center;justify-content:space-between;gap:6px;background:var(--panel);color:inherit;font-size:10px;text-align:left}.citymarkt-select__trigger span{overflow:hidden;white-space:nowrap;text-overflow:ellipsis}.citymarkt-select__trigger svg{flex:none;color:var(--yellow);transition:transform .18s ease}.citymarkt-select__trigger svg.open{transform:rotate(180deg)}.citymarkt-select__menu{position:absolute;z-index:12;top:calc(100% + 5px);right:0;left:0;max-height:176px;padding:4px;border:1px solid #ffffff16;border-radius:11px;overflow-y:auto;background:#292a27;box-shadow:0 12px 28px #0009;scrollbar-width:none}:global(.citymarkt--light) .citymarkt-select__menu{border-color:#00000014;background:#fff;box-shadow:0 12px 28px #0003}.citymarkt-select__menu button{width:100%;min-height:31px;padding:6px 7px;border:0;border-radius:8px;display:flex;align-items:center;justify-content:space-between;gap:5px;background:none;color:var(--muted);font-size:10px;text-align:left}.citymarkt-select__menu button.highlighted{background:#ffffff0b;color:inherit}:global(.citymarkt--light) .citymarkt-select__menu button.highlighted{background:#0000000b}.citymarkt-select__menu button.selected{color:var(--yellow);font-weight:800}.citymarkt-select-enter-active,.citymarkt-select-leave-active{transition:opacity .15s ease,transform .15s ease}.citymarkt-select-enter-from,.citymarkt-select-leave-to{opacity:0;transform:translateY(-4px) scale(.98)}
|
||||
.citymarkt-select__trigger{height:40px;font-size:13px}
|
||||
.citymarkt-select__menu button{min-height:36px;padding:7px 8px;font-size:12px}
|
||||
</style>
|
||||
Reference in New Issue
Block a user