ADD - integrate calendar games and local marketplaces

This commit is contained in:
Eichenholz
2026-08-06 19:14:15 +02:00
191 changed files with 23266 additions and 65 deletions
+7 -3
View File
@@ -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>
+43 -3
View File
@@ -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>