mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-29 00:01:29 +00:00
ENH - unify social app accounts and profiles
This commit is contained in:
@@ -32,6 +32,7 @@ import { useBankingStore } from '@/stores/banking'
|
||||
import { useBillingStore } from '@/stores/billing'
|
||||
import { useCompaniesStore } from '@/stores/companies'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore } from '@/stores/app-auth'
|
||||
import { useMailStore } from '@/stores/mail'
|
||||
import { useMessagesStore } from '@/stores/messages'
|
||||
import { useDarkChatStore } from '@/stores/darkchat'
|
||||
@@ -241,6 +242,7 @@ const isDevelopment = import.meta.env.DEV
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const clock = useClockStore()
|
||||
const games = useGamesStore()
|
||||
const calls = useCallsStore()
|
||||
@@ -342,6 +344,10 @@ function hydratePhone(payload: PhoneOpenPayload): void {
|
||||
notifications.hideDevicePreview(payload.device.imei)
|
||||
}
|
||||
account.hydrate(payload.account ?? null)
|
||||
appAuth.hydrate(
|
||||
payload.device?.data.appAuth?.payload,
|
||||
payload.account?.email ?? '',
|
||||
)
|
||||
notes.hydrate(payload.notes ?? [])
|
||||
clock.hydrate(payload.device?.data.alarms?.payload)
|
||||
games.hydrate(payload.device?.data.games?.payload)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<script setup lang="ts">
|
||||
import { kDialog, kDialogButton } from 'konsta/vue'
|
||||
|
||||
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
|
||||
import { useCrewLinkStore } from '@/stores/crewlink'
|
||||
import { useFeatherStore } from '@/stores/feather'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
import { usePagesStore } from '@/stores/pages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const opened = defineModel<boolean>('opened', { default: false })
|
||||
const emit = defineEmits<{ loggedOut: [] }>()
|
||||
const props = defineProps<{ appId: AppAuthId; appName: string }>()
|
||||
|
||||
const appAuth = useAppAuthStore()
|
||||
const crewLink = useCrewLinkStore()
|
||||
const feather = useFeatherStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const pages = usePagesStore()
|
||||
const phone = usePhoneStore()
|
||||
|
||||
function close(): void {
|
||||
opened.value = false
|
||||
}
|
||||
|
||||
function confirmLogout(): void {
|
||||
appAuth.signOut(props.appId)
|
||||
if (props.appId === 'citymarkt') marketplace.$reset()
|
||||
if (props.appId === 'local-pages') pages.$reset()
|
||||
if (props.appId === 'feather') feather.$reset()
|
||||
if (props.appId === 'crewlink') {
|
||||
crewLink.$reset()
|
||||
crewLink.error = 'not_authenticated'
|
||||
}
|
||||
opened.value = false
|
||||
emit('loggedOut')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<k-dialog :opened="opened" @backdropclick="close">
|
||||
<template #title>{{ phone.t('Common.signOutTitle', { app: appName }) }}</template>
|
||||
<p>{{ phone.t('Common.signOutBody', { app: appName }) }}</p>
|
||||
<template #buttons>
|
||||
<k-dialog-button @click="close">
|
||||
{{ phone.t('Common.cancel') }}
|
||||
</k-dialog-button>
|
||||
<k-dialog-button
|
||||
strong
|
||||
class="account-logout-confirm"
|
||||
@click="confirmLogout"
|
||||
>
|
||||
{{ phone.t('Common.signOut') }}
|
||||
</k-dialog-button>
|
||||
</template>
|
||||
</k-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-logout-confirm {
|
||||
background: #e44760 !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,391 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ArrowRight,
|
||||
Camera,
|
||||
Images,
|
||||
LockKeyhole,
|
||||
Mail,
|
||||
UserRound,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
kButton,
|
||||
kGlass,
|
||||
kList,
|
||||
kListInput,
|
||||
kPreloader,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
} from 'konsta/vue'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
avatarUrl: string | null
|
||||
body: string
|
||||
cameraLabel: string
|
||||
email: string
|
||||
emailLabel: string
|
||||
error: string
|
||||
eyebrow: string
|
||||
galleryLabel: string
|
||||
loginLabel: string
|
||||
maxUsernameLength?: number
|
||||
minUsernameLength?: number
|
||||
mode: 'login' | 'register'
|
||||
pending: boolean
|
||||
registerLabel: string
|
||||
title: string
|
||||
username: string
|
||||
usernameLabel: string
|
||||
usernamePlaceholder?: string
|
||||
}>(),
|
||||
{
|
||||
maxUsernameLength: 40,
|
||||
minUsernameLength: 2,
|
||||
usernamePlaceholder: '',
|
||||
},
|
||||
)
|
||||
const emit = defineEmits<{
|
||||
camera: []
|
||||
gallery: []
|
||||
submit: []
|
||||
'update:mode': [value: 'login' | 'register']
|
||||
'update:username': [value: string]
|
||||
}>()
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const length = props.username.trim().length
|
||||
return Boolean(
|
||||
props.email &&
|
||||
length >= props.minUsernameLength &&
|
||||
length <= props.maxUsernameLength,
|
||||
)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="app-profile-auth">
|
||||
<header class="app-profile-auth__hero">
|
||||
<span class="app-profile-auth__mark"><UserRound :size="23" /></span>
|
||||
<div>
|
||||
<small>{{ eyebrow }}</small>
|
||||
<h2>{{ title }}</h2>
|
||||
</div>
|
||||
<p>{{ body }}</p>
|
||||
</header>
|
||||
|
||||
<k-glass class="app-profile-auth__card">
|
||||
<k-segmented raised class="app-profile-auth__mode">
|
||||
<k-segmented-button
|
||||
:active="mode === 'login'"
|
||||
@click="emit('update:mode', 'login')"
|
||||
>
|
||||
{{ loginLabel }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
:active="mode === 'register'"
|
||||
@click="emit('update:mode', 'register')"
|
||||
>
|
||||
{{ registerLabel }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
|
||||
<div v-if="mode === 'register'" class="app-profile-auth__photo">
|
||||
<span class="app-profile-auth__avatar">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" alt="" />
|
||||
<UserRound v-else :size="28" />
|
||||
<i><Camera :size="11" /></i>
|
||||
</span>
|
||||
<div>
|
||||
<k-button rounded outline @click="emit('gallery')">
|
||||
<Images :size="15" />{{ galleryLabel }}
|
||||
</k-button>
|
||||
<k-button rounded outline @click="emit('camera')">
|
||||
<Camera :size="15" />{{ cameraLabel }}
|
||||
</k-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-profile-auth__identity">
|
||||
<span><Mail :size="17" /></span>
|
||||
<div>
|
||||
<small>{{ emailLabel }}</small>
|
||||
<strong>{{ email }}</strong>
|
||||
</div>
|
||||
<LockKeyhole :size="15" />
|
||||
</div>
|
||||
|
||||
<k-list inset strong class="app-profile-auth__fields">
|
||||
<k-list-input
|
||||
input-id="app-profile-auth-username"
|
||||
:label="usernameLabel"
|
||||
:value="username"
|
||||
:maxlength="maxUsernameLength"
|
||||
:placeholder="usernamePlaceholder"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
outline
|
||||
@input="
|
||||
emit('update:username', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<div v-if="error" class="app-profile-auth__error" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
<k-button
|
||||
large
|
||||
rounded
|
||||
class="app-profile-auth__submit"
|
||||
:disabled="!canSubmit || pending"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
<k-preloader v-if="pending" />
|
||||
<template v-else>
|
||||
<span>{{ mode === 'login' ? loginLabel : registerLabel }}</span>
|
||||
<ArrowRight :size="18" />
|
||||
</template>
|
||||
</k-button>
|
||||
</k-glass>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-profile-auth {
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
margin: auto;
|
||||
padding: 12px 4px 18px;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
}
|
||||
.app-profile-auth__hero {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 46px minmax(0, 1fr);
|
||||
gap: 0 11px;
|
||||
margin: 0 12px 14px;
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__mark {
|
||||
display: grid;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
grid-row: 1 / span 2;
|
||||
place-items: center;
|
||||
border: 1px solid
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 46%, transparent);
|
||||
border-radius: 15px;
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 14%, transparent);
|
||||
box-shadow: 0 10px 28px
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
|
||||
}
|
||||
.app-profile-auth__hero small {
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
font-size: 9px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.app-profile-auth__hero h2 {
|
||||
margin: 3px 0 0;
|
||||
color: inherit;
|
||||
font-size: 20px;
|
||||
line-height: 1.08;
|
||||
}
|
||||
.app-profile-auth__hero p {
|
||||
grid-column: 2;
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted, #9ba4aa);
|
||||
font-size: 11px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.app-profile-auth__card {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 24px;
|
||||
background: color-mix(in srgb, var(--panel, #20262c) 90%, transparent);
|
||||
box-shadow: 0 22px 50px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(22px) saturate(1.15);
|
||||
}
|
||||
.app-profile-auth__card::before {
|
||||
position: absolute;
|
||||
top: -80px;
|
||||
right: -55px;
|
||||
width: 170px;
|
||||
height: 150px;
|
||||
border-radius: 50%;
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 16%, transparent);
|
||||
filter: blur(38px);
|
||||
content: '';
|
||||
pointer-events: none;
|
||||
}
|
||||
.app-profile-auth__mode {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin: 0 0 12px;
|
||||
padding: 3px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
}
|
||||
.app-profile-auth__photo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin: 0 2px 11px;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.035);
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__avatar {
|
||||
position: relative;
|
||||
display: grid;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
border: 2px solid
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 58%, transparent);
|
||||
border-radius: 50%;
|
||||
color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--auth-accent, #ffd63e) 10%,
|
||||
var(--panel, #20262c)
|
||||
);
|
||||
box-shadow: 0 8px 22px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.app-profile-auth__photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: inherit;
|
||||
}
|
||||
.app-profile-auth__avatar i {
|
||||
position: absolute;
|
||||
right: -2px;
|
||||
bottom: -1px;
|
||||
display: grid;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
place-items: center;
|
||||
border: 2px solid var(--panel, #20262c);
|
||||
border-radius: 50%;
|
||||
color: #fff;
|
||||
background: var(--auth-accent, #ffd63e);
|
||||
}
|
||||
.app-profile-auth__photo > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
}
|
||||
.app-profile-auth__photo :deep(.k-button) {
|
||||
min-height: 32px;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
border-color: rgba(255, 255, 255, 0.12);
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-profile-auth__identity {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 34px minmax(0, 1fr) 18px;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 9px;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.09);
|
||||
border-radius: 15px;
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__identity > span {
|
||||
display: grid;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
color: var(--auth-accent, #ffd63e);
|
||||
background: color-mix(in srgb, var(--auth-accent, #ffd63e) 13%, transparent);
|
||||
}
|
||||
.app-profile-auth__identity div {
|
||||
min-width: 0;
|
||||
}
|
||||
.app-profile-auth__identity small {
|
||||
display: block;
|
||||
margin-bottom: 1px;
|
||||
color: var(--muted, #9ba4aa);
|
||||
font-size: 9px;
|
||||
}
|
||||
.app-profile-auth__identity strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app-profile-auth__identity > svg {
|
||||
color: var(--muted, #9ba4aa);
|
||||
}
|
||||
.app-profile-auth__fields {
|
||||
margin-top: 0;
|
||||
margin-right: 0;
|
||||
margin-bottom: 11px;
|
||||
margin-left: 0;
|
||||
color: inherit;
|
||||
background: rgba(255, 255, 255, 0.045) !important;
|
||||
text-align: left;
|
||||
}
|
||||
.app-profile-auth__fields :deep(.text-black) {
|
||||
color: inherit !important;
|
||||
}
|
||||
.app-profile-auth__fields :deep(.text-xs > div) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--panel, #20262c) 94%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
.app-profile-auth__error {
|
||||
margin: -2px 1px 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(255, 105, 97, 0.22);
|
||||
border-radius: 11px;
|
||||
color: #ff6961;
|
||||
background: rgba(255, 105, 97, 0.08);
|
||||
font-size: 11px;
|
||||
}
|
||||
.app-profile-auth__submit {
|
||||
--k-button-bg-color: var(--auth-accent, var(--yellow, #ffd63e));
|
||||
--k-button-text-color: #fff;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0 17px;
|
||||
color: #fff !important;
|
||||
background: var(--auth-accent, var(--yellow, #ffd63e)) !important;
|
||||
box-shadow: 0 10px 26px
|
||||
color-mix(in srgb, var(--auth-accent, #ffd63e) 25%, transparent);
|
||||
font-weight: 750;
|
||||
}
|
||||
.app-profile-auth__submit:disabled {
|
||||
opacity: 0.46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
kButton,
|
||||
kList,
|
||||
kListInput,
|
||||
kPreloader,
|
||||
kSegmented,
|
||||
kSegmentedButton,
|
||||
} from 'konsta/vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore, type AppAuthId } from '@/stores/app-auth'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import {
|
||||
filterMailAddressInput,
|
||||
MAIL_ADDRESS_INPUT_MAX_LENGTH,
|
||||
normalizeMailAddress,
|
||||
} from '@/utils/mail'
|
||||
|
||||
const props = defineProps<{
|
||||
appId: AppAuthId
|
||||
appName: string
|
||||
}>()
|
||||
const emit = defineEmits<{ signedIn: [] }>()
|
||||
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const phone = usePhoneStore()
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const email = ref(account.email)
|
||||
const password = ref('')
|
||||
const confirm = ref('')
|
||||
const pending = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
const normalized = normalizeMailAddress(email.value)
|
||||
const passwordValid = password.value.length >= 6 && password.value.length <= 64
|
||||
return Boolean(
|
||||
normalized &&
|
||||
passwordValid &&
|
||||
(mode.value === 'login' || (confirm.value && confirm.value === password.value)),
|
||||
)
|
||||
})
|
||||
|
||||
function setMode(next: 'login' | 'register'): void {
|
||||
mode.value = next
|
||||
confirm.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
|
||||
function updateEmail(event: Event): void {
|
||||
email.value = filterMailAddressInput((event.target as HTMLInputElement).value)
|
||||
}
|
||||
|
||||
function errorMessage(key?: string): string {
|
||||
const known = [
|
||||
'invalid_email',
|
||||
'invalid_password',
|
||||
'invalid_credentials',
|
||||
'email_taken',
|
||||
'rate_limited',
|
||||
]
|
||||
return phone.t(
|
||||
`Common.appAuth.errors.${key && known.includes(key) ? key : 'default'}`,
|
||||
)
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
if (!canSubmit.value || pending.value) return
|
||||
const normalized = normalizeMailAddress(email.value)
|
||||
if (!normalized) return
|
||||
|
||||
pending.value = true
|
||||
error.value = ''
|
||||
const response =
|
||||
mode.value === 'login'
|
||||
? await account.login(normalized, password.value)
|
||||
: await account.register(normalized, password.value)
|
||||
pending.value = false
|
||||
if (!response.success || !response.data) {
|
||||
error.value = errorMessage(response.error)
|
||||
return
|
||||
}
|
||||
|
||||
appAuth.signIn(props.appId, response.data.email)
|
||||
password.value = ''
|
||||
confirm.value = ''
|
||||
emit('signedIn')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="ifruit-app-auth">
|
||||
<small>{{ phone.t('Common.appAuth.eyebrow') }}</small>
|
||||
<h2>{{ phone.t('Common.appAuth.title', { app: appName }) }}</h2>
|
||||
<p>{{ phone.t('Common.appAuth.body', { app: appName }) }}</p>
|
||||
|
||||
<k-segmented raised class="ifruit-app-auth__mode">
|
||||
<k-segmented-button :active="mode === 'login'" @click="setMode('login')">
|
||||
{{ phone.t('Common.appAuth.login') }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button :active="mode === 'register'" @click="setMode('register')">
|
||||
{{ phone.t('Common.appAuth.register') }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
|
||||
<k-list inset strong class="ifruit-app-auth__fields">
|
||||
<k-list-input
|
||||
input-id="ifruit-app-auth-email"
|
||||
:label="phone.t('Common.appAuth.email')"
|
||||
:value="email"
|
||||
:maxlength="MAIL_ADDRESS_INPUT_MAX_LENGTH"
|
||||
autocomplete="username"
|
||||
inputmode="email"
|
||||
outline
|
||||
@input="updateEmail"
|
||||
/>
|
||||
<k-list-input
|
||||
input-id="ifruit-app-auth-password"
|
||||
:label="phone.t('Common.appAuth.password')"
|
||||
:value="password"
|
||||
type="password"
|
||||
maxlength="64"
|
||||
:autocomplete="mode === 'login' ? 'current-password' : 'new-password'"
|
||||
outline
|
||||
@input="password = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
<k-list-input
|
||||
v-if="mode === 'register'"
|
||||
input-id="ifruit-app-auth-confirm"
|
||||
:label="phone.t('Common.appAuth.confirm')"
|
||||
:value="confirm"
|
||||
type="password"
|
||||
maxlength="64"
|
||||
autocomplete="new-password"
|
||||
outline
|
||||
@input="confirm = ($event.target as HTMLInputElement).value"
|
||||
/>
|
||||
</k-list>
|
||||
|
||||
<p v-if="error" class="ifruit-app-auth__error" role="alert">{{ error }}</p>
|
||||
<k-button large rounded :disabled="!canSubmit || pending" @click="submit">
|
||||
<k-preloader v-if="pending" />
|
||||
<template v-else>
|
||||
{{ phone.t(mode === 'login' ? 'Common.appAuth.loginAction' : 'Common.appAuth.registerAction') }}
|
||||
</template>
|
||||
</k-button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ifruit-app-auth {
|
||||
width: 100%;
|
||||
max-width: 310px;
|
||||
margin: auto;
|
||||
padding: 18px 10px;
|
||||
text-align: center;
|
||||
}
|
||||
.ifruit-app-auth > small {
|
||||
color: var(--k-color-primary);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.ifruit-app-auth h2 {
|
||||
margin: 6px 0 4px;
|
||||
font-size: 21px;
|
||||
}
|
||||
.ifruit-app-auth > p {
|
||||
margin: 0 auto 14px;
|
||||
color: #8e8e93;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ifruit-app-auth__mode {
|
||||
margin: 0 8px 12px;
|
||||
}
|
||||
.ifruit-app-auth__fields {
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.ifruit-app-auth .ifruit-app-auth__error {
|
||||
margin: -4px 12px 10px;
|
||||
color: #ff453a;
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
defineProps<{
|
||||
avatarUrl: string | null
|
||||
email: string
|
||||
error: string
|
||||
mode: 'login' | 'register'
|
||||
pending: boolean
|
||||
username: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
camera: []
|
||||
gallery: []
|
||||
submit: []
|
||||
'update:mode': [value: 'login' | 'register']
|
||||
'update:username': [value: string]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppProfileAuth
|
||||
:avatar-url="avatarUrl"
|
||||
:body="phone.t('Apps.citymarkt.authBody')"
|
||||
:camera-label="phone.t('Apps.citymarkt.takePhoto')"
|
||||
:email="email"
|
||||
:email-label="phone.t('Apps.citymarkt.profileEmail')"
|
||||
:error="error"
|
||||
:eyebrow="phone.t('Apps.citymarkt.authEyebrow')"
|
||||
:gallery-label="phone.t('Apps.citymarkt.chooseGallery')"
|
||||
:login-label="phone.t('Apps.citymarkt.login')"
|
||||
:mode="mode"
|
||||
:pending="pending"
|
||||
:register-label="phone.t('Apps.citymarkt.register')"
|
||||
:title="phone.t('Apps.citymarkt.authTitle')"
|
||||
:username="username"
|
||||
:username-label="phone.t('Apps.citymarkt.authUsername')"
|
||||
@camera="emit('camera')"
|
||||
@gallery="emit('gallery')"
|
||||
@submit="emit('submit')"
|
||||
@update:mode="emit('update:mode', $event)"
|
||||
@update:username="emit('update:username', $event)"
|
||||
/>
|
||||
</template>
|
||||
@@ -58,6 +58,19 @@ describe('account store', () => {
|
||||
expect(account.devices).toEqual(devices)
|
||||
})
|
||||
|
||||
it('clears the linked account after logout', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
|
||||
const account = useAccountStore()
|
||||
account.hydrate({ devices, email: 'alex@ifruit.com' })
|
||||
const success = await account.logout()
|
||||
|
||||
expect(success).toBe(true)
|
||||
expect(account.email).toBe('')
|
||||
expect(account.devices).toEqual([])
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('account:logout')
|
||||
})
|
||||
|
||||
it('clears account state after a factory reset', async () => {
|
||||
mockNuiCall.mockResolvedValueOnce({ success: true })
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const saveDeviceNamespace = vi.fn()
|
||||
|
||||
vi.mock('@/stores/phone', () => ({
|
||||
usePhoneStore: () => ({ saveDeviceNamespace }),
|
||||
}))
|
||||
|
||||
import { useAppAuthStore } from '@/stores/app-auth'
|
||||
|
||||
describe('app auth store', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
saveDeviceNamespace.mockReset()
|
||||
})
|
||||
|
||||
it('keeps each app session independent', () => {
|
||||
const auth = useAppAuthStore()
|
||||
auth.hydrate(
|
||||
{
|
||||
accountEmail: 'demo@ifruit.com',
|
||||
signedIn: ['citymarkt', 'feather'],
|
||||
version: 1,
|
||||
},
|
||||
'demo@ifruit.com',
|
||||
)
|
||||
|
||||
auth.signOut('citymarkt')
|
||||
|
||||
expect(auth.isSignedIn('citymarkt')).toBe(false)
|
||||
expect(auth.isSignedIn('feather')).toBe(true)
|
||||
expect(saveDeviceNamespace).toHaveBeenLastCalledWith('appAuth', {
|
||||
accountEmail: 'demo@ifruit.com',
|
||||
signedIn: ['feather'],
|
||||
version: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not restore sessions belonging to another iFruit account', () => {
|
||||
const auth = useAppAuthStore()
|
||||
auth.hydrate(
|
||||
{
|
||||
accountEmail: 'old@ifruit.com',
|
||||
signedIn: ['citymarkt'],
|
||||
version: 1,
|
||||
},
|
||||
'new@ifruit.com',
|
||||
)
|
||||
|
||||
expect(auth.isSignedIn('citymarkt')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
export const APP_AUTH_IDS = [
|
||||
'citymarkt',
|
||||
'local-pages',
|
||||
'feather',
|
||||
'crewlink',
|
||||
] as const
|
||||
|
||||
export type AppAuthId = (typeof APP_AUTH_IDS)[number]
|
||||
|
||||
type PersistedAppAuth = {
|
||||
accountEmail: string
|
||||
signedIn: AppAuthId[]
|
||||
version: 1
|
||||
}
|
||||
|
||||
function emptySessions(): Record<AppAuthId, boolean> {
|
||||
return {
|
||||
citymarkt: false,
|
||||
'local-pages': false,
|
||||
feather: false,
|
||||
crewlink: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const useAppAuthStore = defineStore('app-auth', {
|
||||
state: () => ({
|
||||
accountEmail: '',
|
||||
sessions: emptySessions(),
|
||||
}),
|
||||
actions: {
|
||||
hydrate(payload: unknown, accountEmail: string): void {
|
||||
this.accountEmail = accountEmail
|
||||
this.sessions = emptySessions()
|
||||
if (!accountEmail || !payload || typeof payload !== 'object') return
|
||||
|
||||
const data = payload as Partial<PersistedAppAuth>
|
||||
if (
|
||||
data.version !== 1 ||
|
||||
data.accountEmail !== accountEmail ||
|
||||
!Array.isArray(data.signedIn)
|
||||
)
|
||||
return
|
||||
|
||||
for (const appId of data.signedIn) {
|
||||
if (APP_AUTH_IDS.includes(appId)) this.sessions[appId] = true
|
||||
}
|
||||
},
|
||||
isSignedIn(appId: AppAuthId): boolean {
|
||||
return Boolean(this.accountEmail && this.sessions[appId])
|
||||
},
|
||||
signIn(appId: AppAuthId, accountEmail: string): void {
|
||||
if (this.accountEmail !== accountEmail) this.sessions = emptySessions()
|
||||
this.accountEmail = accountEmail
|
||||
this.sessions[appId] = true
|
||||
this.persist()
|
||||
},
|
||||
signOut(appId: AppAuthId): void {
|
||||
this.sessions[appId] = false
|
||||
this.persist()
|
||||
},
|
||||
clear(): void {
|
||||
this.accountEmail = ''
|
||||
this.sessions = emptySessions()
|
||||
this.persist()
|
||||
},
|
||||
persist(): void {
|
||||
usePhoneStore().saveDeviceNamespace('appAuth', {
|
||||
accountEmail: this.accountEmail,
|
||||
signedIn: APP_AUTH_IDS.filter((appId) => this.sessions[appId]),
|
||||
version: 1,
|
||||
} satisfies PersistedAppAuth)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -62,19 +62,28 @@ export const useCrewLinkStore = defineStore('crewlink', {
|
||||
}
|
||||
return response
|
||||
},
|
||||
createProfile(username: string): Promise<NuiResponse<CrewLinkBootstrap>> {
|
||||
return this.request('crewlink:create-profile', { username })
|
||||
createProfile(
|
||||
username: string,
|
||||
avatarMediaId = 0,
|
||||
): Promise<NuiResponse<CrewLinkBootstrap>> {
|
||||
return this.request('crewlink:create-profile', {
|
||||
avatarMediaId,
|
||||
username,
|
||||
})
|
||||
},
|
||||
updateProfile(
|
||||
username: string,
|
||||
mapVisible: boolean,
|
||||
overheadVisible: boolean,
|
||||
avatarMediaId?: number | null,
|
||||
): Promise<NuiResponse<CrewLinkBootstrap>> {
|
||||
return this.request('crewlink:update-profile', {
|
||||
const data: Record<string, unknown> = {
|
||||
mapVisible,
|
||||
overheadVisible,
|
||||
username,
|
||||
})
|
||||
}
|
||||
if (avatarMediaId !== undefined) data.avatarMediaId = avatarMediaId
|
||||
return this.request('crewlink:update-profile', data)
|
||||
},
|
||||
createGroup(
|
||||
name: string,
|
||||
@@ -107,17 +116,15 @@ export const useCrewLinkStore = defineStore('crewlink', {
|
||||
return this.request('crewlink:join-code', { code })
|
||||
},
|
||||
rotateCode(groupId: string): Promise<NuiResponse<{ inviteCode: string }>> {
|
||||
return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', { groupId })
|
||||
return nuiCall<{ inviteCode: string }>('crewlink:rotate-code', {
|
||||
groupId,
|
||||
})
|
||||
},
|
||||
nearby(): Promise<NuiResponse<CrewLinkNearbyPlayer[]>> {
|
||||
return nuiCall<CrewLinkNearbyPlayer[]>('crewlink:nearby')
|
||||
},
|
||||
inviteNearby(targetSource: number): Promise<NuiResponse> {
|
||||
return this.request(
|
||||
'crewlink:invite-nearby',
|
||||
{ targetSource },
|
||||
false,
|
||||
)
|
||||
return this.request('crewlink:invite-nearby', { targetSource }, false)
|
||||
},
|
||||
respondInvite(
|
||||
invitationId: string,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
describe('phone locale fallback', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('window', {
|
||||
matchMedia: vi.fn(() => ({ matches: false })),
|
||||
})
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps CityMarkt profile copy translated with a partial server locale', () => {
|
||||
const phone = usePhoneStore()
|
||||
phone.open({ locales: { Apps: { citymarkt: { name: 'CityMarkt' } } } })
|
||||
|
||||
expect(phone.t('Apps.citymarkt.editProfile')).toBe('Edit profile')
|
||||
expect(phone.t('Apps.citymarkt.profileIntro')).toBe(
|
||||
'Your iFruit email stays linked to this profile.',
|
||||
)
|
||||
expect(phone.t('Apps.citymarkt.saveProfile')).toBe('Save profile')
|
||||
expect(phone.t('Apps.citymarkt.addFavorite')).toBe('Add to favorites')
|
||||
expect(phone.t('Apps.citymarkt.removeFavorite')).toBe(
|
||||
'Remove from favorites',
|
||||
)
|
||||
})
|
||||
})
|
||||
+112
-25
@@ -421,6 +421,26 @@ const defaultLocales: LocaleTree = {
|
||||
signInBody:
|
||||
'CrewLink uses your private iFruit identity to keep groups and roles available across your phones.',
|
||||
openSettings: 'Open iFruit Settings',
|
||||
authEyebrow: 'Private crew network',
|
||||
authTitle: 'Welcome to CrewLink',
|
||||
authBody:
|
||||
'Your iFruit email is linked automatically. Use your CrewLink username to continue.',
|
||||
login: 'Log in',
|
||||
register: 'Register',
|
||||
ifruitEmail: 'iFruit address',
|
||||
gallery: 'Gallery',
|
||||
camera: 'Camera',
|
||||
backToLogin: 'Back to CrewLink login',
|
||||
authErrors: {
|
||||
no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
|
||||
invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.',
|
||||
profile_not_found: 'No CrewLink profile exists for this iFruit email.',
|
||||
profile_exists: 'This iFruit email already has a CrewLink profile.',
|
||||
username_taken: 'That CrewLink username is already taken.',
|
||||
invalid_profile_image: 'Choose a valid photo from this phone.',
|
||||
rate_limited: 'Too many attempts. Try again shortly.',
|
||||
request_failed: 'CrewLink could not complete the request.',
|
||||
},
|
||||
welcomeEyebrow: 'Your crew. One signal.',
|
||||
welcomeTitle: 'Find your people',
|
||||
welcomeBody:
|
||||
@@ -553,6 +573,9 @@ const defaultLocales: LocaleTree = {
|
||||
externalApiBody: 'Approved scripts may add temporary group pings.',
|
||||
editUsername: 'Edit Username',
|
||||
editUsernameBody: 'Your username is unique across CrewLink.',
|
||||
editProfile: 'Edit CrewLink Profile',
|
||||
editProfileBody: 'Change your username and profile photo.',
|
||||
removeProfilePhoto: 'Remove profile photo',
|
||||
profileSaved: 'CrewLink profile saved.',
|
||||
deleteGroup: 'Delete Group',
|
||||
leaveGroup: 'Leave Group',
|
||||
@@ -592,6 +615,7 @@ const defaultLocales: LocaleTree = {
|
||||
profile_required: 'Create your CrewLink profile first.',
|
||||
invalid_username: 'Use 3–20 letters, numbers, dots, or underscores.',
|
||||
invalid_profile: 'Check your profile details.',
|
||||
invalid_profile_image: 'Choose a valid photo from this phone.',
|
||||
username_taken: 'That CrewLink username is already taken.',
|
||||
invalid_group: 'Choose a valid group name and signal colour.',
|
||||
group_limit: 'You have reached your group limit.',
|
||||
@@ -1003,14 +1027,16 @@ const defaultLocales: LocaleTree = {
|
||||
done: 'Done',
|
||||
authEyebrow: 'Feather network',
|
||||
authWelcome: 'Welcome to Feather',
|
||||
authBody: 'One iFruit account. Every conversation, wherever you sign in.',
|
||||
authBody:
|
||||
'Your iFruit email is linked automatically. Use your Feather username to continue.',
|
||||
login: 'Log in',
|
||||
register: 'Register',
|
||||
loginTitle: 'Good to see you again',
|
||||
loginBody: 'Log in with your iFruit address to continue to Feather.',
|
||||
registerTitle: 'Create your iFruit account',
|
||||
loginBody:
|
||||
'Use your Feather username. Your iFruit email is linked automatically.',
|
||||
registerTitle: 'Create your Feather profile',
|
||||
registerBody:
|
||||
'Choose an address and secure it with a password. Your Feather profile comes next.',
|
||||
'Choose a username and optional profile photo. No password is needed.',
|
||||
email: 'iFruit address',
|
||||
emailPlaceholder: 'yourname',
|
||||
password: 'Password',
|
||||
@@ -1020,23 +1046,25 @@ const defaultLocales: LocaleTree = {
|
||||
showPassword: 'Show password',
|
||||
hidePassword: 'Hide password',
|
||||
loginAction: 'Continue to Feather',
|
||||
registerAction: 'Create account',
|
||||
registerAction: 'Create profile',
|
||||
noAccount: 'New to Feather?',
|
||||
haveAccount: 'Already registered?',
|
||||
registerNow: 'Create an account',
|
||||
registerNow: 'Create a profile',
|
||||
loginNow: 'Log in',
|
||||
authTrust:
|
||||
'Your credentials are verified by the server and this phone is linked to your iFruit account.',
|
||||
'This Feather session is separate from your other apps and linked to your iFruit email.',
|
||||
profileStep: 'Step 2 of 2',
|
||||
accountConnected: 'iFruit account connected',
|
||||
authErrors: {
|
||||
invalid_email: 'Enter a valid 3–32 character iFruit address.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
password_mismatch: 'The passwords do not match.',
|
||||
invalid_credentials: 'The iFruit address or password is incorrect.',
|
||||
email_taken: 'That iFruit address is already registered.',
|
||||
no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
|
||||
invalid_handle: 'Use 3–30 letters, numbers or underscores.',
|
||||
invalid_username: 'That username does not match your Feather profile.',
|
||||
profile_not_found: 'No Feather profile exists for this iFruit email.',
|
||||
already_registered: 'This iFruit email already has a Feather profile.',
|
||||
handle_taken: 'That Feather username is already taken.',
|
||||
invalid_media: 'Choose a valid photo from this phone.',
|
||||
rate_limited: 'Too many attempts. Try again in a minute.',
|
||||
default: 'The account request failed. Please try again.',
|
||||
default: 'Feather could not complete the request. Please try again.',
|
||||
},
|
||||
welcome: 'Find your voice',
|
||||
welcomeBody:
|
||||
@@ -2537,13 +2565,38 @@ const defaultLocales: LocaleTree = {
|
||||
photos: 'photos',
|
||||
activeListings: 'active listings',
|
||||
signInTitle: 'Sign in to iFruit',
|
||||
signInBody:
|
||||
'Use Settings to sign in before selling, saving or messaging.',
|
||||
signInBody: 'Log in to your CityMarkt profile to sell, save or message.',
|
||||
authEyebrow: 'CityMarkt account',
|
||||
authTitle: 'Welcome to CityMarkt',
|
||||
authBody:
|
||||
'Your iFruit email is linked automatically. Use your CityMarkt username to continue.',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
authUsername: 'Username',
|
||||
authErrors: {
|
||||
no_ifruit_account: 'Connect an iFruit account in Settings first.',
|
||||
invalid_username: 'Enter the username of your CityMarkt profile.',
|
||||
profile_not_found: 'No CityMarkt profile exists for this iFruit email.',
|
||||
profile_exists:
|
||||
'A CityMarkt profile already exists. Use Login instead.',
|
||||
},
|
||||
noMessages: 'No conversations',
|
||||
noMessagesBody: 'Messages about offers will appear here.',
|
||||
myListings: 'My listings',
|
||||
favorites: 'Favorites',
|
||||
addFavorite: 'Add to favorites',
|
||||
removeFavorite: 'Remove from favorites',
|
||||
noProfileListings: 'Nothing here yet',
|
||||
createProfile: 'Create your CityMarkt profile',
|
||||
editProfile: 'Edit profile',
|
||||
profileIntro: 'Your iFruit email stays linked to this profile.',
|
||||
profileEmail: 'iFruit email',
|
||||
displayName: 'Display name',
|
||||
profileBio: 'About you',
|
||||
saveProfile: 'Save profile',
|
||||
cancel: 'Cancel',
|
||||
profileSaved: 'Your profile was saved.',
|
||||
removeProfilePhoto: 'Remove photo',
|
||||
phone: 'Phone',
|
||||
contactSeller: 'Contact seller',
|
||||
messagePlaceholder: 'Hi, is this still available?',
|
||||
@@ -2683,13 +2736,16 @@ const defaultLocales: LocaleTree = {
|
||||
signInBody: 'Sign in to iFruit in Settings to publish and save posts.',
|
||||
authEyebrow: 'Local Pages account',
|
||||
authWelcome: 'Welcome to Local Pages',
|
||||
authBody: 'Sign in or create an iFruit account to build your local profile.',
|
||||
authBody:
|
||||
'Your iFruit email is linked automatically. Use your Local Pages username to continue.',
|
||||
login: 'Sign in',
|
||||
register: 'Register',
|
||||
loginTitle: 'Continue with iFruit',
|
||||
loginBody: 'Your posts, saved items and profile stay linked to your account.',
|
||||
loginBody:
|
||||
'Your posts, saved items and profile stay linked to your account.',
|
||||
registerTitle: 'Create an iFruit account',
|
||||
registerBody: 'Choose your new iFruit address. Your Local Pages profile comes next.',
|
||||
registerBody:
|
||||
'Choose your new iFruit address. Your Local Pages profile comes next.',
|
||||
authEmail: 'iFruit address',
|
||||
authEmailPlaceholder: 'your.name',
|
||||
authPassword: 'Password',
|
||||
@@ -2705,7 +2761,8 @@ const defaultLocales: LocaleTree = {
|
||||
profileEmail: 'iFruit email',
|
||||
profileHandle: 'Username',
|
||||
profileHandlePlaceholder: 'your.name',
|
||||
profileHandleHint: 'Use 3–24 lowercase letters, numbers, dots or underscores.',
|
||||
profileHandleHint:
|
||||
'Use 3–24 lowercase letters, numbers, dots or underscores.',
|
||||
profileBio: 'Bio',
|
||||
profileBioPlaceholder: 'Tell the city a little about yourself...',
|
||||
profilePhoto: 'Profile photo',
|
||||
@@ -2751,13 +2808,17 @@ const defaultLocales: LocaleTree = {
|
||||
'Review the listing and publish it when you are ready.',
|
||||
cityMarktPhotosHint: 'The CityMarkt listing photos will be included.',
|
||||
authErrors: {
|
||||
invalid_email: 'Enter a valid 3–32 character iFruit address.',
|
||||
invalid_password: 'Use a password between 6 and 64 characters.',
|
||||
invalid_credentials: 'The iFruit address or password is incorrect.',
|
||||
email_taken: 'This iFruit address is already registered.',
|
||||
password_mismatch: 'The passwords do not match.',
|
||||
no_ifruit_account: 'Sign in to your iFruit account in Settings first.',
|
||||
invalid_username:
|
||||
'Use 3–24 lowercase letters, numbers, dots or underscores.',
|
||||
profile_not_found:
|
||||
'No Local Pages profile exists for this iFruit email.',
|
||||
profile_exists: 'This iFruit email already has a Local Pages profile.',
|
||||
invalid_profile: 'Check your Local Pages username.',
|
||||
invalid_profile_image: 'Choose a valid photo from this phone.',
|
||||
profile_handle_taken: 'This Local Pages username is already taken.',
|
||||
rate_limited: 'Too many attempts. Try again shortly.',
|
||||
default: 'The iFruit account request failed.',
|
||||
default: 'Local Pages could not complete the request.',
|
||||
},
|
||||
errors: {
|
||||
profile_required: 'Create your Local Pages profile first.',
|
||||
@@ -3469,6 +3530,32 @@ const defaultLocales: LocaleTree = {
|
||||
send: 'Send',
|
||||
start: 'Start',
|
||||
stop: 'Stop',
|
||||
signOut: 'Sign Out',
|
||||
signingOut: 'Signing Out...',
|
||||
signOutTitle: 'Sign out of {app}?',
|
||||
signOutBody:
|
||||
'You will only be signed out of {app}. Your other iFruit apps stay signed in.',
|
||||
signOutFailed: 'Could not sign out. Please try again.',
|
||||
appAuth: {
|
||||
eyebrow: 'iFruit account',
|
||||
title: 'Continue to {app}',
|
||||
body: 'Use your iFruit email and password. This login applies only to {app}.',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
email: 'iFruit email',
|
||||
password: 'Password',
|
||||
confirm: 'Confirm password',
|
||||
loginAction: 'Log in',
|
||||
registerAction: 'Create account',
|
||||
errors: {
|
||||
invalid_email: 'Enter a valid iFruit email.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
invalid_credentials: 'Email or password is incorrect.',
|
||||
email_taken: 'That iFruit email is already registered.',
|
||||
rate_limited: 'Too many attempts. Try again in a minute.',
|
||||
default: 'The account request failed.',
|
||||
},
|
||||
},
|
||||
use: 'Use',
|
||||
},
|
||||
Notifications: {
|
||||
|
||||
@@ -15,15 +15,12 @@ export type CrewLinkColour =
|
||||
| 'green'
|
||||
| 'rose'
|
||||
|
||||
export type CrewLinkPingType =
|
||||
| 'meeting'
|
||||
| 'danger'
|
||||
| 'help'
|
||||
| 'target'
|
||||
| 'info'
|
||||
export type CrewLinkPingType = 'meeting' | 'danger' | 'help' | 'target' | 'info'
|
||||
|
||||
export type CrewLinkProfile = {
|
||||
activeGroupId: string | null
|
||||
avatarMediaId: number | null
|
||||
avatarUrl: string | null
|
||||
id: string
|
||||
mapVisible: boolean
|
||||
overheadVisible: boolean
|
||||
@@ -31,6 +28,7 @@ export type CrewLinkProfile = {
|
||||
}
|
||||
|
||||
export type CrewLinkMember = {
|
||||
avatarUrl?: string | null
|
||||
coords?: MapPoint & { z: number }
|
||||
id: string
|
||||
joinedAt: number
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
Inbox,
|
||||
Laptop,
|
||||
LayoutGrid,
|
||||
LogOut,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
MoreHorizontal,
|
||||
@@ -50,7 +51,10 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
|
||||
import CityMarktGallery from '@/components/citymarkt/CityMarktGallery.vue'
|
||||
import CityMarktOfferCard from '@/components/citymarkt/CityMarktOfferCard.vue'
|
||||
import CityMarktAuth from '@/components/citymarkt/CityMarktAuth.vue'
|
||||
import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore } from '@/stores/app-auth'
|
||||
import { useAppStoreStore } from '@/stores/app-store'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import { useMarketplaceStore } from '@/stores/marketplace'
|
||||
@@ -105,7 +109,10 @@ type MediaContext = {
|
||||
photos: SelectedPhoto[]
|
||||
sellStep: number
|
||||
}
|
||||
type ProfileMediaContext = { draft: MarketplaceProfileDraft }
|
||||
type ProfileMediaContext = {
|
||||
authMode?: 'login' | 'register'
|
||||
draft: MarketplaceProfileDraft
|
||||
}
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const detailActionColors = computed(() => ({
|
||||
@@ -115,11 +122,15 @@ const detailActionColors = computed(() => ({
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const appStore = useAppStoreStore()
|
||||
const easyShare = useEasyShareStore()
|
||||
const marketplace = useMarketplaceStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const pages = usePagesStore()
|
||||
const logoutDialogOpen = ref(false)
|
||||
const authMode = ref<'login' | 'register'>('login')
|
||||
const authError = ref('')
|
||||
const tab = ref<Tab>('discover')
|
||||
const screen = ref<Screen>('main')
|
||||
const selectedListing = ref<MarketplaceListing | null>(null)
|
||||
@@ -239,7 +250,7 @@ const tabs = [
|
||||
{ icon: UserRound, id: 'profile' },
|
||||
] as const
|
||||
|
||||
const isAuthenticated = computed(() => account.email !== '')
|
||||
const isAuthenticated = computed(() => appAuth.isSignedIn('citymarkt'))
|
||||
const localPagesInstalled = computed(
|
||||
() =>
|
||||
appStore.isInstalled('local-pages') &&
|
||||
@@ -529,6 +540,80 @@ async function selectTab(next: Tab): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function finishAuthentication(): Promise<void> {
|
||||
await marketplace.loadProfile()
|
||||
syncProfileDraft()
|
||||
profileEditing.value = !marketplace.profile?.exists
|
||||
await Promise.all([
|
||||
marketplace.loadOwn(),
|
||||
marketplace.load({ favorites: true }),
|
||||
])
|
||||
tab.value = 'profile'
|
||||
screen.value = 'main'
|
||||
}
|
||||
|
||||
function switchAuthMode(mode: 'login' | 'register'): void {
|
||||
authMode.value = mode
|
||||
authError.value = ''
|
||||
profileDraft.value.displayName =
|
||||
mode === 'login'
|
||||
? (marketplace.profile?.display_name ?? '')
|
||||
: account.email.split('@')[0] ?? ''
|
||||
if (mode === 'login') selectedProfilePhoto.value = null
|
||||
}
|
||||
|
||||
async function submitCityMarktAuth(): Promise<void> {
|
||||
const username = profileDraft.value.displayName.trim()
|
||||
authError.value = ''
|
||||
if (!account.email) {
|
||||
authError.value = phone.t('Apps.citymarkt.authErrors.no_ifruit_account')
|
||||
return
|
||||
}
|
||||
if (username.length < 2 || username.length > 40) {
|
||||
authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
|
||||
return
|
||||
}
|
||||
|
||||
profilePending.value = true
|
||||
await marketplace.loadProfile()
|
||||
if (authMode.value === 'login') {
|
||||
profilePending.value = false
|
||||
if (!marketplace.profile?.exists) {
|
||||
authError.value = phone.t('Apps.citymarkt.authErrors.profile_not_found')
|
||||
return
|
||||
}
|
||||
if (
|
||||
marketplace.profile.display_name.trim().toLocaleLowerCase(phone.lang) !==
|
||||
username.toLocaleLowerCase(phone.lang)
|
||||
) {
|
||||
authError.value = phone.t('Apps.citymarkt.authErrors.invalid_username')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (marketplace.profile?.exists) {
|
||||
profilePending.value = false
|
||||
authError.value = phone.t('Apps.citymarkt.authErrors.profile_exists')
|
||||
return
|
||||
}
|
||||
const response = await marketplace.saveProfile({
|
||||
avatarMediaId:
|
||||
selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
|
||||
bio: '',
|
||||
displayName: username,
|
||||
})
|
||||
profilePending.value = false
|
||||
if (!response.success) {
|
||||
authError.value = phone.t(
|
||||
`Apps.citymarkt.errors.${response.error ?? 'default'}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
appAuth.signIn('citymarkt', account.email)
|
||||
await finishAuthentication()
|
||||
}
|
||||
|
||||
async function openListing(
|
||||
item: Pick<MarketplaceListingSummary, 'id'>,
|
||||
): Promise<void> {
|
||||
@@ -578,9 +663,12 @@ function openProfileMedia(app: 'camera' | 'photos'): void {
|
||||
'photo',
|
||||
'/apps/citymarkt?profileEdit=1',
|
||||
1,
|
||||
{ draft: { ...profileDraft.value } } satisfies ProfileMediaContext,
|
||||
{
|
||||
authMode: isAuthenticated.value ? undefined : authMode.value,
|
||||
draft: { ...profileDraft.value },
|
||||
} satisfies ProfileMediaContext,
|
||||
)
|
||||
void router.push(app === 'photos' ? '/apps/photos?picker=1' : '/apps/camera?picker=1')
|
||||
void router.push(`/apps/${app}?mediaAttachment=photo`)
|
||||
}
|
||||
|
||||
function removeProfilePhoto(): void {
|
||||
@@ -905,11 +993,13 @@ onMounted(async () => {
|
||||
}
|
||||
if (profileSelection?.context) {
|
||||
profileDraft.value = profileSelection.context.draft
|
||||
if (profileSelection.context.authMode)
|
||||
authMode.value = profileSelection.context.authMode
|
||||
if (profileSelection.media[0]) {
|
||||
selectedProfilePhoto.value = profileSelection.media[0]
|
||||
profileDraft.value.avatarMediaId = profileSelection.media[0].id
|
||||
}
|
||||
profileEditing.value = true
|
||||
profileEditing.value = isAuthenticated.value
|
||||
tab.value = 'profile'
|
||||
screen.value = 'main'
|
||||
}
|
||||
@@ -932,6 +1022,8 @@ onMounted(async () => {
|
||||
}
|
||||
await marketplace.loadCounts()
|
||||
} else {
|
||||
await marketplace.loadProfile()
|
||||
switchAuthMode(marketplace.profile?.exists ? 'login' : 'register')
|
||||
tab.value = 'profile'
|
||||
screen.value = 'main'
|
||||
}
|
||||
@@ -1127,6 +1219,9 @@ onMounted(async () => {
|
||||
<UserRound :size="40" />
|
||||
<h2>{{ phone.t('Apps.citymarkt.signInTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.citymarkt.signInBody') }}</p>
|
||||
<k-button rounded @click="tab = 'profile'">
|
||||
{{ phone.t('Apps.citymarkt.login') }}
|
||||
</k-button>
|
||||
</div>
|
||||
<div v-else-if="!marketplace.inquiries.length" class="citymarkt__empty">
|
||||
<MessageCircle :size="36" /><strong>{{
|
||||
@@ -1156,9 +1251,17 @@ onMounted(async () => {
|
||||
|
||||
<template v-else-if="tab === 'profile'">
|
||||
<div v-if="!isAuthenticated" class="citymarkt__auth">
|
||||
<UserRound :size="40" />
|
||||
<h2>{{ phone.t('Apps.citymarkt.signInTitle') }}</h2>
|
||||
<p>{{ phone.t('Apps.citymarkt.signInBody') }}</p>
|
||||
<CityMarktAuth
|
||||
v-model:mode="authMode"
|
||||
v-model:username="profileDraft.displayName"
|
||||
:avatar-url="selectedProfilePhoto?.url ?? null"
|
||||
:email="account.email"
|
||||
:error="authError"
|
||||
:pending="profilePending"
|
||||
@camera="openProfileMedia('camera')"
|
||||
@gallery="openProfileMedia('photos')"
|
||||
@submit="submitCityMarktAuth"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<section v-if="profileEditing || !marketplace.profile?.exists" class="citymarkt__profile-editor">
|
||||
@@ -1255,6 +1358,10 @@ onMounted(async () => {
|
||||
></k-glass>
|
||||
</div>
|
||||
</template>
|
||||
<k-button large rounded outline class="citymarkt__logout" @click="logoutDialogOpen = true">
|
||||
<LogOut :size="17" />
|
||||
{{ phone.t('Common.signOut') }}
|
||||
</k-button>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
@@ -1849,6 +1956,11 @@ onMounted(async () => {
|
||||
</k-tabbar-link>
|
||||
</k-toolbar-pane>
|
||||
</k-tabbar>
|
||||
<AccountLogoutDialog
|
||||
v-model:opened="logoutDialogOpen"
|
||||
app-id="citymarkt"
|
||||
:app-name="phone.t('Apps.citymarkt.name')"
|
||||
/>
|
||||
<Transition name="toast"
|
||||
><div v-if="feedback" class="citymarkt__toast">
|
||||
{{ feedback }}
|
||||
@@ -2503,6 +2615,11 @@ onMounted(async () => {
|
||||
.citymarkt__profile-actions button:disabled {
|
||||
opacity: .4;
|
||||
}
|
||||
.citymarkt__logout {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
color: #ff796f;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt__profile-editor {
|
||||
border-color: #00000012;
|
||||
}
|
||||
|
||||
+2460
-205
File diff suppressed because it is too large
Load Diff
@@ -9,20 +9,16 @@ import {
|
||||
CheckCircle2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Feather,
|
||||
Home,
|
||||
ImagePlus,
|
||||
Images,
|
||||
KeyRound,
|
||||
Mail,
|
||||
LogOut,
|
||||
MessageCircle,
|
||||
PencilLine,
|
||||
Plus,
|
||||
Search,
|
||||
Share2,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
@@ -59,18 +55,16 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import FeatherPostCard from '@/components/feather/FeatherPostCard.vue'
|
||||
import AccountLogoutDialog from '@/components/account/AccountLogoutDialog.vue'
|
||||
import AppProfileAuth from '@/components/account/AppProfileAuth.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore } from '@/stores/app-auth'
|
||||
import { useFeatherStore } from '@/stores/feather'
|
||||
import { useEasyShareStore } from '@/stores/easyshare'
|
||||
import type { FeatherConnectionMode } from '@/stores/feather'
|
||||
import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import type { FeatherMedia, FeatherPost, FeatherProfile } from '@/types/feather'
|
||||
import {
|
||||
filterMailAddressInput,
|
||||
MAIL_ADDRESS_INPUT_MAX_LENGTH,
|
||||
normalizeMailAddress,
|
||||
} from '@/utils/mail'
|
||||
|
||||
type Tab = 'home' | 'explore' | 'network' | 'activity' | 'profile'
|
||||
type Screen =
|
||||
@@ -93,9 +87,15 @@ type ProfileMediaContext = {
|
||||
editing: { bio: string; displayName: string }
|
||||
selectedPhoto: SelectedPhoto | null
|
||||
}
|
||||
type AuthMediaContext = {
|
||||
mode: 'login' | 'register'
|
||||
selectedPhoto: SelectedPhoto | null
|
||||
username: string
|
||||
}
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const feather = useFeatherStore()
|
||||
const messageMedia = useMessageMediaStore()
|
||||
const route = useRoute()
|
||||
@@ -107,6 +107,7 @@ const activityView = ref<ActivityView>('all')
|
||||
const profileView = ref<ProfileView>('posts')
|
||||
const connectionMode = ref<FeatherConnectionMode>('followers')
|
||||
const settingsOpen = ref(false)
|
||||
const logoutDialogOpen = ref(false)
|
||||
const compactMode = ref(false)
|
||||
const showSuggestions = ref(true)
|
||||
const search = ref('')
|
||||
@@ -128,11 +129,10 @@ const onboarding = ref({ bio: '', displayName: '', handle: '' })
|
||||
const editing = ref({ bio: '', displayName: '' })
|
||||
const selectedProfilePhoto = ref<SelectedPhoto | null>(null)
|
||||
const authMode = ref<'login' | 'register'>('login')
|
||||
const authForm = ref({ confirm: '', email: '', password: '' })
|
||||
const authUsername = ref('')
|
||||
const authProfilePhoto = ref<SelectedPhoto | null>(null)
|
||||
const authBusy = ref(false)
|
||||
const authAttempted = ref(false)
|
||||
const authError = ref('')
|
||||
const authPasswordVisible = ref(false)
|
||||
const composeFabColors = {
|
||||
activeBgIos: 'active:!bg-[#2778dc] dark:active:!bg-[#2778dc]',
|
||||
bgIos: '!bg-[#58a6ff] dark:!bg-[#58a6ff]',
|
||||
@@ -244,18 +244,9 @@ const navbarTitle = computed(() => {
|
||||
const unreadActivities = computed(
|
||||
() => feather.activities.filter((item) => !item.read).length,
|
||||
)
|
||||
const authEmailValid = computed(
|
||||
() => normalizeMailAddress(authForm.value.email) !== null,
|
||||
)
|
||||
const authPasswordValid = computed(() => {
|
||||
const length = authForm.value.password.length
|
||||
return length >= 6 && length <= 64
|
||||
})
|
||||
const authConfirmValid = computed(
|
||||
() =>
|
||||
authMode.value === 'login' ||
|
||||
(authForm.value.confirm.length > 0 &&
|
||||
authForm.value.confirm === authForm.value.password),
|
||||
const isAuthenticated = computed(() => appAuth.isSignedIn('feather'))
|
||||
const authUsernameValid = computed(() =>
|
||||
/^[a-z0-9][a-z0-9_]{1,28}[a-z0-9]$/i.test(authUsername.value.trim()),
|
||||
)
|
||||
|
||||
function t(path: string, params?: Record<string, string>): string {
|
||||
@@ -303,13 +294,6 @@ function errorToast(error?: string): void {
|
||||
toast(key)
|
||||
}
|
||||
|
||||
function updateAuthEmail(event: Event): void {
|
||||
const input = event.target as HTMLInputElement
|
||||
const filtered = filterMailAddressInput(input.value)
|
||||
if (input.value !== filtered) input.value = filtered
|
||||
authForm.value.email = filtered
|
||||
}
|
||||
|
||||
function inputValue(event: Event): string {
|
||||
const target = event.target
|
||||
if (
|
||||
@@ -324,55 +308,101 @@ function inputValue(event: Event): string {
|
||||
|
||||
function switchAuthMode(mode: 'login' | 'register'): void {
|
||||
authMode.value = mode
|
||||
authForm.value.confirm = ''
|
||||
authProfilePhoto.value = null
|
||||
if (mode === 'register') {
|
||||
authUsername.value = (account.email.split('@')[0] ?? '')
|
||||
.replace(/[^a-z0-9_]/gi, '_')
|
||||
.slice(0, 30)
|
||||
} else {
|
||||
authUsername.value = ''
|
||||
}
|
||||
authError.value = ''
|
||||
authAttempted.value = false
|
||||
}
|
||||
|
||||
function authErrorMessage(error?: string): string {
|
||||
const known = [
|
||||
'invalid_email',
|
||||
'invalid_password',
|
||||
'invalid_credentials',
|
||||
'email_taken',
|
||||
'already_registered',
|
||||
'handle_taken',
|
||||
'invalid_handle',
|
||||
'invalid_media',
|
||||
'invalid_username',
|
||||
'no_ifruit_account',
|
||||
'profile_not_found',
|
||||
'rate_limited',
|
||||
]
|
||||
return t(`authErrors.${error && known.includes(error) ? error : 'default'}`)
|
||||
}
|
||||
|
||||
async function submitAuth(): Promise<void> {
|
||||
authAttempted.value = true
|
||||
authError.value = ''
|
||||
if (!authEmailValid.value) {
|
||||
authError.value = t('authErrors.invalid_email')
|
||||
if (!account.email) {
|
||||
authError.value = t('authErrors.no_ifruit_account')
|
||||
return
|
||||
}
|
||||
if (!authPasswordValid.value) {
|
||||
authError.value = t('authErrors.invalid_password')
|
||||
return
|
||||
}
|
||||
if (!authConfirmValid.value) {
|
||||
authError.value = t('authErrors.password_mismatch')
|
||||
if (!authUsernameValid.value) {
|
||||
authError.value = t('authErrors.invalid_handle')
|
||||
return
|
||||
}
|
||||
|
||||
const email = normalizeMailAddress(authForm.value.email)
|
||||
if (!email) return
|
||||
const username = authUsername.value.trim().toLowerCase()
|
||||
authBusy.value = true
|
||||
const response =
|
||||
authMode.value === 'login'
|
||||
? await account.login(email, authForm.value.password)
|
||||
: await account.register(email, authForm.value.password)
|
||||
authBusy.value = false
|
||||
if (!response.success) {
|
||||
authError.value = authErrorMessage(response.error)
|
||||
const bootstrapped = await feather.bootstrap()
|
||||
if (!bootstrapped) {
|
||||
authBusy.value = false
|
||||
authError.value = authErrorMessage()
|
||||
return
|
||||
}
|
||||
if (authMode.value === 'login') {
|
||||
authBusy.value = false
|
||||
if (!feather.onboarded || !feather.profile) {
|
||||
authError.value = t('authErrors.profile_not_found')
|
||||
return
|
||||
}
|
||||
if (feather.profile.handle.toLowerCase() !== username) {
|
||||
authError.value = t('authErrors.invalid_username')
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if (feather.onboarded || feather.profile) {
|
||||
authBusy.value = false
|
||||
authError.value = t('authErrors.already_registered')
|
||||
return
|
||||
}
|
||||
const response = await feather.createProfile({
|
||||
avatarId: authProfilePhoto.value?.id,
|
||||
bio: '',
|
||||
displayName: authUsername.value.trim(),
|
||||
handle: username,
|
||||
})
|
||||
authBusy.value = false
|
||||
if (!response.success) {
|
||||
authError.value = authErrorMessage(response.error)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
authForm.value = { confirm: '', email: '', password: '' }
|
||||
authAttempted.value = false
|
||||
authPasswordVisible.value = false
|
||||
await feather.bootstrap()
|
||||
appAuth.signIn('feather', account.email)
|
||||
authUsername.value = ''
|
||||
authProfilePhoto.value = null
|
||||
if (authMode.value === 'login') await feather.bootstrap()
|
||||
}
|
||||
|
||||
function openAuthMedia(app: 'camera' | 'photos'): void {
|
||||
messageMedia.begin(
|
||||
'feather:auth-avatar',
|
||||
'photo',
|
||||
'/apps/feather?auth=register',
|
||||
1,
|
||||
{
|
||||
mode: authMode.value,
|
||||
selectedPhoto: authProfilePhoto.value,
|
||||
username: authUsername.value,
|
||||
} satisfies AuthMediaContext,
|
||||
)
|
||||
void router.push({
|
||||
path: `/apps/${app}`,
|
||||
query: { mediaAttachment: 'photo' },
|
||||
})
|
||||
}
|
||||
|
||||
async function createProfile(): Promise<void> {
|
||||
@@ -738,8 +768,12 @@ onMounted(async () => {
|
||||
window.addEventListener('keydown', handleMediaPreviewKeydown)
|
||||
const selection =
|
||||
messageMedia.consumeMany<ComposerContext>('feather:composer')
|
||||
const profileSelection =
|
||||
messageMedia.consumeMany<ProfileMediaContext>('feather:profile-avatar')
|
||||
const profileSelection = messageMedia.consumeMany<ProfileMediaContext>(
|
||||
'feather:profile-avatar',
|
||||
)
|
||||
const authSelection = messageMedia.consumeMany<AuthMediaContext>(
|
||||
'feather:auth-avatar',
|
||||
)
|
||||
if (selection) {
|
||||
if (selection.context) {
|
||||
composerBody.value = selection.context.body
|
||||
@@ -763,6 +797,19 @@ onMounted(async () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (authSelection) {
|
||||
if (authSelection.context) {
|
||||
authMode.value = authSelection.context.mode
|
||||
authUsername.value = authSelection.context.username
|
||||
authProfilePhoto.value = authSelection.context.selectedPhoto
|
||||
}
|
||||
if (authSelection.media[0]) {
|
||||
authProfilePhoto.value = {
|
||||
id: authSelection.media[0].id,
|
||||
url: authSelection.media[0].url,
|
||||
}
|
||||
}
|
||||
}
|
||||
if (route.query.compose === '1') screen.value = 'composer'
|
||||
await feather.bootstrap()
|
||||
if (route.query.profileEdit === '1' && feather.profile) screen.value = 'edit'
|
||||
@@ -790,18 +837,24 @@ onMounted(async () => {
|
||||
component="main"
|
||||
class="feather-app"
|
||||
:class="{
|
||||
'feather-app--active': feather.onboarded,
|
||||
'feather-app--active': feather.onboarded && isAuthenticated,
|
||||
'feather-app--home':
|
||||
feather.onboarded && screen === 'main' && tab === 'home',
|
||||
'feather-app--light': feather.onboarded && !phone.isDarkMode,
|
||||
feather.onboarded &&
|
||||
isAuthenticated &&
|
||||
screen === 'main' &&
|
||||
tab === 'home',
|
||||
'feather-app--light': !phone.isDarkMode,
|
||||
'feather-app--section':
|
||||
feather.onboarded && screen === 'main' && tab !== 'home',
|
||||
'native-app': feather.onboarded,
|
||||
feather.onboarded &&
|
||||
isAuthenticated &&
|
||||
screen === 'main' &&
|
||||
tab !== 'home',
|
||||
'native-app': feather.onboarded && isAuthenticated,
|
||||
'feather-app--compact': compactMode,
|
||||
}"
|
||||
>
|
||||
<kNavbar
|
||||
v-if="feather.onboarded"
|
||||
v-if="feather.onboarded && isAuthenticated"
|
||||
class="feather-navbar"
|
||||
:left-class="
|
||||
screen === 'composer' ? 'feather-navbar__plain-action' : undefined
|
||||
@@ -874,150 +927,31 @@ onMounted(async () => {
|
||||
<span>{{ t('loading') }}</span>
|
||||
</div>
|
||||
|
||||
<section v-else-if="!account.email" class="feather-auth">
|
||||
<header class="feather-auth__hero">
|
||||
<div class="feather-welcome__mark"><Feather :size="43" /></div>
|
||||
<div>
|
||||
<span>{{ t('authEyebrow') }}</span>
|
||||
<h1>{{ t('authWelcome') }}</h1>
|
||||
<p>{{ t('authBody') }}</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form class="feather-auth__card" @submit.prevent="submitAuth">
|
||||
<kSegmented raised class="feather-auth__modes">
|
||||
<kSegmentedButton
|
||||
type="button"
|
||||
:active="authMode === 'login'"
|
||||
@click="switchAuthMode('login')"
|
||||
>
|
||||
{{ t('login') }}
|
||||
</kSegmentedButton>
|
||||
<kSegmentedButton
|
||||
type="button"
|
||||
:active="authMode === 'register'"
|
||||
@click="switchAuthMode('register')"
|
||||
>
|
||||
{{ t('register') }}
|
||||
</kSegmentedButton>
|
||||
</kSegmented>
|
||||
|
||||
<div class="feather-auth__copy">
|
||||
<h2>
|
||||
{{ t(authMode === 'login' ? 'loginTitle' : 'registerTitle') }}
|
||||
</h2>
|
||||
<p>{{ t(authMode === 'login' ? 'loginBody' : 'registerBody') }}</p>
|
||||
</div>
|
||||
|
||||
<kList strong inset class="feather-auth__fields">
|
||||
<kListInput
|
||||
class="relative"
|
||||
:value="authForm.email"
|
||||
:label="t('email')"
|
||||
:placeholder="t('emailPlaceholder')"
|
||||
:maxlength="MAIL_ADDRESS_INPUT_MAX_LENGTH"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
inputmode="email"
|
||||
spellcheck="false"
|
||||
:input-class="!authForm.email.includes('@') ? 'pr-20' : undefined"
|
||||
:error="
|
||||
authAttempted && !authEmailValid
|
||||
? t('authErrors.invalid_email')
|
||||
: ''
|
||||
"
|
||||
@input="updateAuthEmail"
|
||||
>
|
||||
<template #media><Mail :size="19" /></template>
|
||||
<span
|
||||
v-if="!authForm.email.includes('@')"
|
||||
class="feather-auth__domain"
|
||||
>@ifruit.com</span
|
||||
>
|
||||
</kListInput>
|
||||
<kListInput
|
||||
:value="authForm.password"
|
||||
class="relative"
|
||||
:type="authPasswordVisible ? 'text' : 'password'"
|
||||
:label="t('password')"
|
||||
:placeholder="t('passwordPlaceholder')"
|
||||
:maxlength="64"
|
||||
:autocomplete="
|
||||
authMode === 'login' ? 'current-password' : 'new-password'
|
||||
"
|
||||
:error="
|
||||
authAttempted && !authPasswordValid
|
||||
? t('authErrors.invalid_password')
|
||||
: ''
|
||||
"
|
||||
@input="authForm.password = inputValue($event)"
|
||||
>
|
||||
<template #media><KeyRound :size="19" /></template>
|
||||
<button
|
||||
class="feather-auth__reveal"
|
||||
type="button"
|
||||
:aria-label="
|
||||
t(authPasswordVisible ? 'hidePassword' : 'showPassword')
|
||||
"
|
||||
@click="authPasswordVisible = !authPasswordVisible"
|
||||
>
|
||||
<EyeOff v-if="authPasswordVisible" :size="18" />
|
||||
<Eye v-else :size="18" />
|
||||
</button>
|
||||
</kListInput>
|
||||
<kListInput
|
||||
v-if="authMode === 'register'"
|
||||
:value="authForm.confirm"
|
||||
:type="authPasswordVisible ? 'text' : 'password'"
|
||||
:label="t('confirmPassword')"
|
||||
:placeholder="t('confirmPasswordPlaceholder')"
|
||||
:maxlength="64"
|
||||
autocomplete="new-password"
|
||||
:error="
|
||||
authAttempted && !authConfirmValid
|
||||
? t('authErrors.password_mismatch')
|
||||
: ''
|
||||
"
|
||||
@input="authForm.confirm = inputValue($event)"
|
||||
>
|
||||
<template #media><ShieldCheck :size="19" /></template>
|
||||
</kListInput>
|
||||
</kList>
|
||||
|
||||
<div v-if="authError" class="feather-auth__error" role="alert">
|
||||
{{ authError }}
|
||||
</div>
|
||||
|
||||
<kButton
|
||||
component="button"
|
||||
type="submit"
|
||||
large
|
||||
rounded
|
||||
:disabled="authBusy"
|
||||
class="feather-primary feather-auth__submit"
|
||||
>
|
||||
<kPreloader v-if="authBusy" />
|
||||
<template v-else>
|
||||
{{ t(authMode === 'login' ? 'loginAction' : 'registerAction') }}
|
||||
</template>
|
||||
</kButton>
|
||||
|
||||
<p class="feather-auth__switch">
|
||||
{{ t(authMode === 'login' ? 'noAccount' : 'haveAccount') }}
|
||||
<button
|
||||
type="button"
|
||||
@click="switchAuthMode(authMode === 'login' ? 'register' : 'login')"
|
||||
>
|
||||
{{ t(authMode === 'login' ? 'registerNow' : 'loginNow') }}
|
||||
</button>
|
||||
</p>
|
||||
</form>
|
||||
|
||||
<div class="feather-auth__trust">
|
||||
<ShieldCheck :size="16" />
|
||||
<span>{{ t('authTrust') }}</span>
|
||||
</div>
|
||||
<section v-else-if="!isAuthenticated" class="feather-auth">
|
||||
<AppProfileAuth
|
||||
:mode="authMode"
|
||||
v-model:username="authUsername"
|
||||
:avatar-url="authProfilePhoto?.url ?? null"
|
||||
:body="t('authBody')"
|
||||
:camera-label="t('takePhoto')"
|
||||
:email="account.email"
|
||||
:email-label="t('email')"
|
||||
:error="authError"
|
||||
:eyebrow="t('authEyebrow')"
|
||||
:gallery-label="t('chooseGallery')"
|
||||
:login-label="t('login')"
|
||||
:max-username-length="30"
|
||||
:min-username-length="3"
|
||||
:pending="authBusy"
|
||||
:register-label="t('register')"
|
||||
:title="t('authWelcome')"
|
||||
:username-label="t('handle')"
|
||||
:username-placeholder="t('handlePlaceholder')"
|
||||
@camera="openAuthMedia('camera')"
|
||||
@gallery="openAuthMedia('photos')"
|
||||
@submit="submitAuth"
|
||||
@update:mode="switchAuthMode"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section v-else-if="!feather.onboarded" class="feather-onboarding">
|
||||
@@ -1080,6 +1014,16 @@ onMounted(async () => {
|
||||
>
|
||||
{{ t('start') }}
|
||||
</kButton>
|
||||
<kButton
|
||||
large
|
||||
rounded
|
||||
outline
|
||||
class="feather-onboarding__logout"
|
||||
@click="logoutDialogOpen = true"
|
||||
>
|
||||
<LogOut :size="16" />
|
||||
{{ phone.t('Common.signOut') }}
|
||||
</kButton>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
@@ -1234,7 +1178,6 @@ onMounted(async () => {
|
||||
<template #media><AlignLeft :size="18" /></template>
|
||||
</kListInput>
|
||||
</kList>
|
||||
|
||||
</section>
|
||||
|
||||
<section
|
||||
@@ -1516,6 +1459,15 @@ onMounted(async () => {
|
||||
<PencilLine :size="15" />
|
||||
<span>{{ t('editProfile') }}</span>
|
||||
</kButton>
|
||||
<kButton
|
||||
rounded
|
||||
outline
|
||||
class="feather-profile-action feather-profile-action--logout"
|
||||
@click="logoutDialogOpen = true"
|
||||
>
|
||||
<LogOut :size="15" />
|
||||
<span>{{ phone.t('Common.signOut') }}</span>
|
||||
</kButton>
|
||||
</div>
|
||||
</div>
|
||||
</kGlass>
|
||||
@@ -2084,6 +2036,12 @@ onMounted(async () => {
|
||||
</kFab>
|
||||
</template>
|
||||
|
||||
<AccountLogoutDialog
|
||||
v-model:opened="logoutDialogOpen"
|
||||
app-id="feather"
|
||||
:app-name="t('name')"
|
||||
/>
|
||||
|
||||
<kSheet :opened="settingsOpen" @backdropclick="settingsOpen = false">
|
||||
<kBlock strong inset class="feather-settings-sheet">
|
||||
<header>
|
||||
@@ -2219,6 +2177,7 @@ onMounted(async () => {
|
||||
.feather-app {
|
||||
--feather-blue: #438cf5;
|
||||
--feather-blue-dark: #2867d8;
|
||||
--color-primary: var(--feather-blue);
|
||||
background: #fff;
|
||||
color: #111923;
|
||||
}
|
||||
@@ -2346,12 +2305,16 @@ onMounted(async () => {
|
||||
box-shadow: 0 15px 35px rgb(45 111 224 / 25%);
|
||||
}
|
||||
.feather-auth {
|
||||
--auth-accent: var(--feather-blue);
|
||||
--panel: #18212b;
|
||||
min-height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: 25px 15px 30px;
|
||||
padding: 68px 15px 34px;
|
||||
color: #f4f7fa;
|
||||
background:
|
||||
radial-gradient(circle at 85% 2%, rgb(90 183 255 / 19%), transparent 34%),
|
||||
radial-gradient(circle at 0 37%, rgb(67 140 245 / 9%), transparent 38%);
|
||||
radial-gradient(circle at 85% 5%, rgb(90 183 255 / 18%), transparent 31%),
|
||||
radial-gradient(circle at 0 42%, rgb(67 140 245 / 8%), transparent 36%),
|
||||
#0f151b;
|
||||
}
|
||||
.feather-auth__hero {
|
||||
display: flex;
|
||||
@@ -2423,31 +2386,41 @@ onMounted(async () => {
|
||||
.feather-auth__fields :deep(.k-list-item-media) {
|
||||
color: var(--feather-blue);
|
||||
}
|
||||
.feather-auth__domain,
|
||||
.feather-auth__reveal {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 16px;
|
||||
transform: translateY(-50%);
|
||||
.feather-auth__photo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 2px 5px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.feather-auth__domain {
|
||||
pointer-events: none;
|
||||
color: #7b8796;
|
||||
font-size: 11px;
|
||||
}
|
||||
.feather-auth__reveal {
|
||||
.feather-auth__photo > span {
|
||||
display: grid;
|
||||
width: 62px;
|
||||
height: 62px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
color: #788493;
|
||||
background: transparent;
|
||||
}
|
||||
.feather-auth__reveal:active {
|
||||
color: var(--feather-blue);
|
||||
background: rgb(67 140 245 / 10%);
|
||||
}
|
||||
.feather-auth__photo img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.feather-auth__photo > div {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
gap: 6px;
|
||||
}
|
||||
.feather-auth__photo :deep(.k-button) {
|
||||
min-height: 34px;
|
||||
justify-content: flex-start;
|
||||
gap: 6px;
|
||||
font-size: 11px;
|
||||
}
|
||||
.feather-auth__error {
|
||||
margin: 0 4px 10px;
|
||||
border-radius: 10px;
|
||||
@@ -4351,6 +4324,19 @@ onMounted(async () => {
|
||||
font-size: 10.5px;
|
||||
font-weight: 750;
|
||||
}
|
||||
.feather-app--active
|
||||
.feather-profile__actions
|
||||
:deep(.feather-profile-action--logout) {
|
||||
grid-column: 1 / -1;
|
||||
border-color: color-mix(in srgb, #f04f65 62%, transparent);
|
||||
color: #f04f65;
|
||||
}
|
||||
.feather-onboarding__logout {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
border-color: color-mix(in srgb, #f04f65 62%, transparent);
|
||||
color: #f04f65;
|
||||
}
|
||||
.feather-app--active .feather-profile-action span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -57,6 +57,7 @@ import {
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import PhonePasscode from '@/components/PhonePasscode.vue'
|
||||
import { useAccountStore } from '@/stores/account'
|
||||
import { useAppAuthStore } from '@/stores/app-auth'
|
||||
import type {
|
||||
LaunchablePhoneAppDefinition,
|
||||
LaunchablePhoneAppId,
|
||||
@@ -132,6 +133,7 @@ const FRAME_PICKER_GAP = 8
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const account = useAccountStore()
|
||||
const appAuth = useAppAuthStore()
|
||||
const query = ref('')
|
||||
const activeView = ref<SettingsView>('root')
|
||||
const selectedNotificationAppId = ref<LaunchablePhoneAppId>('calculator')
|
||||
@@ -602,6 +604,7 @@ async function submitAccount(): Promise<void> {
|
||||
|
||||
async function logoutAccount(): Promise<void> {
|
||||
if (!(await account.logout())) accountToast.value = accountError()
|
||||
else appAuth.clear()
|
||||
}
|
||||
|
||||
function requestRemoveDevice(imei: string): void {
|
||||
@@ -650,6 +653,7 @@ async function confirmFactoryReset(): Promise<void> {
|
||||
factoryResetProgress.value = 100
|
||||
factoryResetting.value = false
|
||||
if (!success) accountToast.value = accountError()
|
||||
else appAuth.hydrate(undefined, '')
|
||||
}
|
||||
|
||||
async function confirmSimEject(): Promise<void> {
|
||||
|
||||
+168
-28
@@ -144,6 +144,8 @@ let mockMapMarkers = [
|
||||
]
|
||||
let crewLinkProfile = {
|
||||
activeGroupId: 'crewlink-group-night-shift',
|
||||
avatarMediaId: 1,
|
||||
avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
|
||||
id: 'crewlink-profile-skyline',
|
||||
mapVisible: true,
|
||||
overheadVisible: false,
|
||||
@@ -176,6 +178,7 @@ const crewLinkMembers = {
|
||||
'crewlink-group-night-shift': [
|
||||
{
|
||||
coords: { x: -155.2, y: -1005.8, z: 28.4 },
|
||||
avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
|
||||
id: 'crewlink-profile-skyline',
|
||||
joinedAt: Date.now() - 36 * 86400000,
|
||||
mapVisible: true,
|
||||
@@ -350,7 +353,10 @@ const crewLinkLimits = {
|
||||
}
|
||||
|
||||
function crewLinkBootstrap(testScenario = '') {
|
||||
if (testScenario === 'crewlink-onboarding') {
|
||||
if (
|
||||
testScenario === 'crewlink-onboarding' ||
|
||||
(testScenario === 'crewlink-register' && !crewLinkProfile)
|
||||
) {
|
||||
return { groups: [], invitations: [], profile: null }
|
||||
}
|
||||
if (testScenario === 'crewlink-empty') {
|
||||
@@ -1985,6 +1991,14 @@ let calendarEvents = [
|
||||
},
|
||||
]
|
||||
const deviceData = {
|
||||
appAuth: {
|
||||
payload: {
|
||||
accountEmail: 'demo@ifruit.com',
|
||||
signedIn: ['citymarkt', 'local-pages', 'feather', 'crewlink'],
|
||||
version: 1,
|
||||
},
|
||||
revision: 1,
|
||||
},
|
||||
alarms: {
|
||||
payload: [
|
||||
{
|
||||
@@ -3881,7 +3895,8 @@ const easyShareCatalog = [
|
||||
appId: 'citymarkt',
|
||||
copyText: 'Comet Retro Custom in excellent condition.',
|
||||
id: 'listing-easyshare-comet',
|
||||
imageUrl: 'https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?w=900',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1503736334956-4c8f8e92946d?w=900',
|
||||
kind: 'link',
|
||||
link: 'skyphone://citymarkt/listing/listing-easyshare-comet',
|
||||
subtitle: '$84,000',
|
||||
@@ -3905,7 +3920,8 @@ const easyShareCatalog = [
|
||||
appId: 'photos',
|
||||
copyText: 'Sunset over Los Santos.',
|
||||
id: 3,
|
||||
imageUrl: 'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
|
||||
imageUrl:
|
||||
'https://images.unsplash.com/photo-1519501025264-65ba15a82390?w=900',
|
||||
kind: 'photo',
|
||||
link: 'skyphone://media/3',
|
||||
title: 'Los Santos sunset',
|
||||
@@ -3960,7 +3976,8 @@ const easyShareCatalog = [
|
||||
appId: 'photos',
|
||||
copyText: 'Vehicle walkaround video.',
|
||||
id: 7,
|
||||
imageUrl: 'https://videos.pexels.com/video-files/3130284/3130284-hd_1920_1080_30fps.mp4',
|
||||
imageUrl:
|
||||
'https://videos.pexels.com/video-files/3130284/3130284-hd_1920_1080_30fps.mp4',
|
||||
kind: 'video',
|
||||
link: 'skyphone://media/7',
|
||||
title: 'Vehicle walkaround',
|
||||
@@ -4453,6 +4470,10 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
}
|
||||
crewLinkProfile = {
|
||||
activeGroupId: null,
|
||||
avatarMediaId: Number(request.body.avatarMediaId) || null,
|
||||
avatarUrl:
|
||||
mockMedia.find((item) => item.id === Number(request.body.avatarMediaId))
|
||||
?.url ?? null,
|
||||
id: `crewlink-profile-${Date.now()}`,
|
||||
mapVisible: true,
|
||||
overheadVisible: false,
|
||||
@@ -4462,8 +4483,25 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'crewlink:update-profile') {
|
||||
const hasAvatarUpdate = request.body.avatarMediaId !== undefined
|
||||
const avatarMediaId = Number(request.body.avatarMediaId) || null
|
||||
const avatar = hasAvatarUpdate
|
||||
? mockMedia.find(
|
||||
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
|
||||
)
|
||||
: null
|
||||
if (avatarMediaId && !avatar) {
|
||||
response.json({ success: false, error: 'invalid_profile_image' })
|
||||
return
|
||||
}
|
||||
crewLinkProfile = {
|
||||
...crewLinkProfile,
|
||||
avatarMediaId: hasAvatarUpdate
|
||||
? avatarMediaId
|
||||
: crewLinkProfile.avatarMediaId,
|
||||
avatarUrl: hasAvatarUpdate
|
||||
? (avatar?.url ?? null)
|
||||
: crewLinkProfile.avatarUrl,
|
||||
mapVisible: request.body.mapVisible === true,
|
||||
overheadVisible: request.body.overheadVisible === true,
|
||||
username: String(request.body.username ?? crewLinkProfile.username),
|
||||
@@ -4471,6 +4509,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
for (const members of Object.values(crewLinkMembers)) {
|
||||
const own = members.find((member) => member.id === crewLinkProfile.id)
|
||||
if (own) {
|
||||
own.avatarUrl = crewLinkProfile.avatarUrl
|
||||
own.mapVisible = crewLinkProfile.mapVisible
|
||||
own.overheadVisible = crewLinkProfile.overheadVisible
|
||||
own.username = crewLinkProfile.username
|
||||
@@ -4718,7 +4757,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
success: true,
|
||||
data: {
|
||||
onboarded: featherOnboarded,
|
||||
profile: featherProfiles[0],
|
||||
profile: featherOnboarded ? featherProfiles[0] : null,
|
||||
feed: {
|
||||
items: empty
|
||||
? []
|
||||
@@ -4764,6 +4803,22 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
featherProfiles[0].display_name = displayName
|
||||
featherProfiles[0].handle = handle
|
||||
featherProfiles[0].bio = bio
|
||||
const avatar = mockMedia.find(
|
||||
(item) =>
|
||||
item.id === Number(request.body.avatarId) && item.mediaType === 'photo',
|
||||
)
|
||||
if (request.body.avatarId && !avatar) {
|
||||
response.json({ success: false, error: 'invalid_media' })
|
||||
return
|
||||
}
|
||||
featherProfiles[0].avatar_url = avatar?.url ?? null
|
||||
featherPosts
|
||||
.filter((post) => post.profile_id === featherProfiles[0].id)
|
||||
.forEach((post) => {
|
||||
post.avatar_url = featherProfiles[0].avatar_url
|
||||
post.display_name = displayName
|
||||
post.handle = handle
|
||||
})
|
||||
featherOnboarded = true
|
||||
response.json({ success: true })
|
||||
return
|
||||
@@ -5388,8 +5443,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
mediaDurationMs: request.body.mediaDurationMs ?? null,
|
||||
mediaUrl: messageType === 'text' ? null : mediaUrl,
|
||||
messageType,
|
||||
sharePayload:
|
||||
messageType === 'share' ? request.body.sharePayload : null,
|
||||
sharePayload: messageType === 'share' ? request.body.sharePayload : null,
|
||||
}
|
||||
flareMessages[match.id] ??= []
|
||||
flareMessages[match.id].push(message)
|
||||
@@ -6759,8 +6813,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
replyToId: request.body.replyToId,
|
||||
replyBody: reply?.body,
|
||||
reactions: {},
|
||||
sharePayload:
|
||||
messageType === 'share' ? request.body.sharePayload : null,
|
||||
sharePayload: messageType === 'share' ? request.body.sharePayload : null,
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
readAt: null,
|
||||
}
|
||||
@@ -6995,15 +7048,69 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
return
|
||||
}
|
||||
if (endpoint === 'development:bootstrap') {
|
||||
if (testScenario === 'feather-onboarding') featherOnboarded = false
|
||||
authenticated = true
|
||||
linkedAccount = {
|
||||
devices: accountDevices,
|
||||
email: 'demo@ifruit.com',
|
||||
id: 1,
|
||||
}
|
||||
if (
|
||||
testScenario === 'feather-onboarding' ||
|
||||
testScenario === 'feather-register'
|
||||
)
|
||||
featherOnboarded = false
|
||||
else featherOnboarded = true
|
||||
pagesOnboardingCompleted = ![
|
||||
'local-pages-onboarding',
|
||||
'local-pages-register',
|
||||
'citymarkt-local-pages-account-missing',
|
||||
].includes(testScenario)
|
||||
if (testScenario === 'crewlink-register') {
|
||||
crewLinkProfile = null
|
||||
} else {
|
||||
crewLinkProfile = {
|
||||
activeGroupId: 'crewlink-group-night-shift',
|
||||
avatarMediaId: 1,
|
||||
avatarUrl: 'https://picsum.photos/seed/crewlink-skyline/240/240',
|
||||
id: 'crewlink-profile-skyline',
|
||||
mapVisible: true,
|
||||
overheadVisible: false,
|
||||
username: 'Skyline',
|
||||
}
|
||||
}
|
||||
if (testScenario === 'citymarkt-register') {
|
||||
marketplaceProfile = {
|
||||
avatar_media_id: null,
|
||||
avatar_url: null,
|
||||
bio: '',
|
||||
display_name: '',
|
||||
email: linkedAccount?.email ?? 'demo@ifruit.com',
|
||||
exists: false,
|
||||
listing_count: 0,
|
||||
}
|
||||
} else {
|
||||
marketplaceProfile = {
|
||||
avatar_media_id: 1,
|
||||
avatar_url: 'https://picsum.photos/seed/citymarkt-demo-avatar/240/240',
|
||||
bio: 'Fair prices, quick replies, and meetups anywhere in Los Santos.',
|
||||
display_name: 'Skyline Deals',
|
||||
email: linkedAccount?.email ?? 'demo@ifruit.com',
|
||||
exists: true,
|
||||
listing_count: marketplaceListings.filter(
|
||||
(listing) => listing.seller_account_id === 1,
|
||||
).length,
|
||||
}
|
||||
}
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
account: testScenario === 'feather-login' ? null : linkedAccount,
|
||||
account: linkedAccount,
|
||||
device: {
|
||||
data:
|
||||
testScenario.startsWith('citymarkt-')
|
||||
testScenario.startsWith('citymarkt-') ||
|
||||
testScenario.startsWith('feather-') ||
|
||||
testScenario.startsWith('local-pages-') ||
|
||||
testScenario.startsWith('crewlink-')
|
||||
? {
|
||||
...deviceData,
|
||||
apps: {
|
||||
@@ -7012,10 +7119,11 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
...deviceData.apps.payload,
|
||||
homeLayout: {
|
||||
dock: [],
|
||||
grid:
|
||||
testScenario === 'citymarkt-local-pages-missing'
|
||||
? []
|
||||
: ['local-pages'],
|
||||
grid: testScenario.startsWith('crewlink-')
|
||||
? ['crewlink']
|
||||
: testScenario === 'citymarkt-local-pages-missing'
|
||||
? ['citymarkt']
|
||||
: ['citymarkt', 'local-pages'],
|
||||
hidden:
|
||||
testScenario === 'citymarkt-local-pages-missing'
|
||||
? ['local-pages']
|
||||
@@ -7024,6 +7132,31 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
appAuth: [
|
||||
'citymarkt-login',
|
||||
'citymarkt-register',
|
||||
'feather-login',
|
||||
'feather-register',
|
||||
'local-pages-login',
|
||||
'local-pages-register',
|
||||
'crewlink-login',
|
||||
'crewlink-register',
|
||||
].includes(testScenario)
|
||||
? {
|
||||
payload: {
|
||||
accountEmail: linkedAccount?.email ?? '',
|
||||
signedIn: testScenario.startsWith('feather-')
|
||||
? ['citymarkt', 'local-pages', 'crewlink']
|
||||
: testScenario.startsWith('local-pages-')
|
||||
? ['citymarkt', 'feather', 'crewlink']
|
||||
: testScenario.startsWith('crewlink-')
|
||||
? ['citymarkt', 'local-pages', 'feather']
|
||||
: ['local-pages', 'feather', 'crewlink'],
|
||||
version: 1,
|
||||
},
|
||||
revision: deviceData.appAuth?.revision ?? 0,
|
||||
}
|
||||
: deviceData.appAuth,
|
||||
}
|
||||
: deviceData,
|
||||
imei: '356938035643809',
|
||||
@@ -7151,11 +7284,11 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
organization: selectedContact.organization ?? null,
|
||||
phone_number: selectedContact.phone_number,
|
||||
}
|
||||
: messageType === 'share'
|
||||
? request.body.sharePayload
|
||||
: isAttachment
|
||||
? attachmentId
|
||||
: null,
|
||||
: messageType === 'share'
|
||||
? request.body.sharePayload
|
||||
: isAttachment
|
||||
? attachmentId
|
||||
: null,
|
||||
media_waveform:
|
||||
messageType === 'voice' ? request.body.mediaWaveform : null,
|
||||
message_type: messageType,
|
||||
@@ -7575,9 +7708,11 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
if (endpoint === 'pages:profile') {
|
||||
const email = linkedAccount?.email ?? pagesProfile.email
|
||||
const onboarding =
|
||||
['local-pages-onboarding', 'citymarkt-local-pages-account-missing'].includes(
|
||||
testScenario,
|
||||
) && !pagesOnboardingCompleted
|
||||
[
|
||||
'local-pages-onboarding',
|
||||
'local-pages-register',
|
||||
'citymarkt-local-pages-account-missing',
|
||||
].includes(testScenario) && !pagesOnboardingCompleted
|
||||
response.json({
|
||||
success: true,
|
||||
data: {
|
||||
@@ -7597,9 +7732,12 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
if (endpoint === 'pages:profile-save') {
|
||||
pagesOnboardingCompleted = true
|
||||
const avatarMediaId = Number(request.body.avatarMediaId) || 0
|
||||
const avatarMedia = avatarMediaId > 0
|
||||
? mockMedia.find((item) => item.id === avatarMediaId && item.mediaType === 'photo')
|
||||
: null
|
||||
const avatarMedia =
|
||||
avatarMediaId > 0
|
||||
? mockMedia.find(
|
||||
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
|
||||
)
|
||||
: null
|
||||
if (avatarMediaId > 0 && !avatarMedia) {
|
||||
response.json({ success: false, error: 'invalid_profile_image' })
|
||||
return
|
||||
@@ -7610,7 +7748,9 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
bio: String(request.body.bio ?? '').trim(),
|
||||
email: linkedAccount?.email ?? pagesProfile.email,
|
||||
exists: true,
|
||||
handle: String(request.body.handle ?? '').trim().toLowerCase(),
|
||||
handle: String(request.body.handle ?? '')
|
||||
.trim()
|
||||
.toLowerCase(),
|
||||
}
|
||||
pagesPosts.forEach((post) => {
|
||||
if (post.account_id === 1) post.author_name = pagesProfile.handle
|
||||
|
||||
@@ -69,6 +69,18 @@ Locales["en"] = {
|
||||
add = "Add", back = "Back", cancel = "Cancel", clear = "Clear", close = "Close", delete = "Delete", done = "Done", edit = "Edit", home = "Home", loading = "Loading", pause = "Pause", use = "Use",
|
||||
phone = "Phone", phoneStatus = "Phone status", reset = "Reset",
|
||||
save = "Save", search = "Search", send = "Send", start = "Start", stop = "Stop",
|
||||
signOut = "Sign Out", signingOut = "Signing Out...", signOutTitle = "Sign out of {app}?",
|
||||
signOutBody = "You will only be signed out of {app}. Your other iFruit apps stay signed in.", signOutFailed = "Could not sign out. Please try again.",
|
||||
appAuth = {
|
||||
eyebrow = "iFruit account", title = "Continue to {app}", body = "Use your iFruit email and password. This login applies only to {app}.",
|
||||
login = "Login", register = "Register", email = "iFruit email", password = "Password", confirm = "Confirm password",
|
||||
loginAction = "Log in", registerAction = "Create account",
|
||||
errors = {
|
||||
invalid_email = "Enter a valid iFruit email.", invalid_password = "Password must be 6–64 characters.",
|
||||
invalid_credentials = "Email or password is incorrect.", email_taken = "That iFruit email is already registered.",
|
||||
rate_limited = "Too many attempts. Try again in a minute.", default = "The account request failed.",
|
||||
},
|
||||
},
|
||||
},
|
||||
ControlCenter = {
|
||||
airplaneMode = "Airplane Mode", bluetooth = "Bluetooth", brightness = "Brightness", calculator = "Calculator",
|
||||
@@ -124,6 +136,15 @@ Locales["en"] = {
|
||||
crewlink = {
|
||||
name = "CrewLink", connecting = "Connecting your crew...", privateNetwork = "Private location network",
|
||||
signInTitle = "Connect your iFruit Account", signInBody = "CrewLink uses your private iFruit identity to keep groups and roles available across your phones.", openSettings = "Open iFruit Settings",
|
||||
authEyebrow = "Private crew network", authTitle = "Welcome to CrewLink",
|
||||
authBody = "Your iFruit email is linked automatically. Use your CrewLink username to continue.",
|
||||
login = "Log in", register = "Register", ifruitEmail = "iFruit address", gallery = "Gallery", camera = "Camera", backToLogin = "Back to CrewLink login",
|
||||
authErrors = {
|
||||
no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.",
|
||||
profile_not_found = "No CrewLink profile exists for this iFruit email.", profile_exists = "This iFruit email already has a CrewLink profile.",
|
||||
username_taken = "That CrewLink username is already taken.", invalid_profile_image = "Choose a valid photo from this phone.",
|
||||
rate_limited = "Too many attempts. Try again shortly.", request_failed = "CrewLink could not complete the request.",
|
||||
},
|
||||
welcomeEyebrow = "Your crew. One signal.", welcomeTitle = "Find your people", welcomeBody = "Choose a private CrewLink username. Only confirmed group members can see your presence and shared location.",
|
||||
username = "CrewLink username", usernamePlaceholder = "e.g. Skyline", createProfile = "Create CrewLink Profile", profileCreated = "Your CrewLink profile is ready.", privacyNote = "Private by design. No public player search.",
|
||||
noGroupEyebrow = "No active crew", noGroupTitle = "Build your private network", noGroupBody = "Create a crew or join friends with a private invitation code.",
|
||||
@@ -142,7 +163,8 @@ Locales["en"] = {
|
||||
roleDescriptions = { coordinator = "Manage invitations, settings, roles, and pings.", moderator = "Invite nearby users and moderate group pings.", member = "Share presence and create pings when enabled.", guest = "View the active crew without coordination permissions." },
|
||||
manageCrew = "Manage Crew", manageCrewBody = "Update identity, permissions, and invitations.", memberPings = "Member pings", memberPingsBody = "Allow members and guests to create pings.", allowOverhead = "Overhead labels", allowOverheadBody = "Permit nearby opt-in member labels in the world.", groupSaved = "Crew settings saved.",
|
||||
assignRole = "Assign Role", roleUpdated = "Member role updated.", transferOwnership = "Transfer Ownership", removeMember = "Remove from Crew", yourCrewLinkId = "Your CrewLink ID", privacyVisibility = "Privacy & Visibility", shareOnMap = "Share on group map", shareOnMapBody = "Active members can see your live position.", overheadLabels = "Nearby overhead label", overheadLabelsBody = "Show your CrewLink name to nearby opted-in members.",
|
||||
yourGroups = "Your Groups", createAnotherGroup = "Create another group", account = "CrewLink Account", externalApi = "Connected resources", externalApiBody = "Approved scripts may add temporary group pings.", editUsername = "Edit Username", editUsernameBody = "Your username is unique across CrewLink.", profileSaved = "CrewLink profile saved.", deleteGroup = "Delete Group", leaveGroup = "Leave Group", confirmAction = "Confirm",
|
||||
yourGroups = "Your Groups", createAnotherGroup = "Create another group", account = "CrewLink Account", externalApi = "Connected resources", externalApiBody = "Approved scripts may add temporary group pings.", editUsername = "Edit Username", editUsernameBody = "Your username is unique across CrewLink.",
|
||||
editProfile = "Edit CrewLink Profile", editProfileBody = "Change your username and profile photo.", removeProfilePhoto = "Remove profile photo", profileSaved = "CrewLink profile saved.", deleteGroup = "Delete Group", leaveGroup = "Leave Group", confirmAction = "Confirm",
|
||||
confirm = {
|
||||
["delete-group"] = { title = "Delete this crew?", body = "The group, memberships, invitations, and active pings will be permanently removed." },
|
||||
["leave-group"] = { title = "Leave this crew?", body = "You will lose access to its members, map, and pings." },
|
||||
@@ -152,7 +174,7 @@ Locales["en"] = {
|
||||
["delete-groupDone"] = "Crew deleted.", ["leave-groupDone"] = "You left the crew.", ["remove-memberDone"] = "Member removed.", ["transfer-ownerDone"] = "Ownership transferred.",
|
||||
notifications = { invite = "{actor} invited you to {group}.", member_joined = "{actor} joined {group}.", ping = "{actor} shared '{ping}' with {group}.", role = "Your CrewLink role changed in {group}.", removed = "You were removed from {group}.", default = "Your CrewLink group has an update." },
|
||||
errors = {
|
||||
not_authenticated = "Sign in to your iFruit Account first.", profile_required = "Create your CrewLink profile first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", invalid_profile = "Check your profile details.", username_taken = "That CrewLink username is already taken.",
|
||||
not_authenticated = "Sign in to your iFruit Account first.", profile_required = "Create your CrewLink profile first.", invalid_username = "Use 3-20 letters, numbers, dots, or underscores.", invalid_profile = "Check your profile details.", invalid_profile_image = "Choose a valid photo from this phone.", username_taken = "That CrewLink username is already taken.",
|
||||
invalid_group = "Choose a valid group name and signal colour.", group_limit = "You have reached your group limit.", member_limit = "This group has reached its member limit.", group_not_found = "This group is no longer available.", invalid_code = "That invitation code is invalid.", already_member = "You are already a member of this group.", forbidden = "Your current role cannot perform this action.",
|
||||
player_not_nearby = "That player is no longer nearby.", player_unavailable = "That player cannot receive a CrewLink invitation.", invalid_invitation = "This invitation is invalid.", invitation_expired = "This invitation has expired.", invalid_role = "That role cannot be assigned.", owner_must_transfer = "Transfer ownership before leaving this group.",
|
||||
invalid_ping = "Choose a valid ping type, label, and position.", ping_limit = "This crew already has too many active pings.", ping_not_found = "That ping is no longer active.", rate_limited = "Too many CrewLink actions. Try again shortly.", request_failed = "CrewLink is temporarily unavailable.",
|
||||
@@ -264,19 +286,19 @@ Locales["en"] = {
|
||||
name = "Feather", loading = "Loading Feather", home = "Home", explore = "Explore", activity = "Notifications", activityNav = "Alerts", profile = "Profile", back = "Back",
|
||||
settings = "Settings", settingsEyebrow = "Feather preferences", compactMode = "Compact timeline", compactModeBody = "Show more conversation on the screen.", showSuggestions = "Profile suggestions", showSuggestionsBody = "Show people to follow on your profile.",
|
||||
forYou = "For you", following = "Following", add = "Add", network = "Network", networkTitle = "Find your people", networkBody = "Follow voices from across Los Santos and build a timeline that feels like yours.", networkSearchPlaceholder = "Search names or @usernames", postSearchPlaceholder = "Search posts or #hashtags", searchResults = "Search results", suggestedPeople = "Suggested for you", noPeopleFound = "No people found", noPeopleFoundBody = "Try another name or username.", noSuggestions = "No suggestions yet", noSuggestionsBody = "New Feather accounts will appear here.", all = "All", mentions = "Mentions", media = "Media", trending = "Trending", peopleTab = "People", searchPlaceholder = "Search Feather", search = "Search", cancel = "Cancel", done = "Done",
|
||||
authEyebrow = "Feather network", authWelcome = "Welcome to Feather", authBody = "One iFruit account. Every conversation, wherever you sign in.",
|
||||
login = "Log in", register = "Register", loginTitle = "Good to see you again", loginBody = "Log in with your iFruit address to continue to Feather.",
|
||||
registerTitle = "Create your iFruit account", registerBody = "Choose an address and secure it with a password. Your Feather profile comes next.",
|
||||
authEyebrow = "Feather network", authWelcome = "Welcome to Feather", authBody = "Your iFruit email is linked automatically. Use your Feather username to continue.",
|
||||
login = "Log in", register = "Register", loginTitle = "Good to see you again", loginBody = "Use your Feather username. Your iFruit email is linked automatically.",
|
||||
registerTitle = "Create your Feather profile", registerBody = "Choose a username and optional profile photo. No password is needed.",
|
||||
email = "iFruit address", emailPlaceholder = "yourname", password = "Password", passwordPlaceholder = "6–64 characters",
|
||||
confirmPassword = "Confirm password", confirmPasswordPlaceholder = "Enter it again", showPassword = "Show password", hidePassword = "Hide password",
|
||||
loginAction = "Continue to Feather", registerAction = "Create account", noAccount = "New to Feather?", haveAccount = "Already registered?",
|
||||
registerNow = "Create an account", loginNow = "Log in", authTrust = "Your credentials are verified by the server and this phone is linked to your iFruit account.",
|
||||
loginAction = "Continue to Feather", registerAction = "Create profile", noAccount = "New to Feather?", haveAccount = "Already registered?",
|
||||
registerNow = "Create a profile", loginNow = "Log in", authTrust = "This Feather session is separate from your other apps and linked to your iFruit email.",
|
||||
profileStep = "Step 2 of 2", accountConnected = "iFruit account connected",
|
||||
authErrors = {
|
||||
invalid_email = "Enter a valid 3–32 character iFruit address.", invalid_password = "Password must be 6–64 characters.",
|
||||
password_mismatch = "The passwords do not match.", invalid_credentials = "The iFruit address or password is incorrect.",
|
||||
email_taken = "That iFruit address is already registered.", rate_limited = "Too many attempts. Try again in a minute.",
|
||||
default = "The account request failed. Please try again.",
|
||||
no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_handle = "Use 3-30 letters, numbers or underscores.", invalid_username = "That username does not match your Feather profile.",
|
||||
profile_not_found = "No Feather profile exists for this iFruit email.", already_registered = "This iFruit email already has a Feather profile.",
|
||||
handle_taken = "That Feather username is already taken.", invalid_media = "Choose a valid photo from this phone.",
|
||||
rate_limited = "Too many attempts. Try again in a minute.", default = "Feather could not complete the request. Please try again.",
|
||||
},
|
||||
welcome = "Find your voice", welcomeBody = "Join the live conversation in Los Santos. Your Feather profile stays linked to your iFruit account.",
|
||||
createProfile = "Create Feather profile", profileDetailsHint = "Choose how people will recognize you.", displayName = "Display name", displayNamePlaceholder = "Your name", handle = "Username", handlePlaceholder = "your_username", handleHint = "3-30 letters, numbers or underscores",
|
||||
@@ -1197,7 +1219,16 @@ Locales["en"] = {
|
||||
noListingsBody = "Try another search or category.", noDistrict = "No district",
|
||||
free = "Free", negotiablePrice = "${price} negotiable", money = "${price}",
|
||||
hoursAgo = "{count}h ago", daysAgo = "{count}d ago", photos = "photos", activeListings = "active listings",
|
||||
signInTitle = "Sign in to iFruit", signInBody = "Use Settings to sign in before selling, saving or messaging.",
|
||||
signInTitle = "Sign in to iFruit", signInBody = "Log in to your CityMarkt profile to sell, save or message.",
|
||||
authEyebrow = "CityMarkt account", authTitle = "Welcome to CityMarkt",
|
||||
authBody = "Your iFruit email is linked automatically. Use your CityMarkt username to continue.",
|
||||
login = "Login", register = "Register", authUsername = "Username",
|
||||
authErrors = {
|
||||
no_ifruit_account = "Connect an iFruit account in Settings first.",
|
||||
invalid_username = "Enter the username of your CityMarkt profile.",
|
||||
profile_not_found = "No CityMarkt profile exists for this iFruit email.",
|
||||
profile_exists = "A CityMarkt profile already exists. Use Login instead.",
|
||||
},
|
||||
noMessages = "No conversations", noMessagesBody = "Messages about offers will appear here.",
|
||||
myListings = "My listings", favorites = "Favorites", addFavorite = "Add to favorites", removeFavorite = "Remove from favorites", noProfileListings = "Nothing here yet",
|
||||
createProfile = "Create your CityMarkt profile", editProfile = "Edit profile",
|
||||
@@ -1254,7 +1285,7 @@ Locales["en"] = {
|
||||
noPosts = "Nothing here yet", noPostsBody = "Be the first to share something with the city.", noPhoto = "No photo attached",
|
||||
signInTitle = "Your Local Pages profile", signInBody = "Sign in to iFruit in Settings to publish and save posts.",
|
||||
authEyebrow = "Local Pages account", authWelcome = "Welcome to Local Pages",
|
||||
authBody = "Sign in or create an iFruit account to build your local profile.", login = "Sign in", register = "Register",
|
||||
authBody = "Your iFruit email is linked automatically. Use your Local Pages username to continue.", login = "Sign in", register = "Register",
|
||||
loginTitle = "Continue with iFruit", loginBody = "Your posts, saved items and profile stay linked to your account.",
|
||||
registerTitle = "Create an iFruit account", registerBody = "Choose your new iFruit address. Your Local Pages profile comes next.",
|
||||
authEmail = "iFruit address", authEmailPlaceholder = "your.name", authPassword = "Password",
|
||||
@@ -1274,7 +1305,13 @@ Locales["en"] = {
|
||||
cityMarktAppMissing = "Local Pages is not installed", cityMarktInstallHint = "Install Local Pages to share this listing",
|
||||
cityMarktAccountMissing = "Local Pages profile required", cityMarktAccountHint = "Create a Local Pages profile before sharing",
|
||||
cityMarktComposeTitle = "Share CityMarkt listing", cityMarktComposeNavTitle = "Share listing", cityMarktComposeHint = "Review the listing and publish it when you are ready.", cityMarktPhotosHint = "The CityMarkt listing photos will be included.",
|
||||
authErrors = { invalid_email = "Enter a valid 3-32 character iFruit address.", invalid_password = "Use a password between 6 and 64 characters.", invalid_credentials = "The iFruit address or password is incorrect.", email_taken = "This iFruit address is already registered.", password_mismatch = "The passwords do not match.", rate_limited = "Too many attempts. Try again shortly.", default = "The iFruit account request failed." },
|
||||
authErrors = {
|
||||
no_ifruit_account = "Sign in to your iFruit account in Settings first.", invalid_username = "Use 3-24 lowercase letters, numbers, dots or underscores.",
|
||||
profile_not_found = "No Local Pages profile exists for this iFruit email.", profile_exists = "This iFruit email already has a Local Pages profile.",
|
||||
invalid_profile = "Check your Local Pages username.", invalid_profile_image = "Choose a valid photo from this phone.",
|
||||
profile_handle_taken = "This Local Pages username is already taken.", rate_limited = "Too many attempts. Try again shortly.",
|
||||
default = "Local Pages could not complete the request.",
|
||||
},
|
||||
errors = { profile_required = "Create your Local Pages profile first.", invalid_profile = "Check your username and bio.", invalid_profile_image = "Choose a valid photo from this phone.", profile_handle_taken = "This username is already taken.", invalid_post = "Add a title and a little more detail.", invalid_images = "Choose valid photos from this phone.", invalid_request = "This action is not valid.", post_not_found = "This post is no longer available.", citymarkt_not_found = "This CityMarkt listing is unavailable.", citymarkt_daily_limit = "You already shared a CityMarkt listing today.", citymarkt_already_shared = "This listing was already shared.", not_authenticated = "Sign in to iFruit first.", rate_limited = "Too many requests. Try again shortly.", request_failed = "The post could not be saved.", default = "Local Pages is temporarily unavailable." },
|
||||
},
|
||||
map = {
|
||||
|
||||
@@ -82,6 +82,8 @@ local function profile_dto(row)
|
||||
return {
|
||||
id = row.id,
|
||||
username = row.username,
|
||||
avatarMediaId = row.avatar_media_id and tonumber(row.avatar_media_id) or nil,
|
||||
avatarUrl = row.avatar_url,
|
||||
activeGroupId = row.active_group_id,
|
||||
mapVisible = tonumber(row.map_visible) == 1,
|
||||
overheadVisible = tonumber(row.overhead_visible) == 1,
|
||||
@@ -94,9 +96,11 @@ local function require_profile(source)
|
||||
return nil, error_response
|
||||
end
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible`
|
||||
FROM `sky_phone_crewlink_profiles`
|
||||
WHERE `account_id` = ?
|
||||
SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`,
|
||||
p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`
|
||||
FROM `sky_phone_crewlink_profiles` p
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
|
||||
WHERE p.`account_id` = ?
|
||||
LIMIT 1
|
||||
]], { account.id })
|
||||
if not rows[1] then
|
||||
@@ -131,9 +135,10 @@ end
|
||||
local function member_dtos(group_id)
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT p.`id`, p.`account_id`, p.`username`, p.`map_visible`, p.`overhead_visible`,
|
||||
m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at`
|
||||
avatar.`url` AS `avatar_url`, m.`role`, UNIX_TIMESTAMP(m.`joined_at`) AS `joined_at`
|
||||
FROM `sky_phone_crewlink_memberships` m
|
||||
JOIN `sky_phone_crewlink_profiles` p ON p.`id` = m.`profile_id`
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
|
||||
WHERE m.`group_id` = ?
|
||||
ORDER BY FIELD(m.`role`, 'owner', 'coordinator', 'moderator', 'member', 'guest'), m.`joined_at`
|
||||
]], { group_id })
|
||||
@@ -141,8 +146,10 @@ local function member_dtos(group_id)
|
||||
row.account_id = tonumber(row.account_id)
|
||||
row.mapVisible = tonumber(row.map_visible) == 1
|
||||
row.overheadVisible = tonumber(row.overhead_visible) == 1
|
||||
row.avatarUrl = row.avatar_url
|
||||
row.map_visible = nil
|
||||
row.overhead_visible = nil
|
||||
row.avatar_url = nil
|
||||
row.joinedAt = (tonumber(row.joined_at) or 0) * 1000
|
||||
row.joined_at = nil
|
||||
end
|
||||
@@ -397,8 +404,11 @@ Bridge.Callbacks.Register("sky_phone:crewlink:bootstrap", function(source)
|
||||
return error_response
|
||||
end
|
||||
local rows = Bridge.Database.Query([[
|
||||
SELECT `id`, `account_id`, `username`, `active_group_id`, `map_visible`, `overhead_visible`
|
||||
FROM `sky_phone_crewlink_profiles` WHERE `account_id` = ? LIMIT 1
|
||||
SELECT p.`id`, p.`account_id`, p.`username`, p.`avatar_media_id`, p.`active_group_id`,
|
||||
p.`map_visible`, p.`overhead_visible`, avatar.`url` AS `avatar_url`
|
||||
FROM `sky_phone_crewlink_profiles` p
|
||||
LEFT JOIN `sky_phone_media` avatar ON avatar.`id` = p.`avatar_media_id`
|
||||
WHERE p.`account_id` = ? LIMIT 1
|
||||
]], { account.id })
|
||||
if not rows[1] then
|
||||
return { success = true, data = { profile = nil, groups = {}, invitations = {} } }
|
||||
@@ -415,14 +425,22 @@ Bridge.Callbacks.Register("sky_phone:crewlink:create-profile", function(source,
|
||||
if not account then
|
||||
return error_response
|
||||
end
|
||||
local username = valid_username(data and data.username)
|
||||
data = type(data) == "table" and data or {}
|
||||
local username = valid_username(data.username)
|
||||
if not username then
|
||||
return { success = false, error = "invalid_username" }
|
||||
end
|
||||
local avatar_media_id = tonumber(data.avatarMediaId) or 0
|
||||
if avatar_media_id < 0 or avatar_media_id ~= math.floor(avatar_media_id) then
|
||||
return { success = false, error = "invalid_profile_image" }
|
||||
end
|
||||
if avatar_media_id > 0 and not SkyPhoneMedia.ResolveOwnedMedia(source, avatar_media_id, "photo") then
|
||||
return { success = false, error = "invalid_profile_image" }
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`)
|
||||
VALUES (?, ?, ?)
|
||||
]], { new_id(), account.id, username })
|
||||
INSERT IGNORE INTO `sky_phone_crewlink_profiles` (`id`, `account_id`, `username`, `avatar_media_id`)
|
||||
VALUES (?, ?, ?, NULLIF(?, 0))
|
||||
]], { new_id(), account.id, username, avatar_media_id })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "username_taken" }
|
||||
end
|
||||
@@ -442,11 +460,26 @@ Bridge.Callbacks.Register("sky_phone:crewlink:update-profile", function(source,
|
||||
if not username or type(data.mapVisible) ~= "boolean" or type(data.overheadVisible) ~= "boolean" then
|
||||
return { success = false, error = "invalid_profile" }
|
||||
end
|
||||
local avatar_media_id = profile.avatar_media_id and tonumber(profile.avatar_media_id) or nil
|
||||
if data.avatarMediaId ~= nil then
|
||||
local submitted_avatar_id = tonumber(data.avatarMediaId)
|
||||
if not submitted_avatar_id or submitted_avatar_id < 0
|
||||
or submitted_avatar_id ~= math.floor(submitted_avatar_id)
|
||||
then
|
||||
return { success = false, error = "invalid_profile_image" }
|
||||
end
|
||||
if submitted_avatar_id > 0
|
||||
and not SkyPhoneMedia.ResolveOwnedMedia(source, submitted_avatar_id, "photo")
|
||||
then
|
||||
return { success = false, error = "invalid_profile_image" }
|
||||
end
|
||||
avatar_media_id = submitted_avatar_id > 0 and submitted_avatar_id or nil
|
||||
end
|
||||
local result = Bridge.Database.Query([[
|
||||
UPDATE IGNORE `sky_phone_crewlink_profiles`
|
||||
SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?
|
||||
SET `username` = ?, `map_visible` = ?, `overhead_visible` = ?, `avatar_media_id` = ?
|
||||
WHERE `id` = ?
|
||||
]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, profile.id })
|
||||
]], { username, data.mapVisible and 1 or 0, data.overheadVisible and 1 or 0, avatar_media_id, profile.id })
|
||||
if affected_rows(result) ~= 1 and username:lower() ~= tostring(profile.username):lower() then
|
||||
return { success = false, error = "username_taken" }
|
||||
end
|
||||
|
||||
@@ -2098,6 +2098,7 @@ local schema = {
|
||||
{ name = "id", type = "CHAR(36) NOT NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "account_id", type = "BIGINT UNSIGNED NOT NULL" },
|
||||
{ name = "username", type = "VARCHAR(20) NOT NULL", characterSet = "ascii", collation = "ascii_general_ci" },
|
||||
{ name = "avatar_media_id", type = "BIGINT UNSIGNED NULL" },
|
||||
{ name = "active_group_id", type = "CHAR(36) NULL", characterSet = "ascii", collation = "ascii_bin" },
|
||||
{ name = "map_visible", type = "TINYINT(1) NOT NULL DEFAULT 1" },
|
||||
{ name = "overhead_visible", type = "TINYINT(1) NOT NULL DEFAULT 0" },
|
||||
@@ -2111,9 +2112,11 @@ local schema = {
|
||||
},
|
||||
indexes = {
|
||||
{ name = "idx_sky_phone_crewlink_active", columns = "(`active_group_id`)" },
|
||||
{ name = "idx_sky_phone_crewlink_avatar", columns = "(`avatar_media_id`)" },
|
||||
},
|
||||
foreignKeys = {
|
||||
{ column = "account_id", references = "`sky_phone_accounts` (`id`) ON DELETE CASCADE" },
|
||||
{ column = "avatar_media_id", references = "`sky_phone_media` (`id`) ON DELETE SET NULL" },
|
||||
},
|
||||
tableOptions = "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci",
|
||||
},
|
||||
|
||||
@@ -24,6 +24,7 @@ local allowed_device_namespaces = {
|
||||
notifications = true,
|
||||
wallpaper = true,
|
||||
alarms = true,
|
||||
appAuth = true,
|
||||
apps = true,
|
||||
games = true,
|
||||
widgets = true,
|
||||
|
||||
@@ -897,6 +897,7 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` (
|
||||
`id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||
`account_id` BIGINT UNSIGNED NOT NULL,
|
||||
`username` VARCHAR(20) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
|
||||
`avatar_media_id` BIGINT UNSIGNED NULL,
|
||||
`active_group_id` CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL,
|
||||
`map_visible` TINYINT(1) NOT NULL DEFAULT 1,
|
||||
`overhead_visible` TINYINT(1) NOT NULL DEFAULT 0,
|
||||
@@ -906,7 +907,9 @@ CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_profiles` (
|
||||
UNIQUE KEY `uniq_sky_phone_crewlink_account` (`account_id`),
|
||||
UNIQUE KEY `uniq_sky_phone_crewlink_username` (`username`),
|
||||
KEY `idx_sky_phone_crewlink_active` (`active_group_id`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE
|
||||
KEY `idx_sky_phone_crewlink_avatar` (`avatar_media_id`),
|
||||
FOREIGN KEY (`account_id`) REFERENCES `sky_phone_accounts` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`avatar_media_id`) REFERENCES `sky_phone_media` (`id`) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `sky_phone_crewlink_groups` (
|
||||
|
||||
Reference in New Issue
Block a user