mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-28 17:01:18 +00:00
ENH - add CityMarkt password authentication
Authenticate CityMarkt profiles with the linked iFruit account password, including server-side validation and rate limiting. Update the shared auth UI, localized copy, frontend state, browser mocks, and regression coverage for login and registration flows.
This commit is contained in:
@@ -8,13 +8,13 @@ import {
|
||||
UserRound,
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
SkyButton as kButton,
|
||||
SkyField as kListInput,
|
||||
SkyGlass as kGlass,
|
||||
SkyList as kList,
|
||||
SkySegmented as kSegmented,
|
||||
SkySegmentedButton as kSegmentedButton,
|
||||
SkySpinner as kPreloader,
|
||||
SkyButton,
|
||||
SkyField,
|
||||
SkyGlass,
|
||||
SkyList,
|
||||
SkySegmented,
|
||||
SkySegmentedButton,
|
||||
SkySpinner,
|
||||
} from '@/ui'
|
||||
import { computed } from 'vue'
|
||||
|
||||
@@ -24,6 +24,7 @@ const props = withDefaults(
|
||||
body: string
|
||||
cameraLabel: string
|
||||
email: string
|
||||
emailAsField?: boolean
|
||||
emailLabel: string
|
||||
error: string
|
||||
eyebrow: string
|
||||
@@ -38,19 +39,38 @@ const props = withDefaults(
|
||||
pending: boolean
|
||||
registerLabel: string
|
||||
requirePassword?: boolean
|
||||
submitEnabled?: boolean
|
||||
title: string
|
||||
username: string
|
||||
usernameAutocomplete?: string
|
||||
usernameHelp?: string
|
||||
usernameInputType?: 'password' | 'text'
|
||||
usernameLabel: string
|
||||
usernamePlaceholder?: string
|
||||
variant?: 'default' | 'centered'
|
||||
confirmPassword?: string
|
||||
confirmPasswordError?: boolean | string
|
||||
confirmPasswordLabel?: string
|
||||
confirmPasswordPlaceholder?: string
|
||||
showConfirmPassword?: boolean
|
||||
}>(),
|
||||
{
|
||||
confirmPassword: '',
|
||||
confirmPasswordError: false,
|
||||
confirmPasswordLabel: '',
|
||||
confirmPasswordPlaceholder: '',
|
||||
emailAsField: false,
|
||||
maxUsernameLength: 40,
|
||||
minUsernameLength: 2,
|
||||
password: '',
|
||||
passwordLabel: 'Password',
|
||||
passwordPlaceholder: '',
|
||||
requirePassword: false,
|
||||
showConfirmPassword: false,
|
||||
submitEnabled: undefined,
|
||||
usernameAutocomplete: 'username',
|
||||
usernameHelp: '',
|
||||
usernameInputType: 'text',
|
||||
usernamePlaceholder: '',
|
||||
variant: 'default',
|
||||
},
|
||||
@@ -59,6 +79,7 @@ const emit = defineEmits<{
|
||||
camera: []
|
||||
gallery: []
|
||||
submit: []
|
||||
'update:confirmPassword': [value: string]
|
||||
'update:mode': [value: 'login' | 'register']
|
||||
'update:password': [value: string]
|
||||
'update:username': [value: string]
|
||||
@@ -68,13 +89,16 @@ const canSubmit = computed(() => {
|
||||
const length = props.username.trim().length
|
||||
const validUsername =
|
||||
length >= props.minUsernameLength && length <= props.maxUsernameLength
|
||||
return Boolean(
|
||||
props.email &&
|
||||
(props.mode === 'login' && props.requirePassword
|
||||
? true
|
||||
: validUsername) &&
|
||||
(!props.requirePassword ||
|
||||
(props.password.length >= 8 && props.password.length <= 72)),
|
||||
return (
|
||||
props.submitEnabled ??
|
||||
Boolean(
|
||||
props.email &&
|
||||
(props.mode === 'login' && props.requirePassword
|
||||
? true
|
||||
: validUsername) &&
|
||||
(!props.requirePassword ||
|
||||
(props.password.length >= 8 && props.password.length <= 72)),
|
||||
)
|
||||
)
|
||||
})
|
||||
</script>
|
||||
@@ -93,41 +117,9 @@ const canSubmit = computed(() => {
|
||||
<p>{{ body }}</p>
|
||||
</header>
|
||||
|
||||
<k-glass class="app-profile-auth__card">
|
||||
<div
|
||||
v-if="variant === 'centered'"
|
||||
class="app-profile-auth__mode app-profile-auth__mode--centered"
|
||||
role="tablist"
|
||||
:aria-label="`${loginLabel} / ${registerLabel}`"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="app-profile-auth__mode-choice"
|
||||
:class="{
|
||||
'app-profile-auth__mode-choice--active': mode === 'login',
|
||||
}"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'login'"
|
||||
@click="emit('update:mode', 'login')"
|
||||
>
|
||||
{{ loginLabel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="app-profile-auth__mode-choice"
|
||||
:class="{
|
||||
'app-profile-auth__mode-choice--active': mode === 'register',
|
||||
}"
|
||||
role="tab"
|
||||
:aria-selected="mode === 'register'"
|
||||
@click="emit('update:mode', 'register')"
|
||||
>
|
||||
{{ registerLabel }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<k-segmented v-else raised class="app-profile-auth__mode">
|
||||
<k-segmented-button
|
||||
<SkyGlass class="app-profile-auth__card">
|
||||
<SkySegmented raised class="app-profile-auth__mode">
|
||||
<SkySegmentedButton
|
||||
class="app-profile-auth__mode-button app-profile-auth__mode-button--login"
|
||||
:class="{
|
||||
'app-profile-auth__mode-button--active': mode === 'login',
|
||||
@@ -136,8 +128,8 @@ const canSubmit = computed(() => {
|
||||
@click="emit('update:mode', 'login')"
|
||||
>
|
||||
{{ loginLabel }}
|
||||
</k-segmented-button>
|
||||
<k-segmented-button
|
||||
</SkySegmentedButton>
|
||||
<SkySegmentedButton
|
||||
class="app-profile-auth__mode-button app-profile-auth__mode-button--register"
|
||||
:class="{
|
||||
'app-profile-auth__mode-button--active': mode === 'register',
|
||||
@@ -146,8 +138,8 @@ const canSubmit = computed(() => {
|
||||
@click="emit('update:mode', 'register')"
|
||||
>
|
||||
{{ registerLabel }}
|
||||
</k-segmented-button>
|
||||
</k-segmented>
|
||||
</SkySegmentedButton>
|
||||
</SkySegmented>
|
||||
|
||||
<div v-if="mode === 'register'" class="app-profile-auth__photo">
|
||||
<span class="app-profile-auth__avatar">
|
||||
@@ -156,16 +148,16 @@ const canSubmit = computed(() => {
|
||||
<i><Camera :size="11" /></i>
|
||||
</span>
|
||||
<div>
|
||||
<k-button rounded outline @click="emit('gallery')">
|
||||
<SkyButton rounded outline @click="emit('gallery')">
|
||||
<Images :size="15" />{{ galleryLabel }}
|
||||
</k-button>
|
||||
<k-button rounded outline @click="emit('camera')">
|
||||
</SkyButton>
|
||||
<SkyButton rounded outline @click="emit('camera')">
|
||||
<Camera :size="15" />{{ cameraLabel }}
|
||||
</k-button>
|
||||
</SkyButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-profile-auth__identity">
|
||||
<div v-if="!emailAsField" class="app-profile-auth__identity">
|
||||
<span><Mail :size="17" /></span>
|
||||
<div>
|
||||
<small>{{ emailLabel }}</small>
|
||||
@@ -174,71 +166,34 @@ const canSubmit = computed(() => {
|
||||
<LockKeyhole :size="15" />
|
||||
</div>
|
||||
|
||||
<label
|
||||
v-if="
|
||||
variant === 'centered' && (mode === 'register' || !requirePassword)
|
||||
"
|
||||
class="app-profile-auth__username-field"
|
||||
for="app-profile-auth-username"
|
||||
>
|
||||
<span><UserRound :size="17" /></span>
|
||||
<div>
|
||||
<small>{{ usernameLabel }}</small>
|
||||
<input
|
||||
id="app-profile-auth-username"
|
||||
:value="username"
|
||||
:maxlength="maxUsernameLength"
|
||||
:placeholder="usernamePlaceholder"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@input="
|
||||
emit('update:username', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label
|
||||
v-if="variant === 'centered' && requirePassword"
|
||||
class="app-profile-auth__password-field"
|
||||
for="app-profile-auth-password"
|
||||
>
|
||||
<span><LockKeyhole :size="17" /></span>
|
||||
<div>
|
||||
<small>{{ passwordLabel }}</small>
|
||||
<input
|
||||
id="app-profile-auth-password"
|
||||
:value="password"
|
||||
maxlength="72"
|
||||
:placeholder="passwordPlaceholder"
|
||||
:autocomplete="
|
||||
mode === 'login' ? 'current-password' : 'new-password'
|
||||
"
|
||||
type="password"
|
||||
@input="
|
||||
emit('update:password', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<k-list
|
||||
v-else-if="variant !== 'centered'"
|
||||
inset
|
||||
strong
|
||||
class="app-profile-auth__fields"
|
||||
>
|
||||
<k-list-input
|
||||
<SkyList inset strong class="app-profile-auth__fields">
|
||||
<SkyField
|
||||
v-if="emailAsField"
|
||||
class="app-profile-auth__email-field"
|
||||
input-id="app-profile-auth-email"
|
||||
:label="emailLabel"
|
||||
:value="email"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
inputmode="email"
|
||||
readonly
|
||||
outline
|
||||
>
|
||||
<template #leading><Mail :size="17" /></template>
|
||||
<template #trailing><LockKeyhole :size="15" /></template>
|
||||
</SkyField>
|
||||
<SkyField
|
||||
v-if="variant !== 'centered' || mode === 'register' || !requirePassword"
|
||||
class="app-profile-auth__credential-field"
|
||||
input-id="app-profile-auth-username"
|
||||
:label="usernameLabel"
|
||||
:value="username"
|
||||
:maxlength="maxUsernameLength"
|
||||
:minlength="minUsernameLength"
|
||||
:placeholder="usernamePlaceholder"
|
||||
autocomplete="username"
|
||||
:help="usernameHelp"
|
||||
:type="usernameInputType"
|
||||
:autocomplete="usernameAutocomplete"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
@@ -247,26 +202,80 @@ const canSubmit = computed(() => {
|
||||
emit('update:username', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
/>
|
||||
</k-list>
|
||||
>
|
||||
<template v-if="usernameInputType === 'password'" #leading>
|
||||
<LockKeyhole :size="17" />
|
||||
</template>
|
||||
</SkyField>
|
||||
<SkyField
|
||||
v-if="requirePassword"
|
||||
class="app-profile-auth__credential-field"
|
||||
input-id="app-profile-auth-password"
|
||||
:label="passwordLabel"
|
||||
:value="password"
|
||||
type="password"
|
||||
maxlength="72"
|
||||
minlength="8"
|
||||
:placeholder="passwordPlaceholder"
|
||||
:autocomplete="
|
||||
mode === 'login' ? 'current-password' : 'new-password'
|
||||
"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
outline
|
||||
@input="
|
||||
emit('update:password', ($event.target as HTMLInputElement).value)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
>
|
||||
<template #leading><LockKeyhole :size="17" /></template>
|
||||
</SkyField>
|
||||
<SkyField
|
||||
v-if="showConfirmPassword"
|
||||
class="app-profile-auth__credential-field"
|
||||
input-id="app-profile-auth-confirm-password"
|
||||
:label="confirmPasswordLabel"
|
||||
:value="confirmPassword"
|
||||
type="password"
|
||||
:maxlength="maxUsernameLength"
|
||||
:minlength="minUsernameLength"
|
||||
:placeholder="confirmPasswordPlaceholder"
|
||||
:error="confirmPasswordError"
|
||||
autocomplete="new-password"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
outline
|
||||
@input="
|
||||
emit(
|
||||
'update:confirmPassword',
|
||||
($event.target as HTMLInputElement).value,
|
||||
)
|
||||
"
|
||||
@keydown.enter="emit('submit')"
|
||||
>
|
||||
<template #leading><LockKeyhole :size="17" /></template>
|
||||
</SkyField>
|
||||
</SkyList>
|
||||
|
||||
<div v-if="error" class="app-profile-auth__error" role="alert">
|
||||
{{ error }}
|
||||
</div>
|
||||
<k-button
|
||||
<SkyButton
|
||||
large
|
||||
rounded
|
||||
class="app-profile-auth__submit"
|
||||
:disabled="!canSubmit || pending"
|
||||
@click="emit('submit')"
|
||||
>
|
||||
<k-preloader v-if="pending" />
|
||||
<SkySpinner v-if="pending" />
|
||||
<template v-else>
|
||||
<span>{{ mode === 'login' ? loginLabel : registerLabel }}</span>
|
||||
<ArrowRight :size="18" />
|
||||
</template>
|
||||
</k-button>
|
||||
</k-glass>
|
||||
</SkyButton>
|
||||
</SkyGlass>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -4,18 +4,20 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
defineProps<{
|
||||
avatarUrl: string | null
|
||||
confirmPassword: string
|
||||
email: string
|
||||
error: string
|
||||
mode: 'login' | 'register'
|
||||
password: string
|
||||
pending: boolean
|
||||
username: string
|
||||
}>()
|
||||
const emit = defineEmits<{
|
||||
camera: []
|
||||
gallery: []
|
||||
submit: []
|
||||
'update:confirmPassword': [value: string]
|
||||
'update:mode': [value: 'login' | 'register']
|
||||
'update:username': [value: string]
|
||||
'update:password': [value: string]
|
||||
}>()
|
||||
|
||||
const phone = usePhoneStore()
|
||||
@@ -23,25 +25,186 @@ const phone = usePhoneStore()
|
||||
|
||||
<template>
|
||||
<AppProfileAuth
|
||||
class="citymarkt-auth"
|
||||
:avatar-url="avatarUrl"
|
||||
:body="phone.t('Apps.citymarkt.authBody')"
|
||||
:body="
|
||||
phone.t(
|
||||
mode === 'login'
|
||||
? 'Apps.citymarkt.authLoginBody'
|
||||
: 'Apps.citymarkt.authRegisterBody',
|
||||
)
|
||||
"
|
||||
:camera-label="phone.t('Apps.citymarkt.takePhoto')"
|
||||
:confirm-password="confirmPassword"
|
||||
:confirm-password-error="
|
||||
confirmPassword && confirmPassword !== password
|
||||
? phone.t('Apps.citymarkt.passwordsMismatch')
|
||||
: false
|
||||
"
|
||||
:confirm-password-label="phone.t('Apps.citymarkt.authConfirmPassword')"
|
||||
:confirm-password-placeholder="phone.t('Apps.citymarkt.authConfirmPlaceholder')"
|
||||
:email="email"
|
||||
email-as-field
|
||||
: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"
|
||||
:username="password"
|
||||
username-input-type="password"
|
||||
:username-autocomplete="
|
||||
mode === 'login' ? 'current-password' : 'new-password'
|
||||
"
|
||||
:username-label="phone.t('Apps.citymarkt.authPassword')"
|
||||
:username-placeholder="phone.t('Apps.citymarkt.authPasswordPlaceholder')"
|
||||
:username-help="
|
||||
mode === 'register' ? phone.t('Apps.citymarkt.authPasswordHelp') : ''
|
||||
"
|
||||
:min-username-length="6"
|
||||
:max-username-length="64"
|
||||
:show-confirm-password="mode === 'register'"
|
||||
:submit-enabled="
|
||||
Boolean(
|
||||
email &&
|
||||
password.length >= 6 &&
|
||||
password.length <= 64 &&
|
||||
(mode === 'login' || confirmPassword === password),
|
||||
)
|
||||
"
|
||||
:pending="pending"
|
||||
:register-label="phone.t('Apps.citymarkt.register')"
|
||||
:title="phone.t('Apps.citymarkt.authTitle')"
|
||||
:username="username"
|
||||
:username-label="phone.t('Apps.citymarkt.authUsername')"
|
||||
:title="
|
||||
phone.t(
|
||||
mode === 'login'
|
||||
? 'Apps.citymarkt.authLoginTitle'
|
||||
: 'Apps.citymarkt.authRegisterTitle',
|
||||
)
|
||||
"
|
||||
@camera="emit('camera')"
|
||||
@gallery="emit('gallery')"
|
||||
@submit="emit('submit')"
|
||||
@update:confirm-password="emit('update:confirmPassword', $event)"
|
||||
@update:mode="emit('update:mode', $event)"
|
||||
@update:username="emit('update:username', $event)"
|
||||
@update:username="emit('update:password', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.citymarkt-auth {
|
||||
--auth-accent: var(--yellow);
|
||||
--sky-app-accent: var(--yellow);
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__card) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__mode) {
|
||||
height: 44px;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
gap: 4px;
|
||||
padding: 3px;
|
||||
border-radius: 15px;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__mode-button) {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
border-radius: 11px !important;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
.citymarkt-auth
|
||||
:deep(.app-profile-auth__mode-button:not(.app-profile-auth__mode-button--active):hover) {
|
||||
background: #ffffff0d;
|
||||
color: #ffe06a;
|
||||
}
|
||||
.citymarkt-auth
|
||||
:deep(.app-profile-auth__mode-button:not(.app-profile-auth__mode-button--active):active) {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.citymarkt-auth
|
||||
:deep(.app-profile-auth__mode-button--active) {
|
||||
border-radius: 11px !important;
|
||||
color: #171816;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__submit) {
|
||||
color: #171816 !important;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields) {
|
||||
overflow: visible;
|
||||
background: transparent !important;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields > .sky-list__items) {
|
||||
display: grid;
|
||||
gap: 9px;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field) {
|
||||
min-height: 60px;
|
||||
margin: 0;
|
||||
padding: 0 13px;
|
||||
border: 1px solid #ffffff1f;
|
||||
border-radius: 14px;
|
||||
background: #ffffff0a;
|
||||
color: inherit;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field:focus-within) {
|
||||
border-color: color-mix(in srgb, var(--yellow) 68%, transparent);
|
||||
background: #ffffff10;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__border) {
|
||||
display: none;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__media) {
|
||||
margin-right: 11px;
|
||||
padding: 0;
|
||||
color: var(--yellow);
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__inner) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__label) {
|
||||
display: block;
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
line-height: 14px;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__label-text) {
|
||||
position: static;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__control) {
|
||||
margin: 0;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__input) {
|
||||
height: 28px;
|
||||
min-height: 28px;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
}
|
||||
.citymarkt-auth
|
||||
:deep(.app-profile-auth__email-field .sky-field__input) {
|
||||
color: var(--muted);
|
||||
font-weight: 650;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__help),
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields .sky-field__error) {
|
||||
margin-top: 2px;
|
||||
font-size: 9px;
|
||||
line-height: 12px;
|
||||
}
|
||||
.citymarkt-auth :deep(.app-profile-auth__photo),
|
||||
.citymarkt-auth :deep(.app-profile-auth__identity),
|
||||
.citymarkt-auth :deep(.app-profile-auth__fields),
|
||||
.citymarkt-auth :deep(.app-profile-auth__error) {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -47,4 +47,28 @@ describe('marketplace store offers', () => {
|
||||
inquiryId: 'inquiry-id',
|
||||
})
|
||||
})
|
||||
|
||||
it('authenticates CityMarkt with the iFruit password and stores the profile', async () => {
|
||||
const profile = {
|
||||
avatar_media_id: null,
|
||||
avatar_url: null,
|
||||
bio: '',
|
||||
display_name: 'demo',
|
||||
email: 'demo@ifruit.com',
|
||||
exists: true,
|
||||
listing_count: 0,
|
||||
}
|
||||
mockNuiCall.mockResolvedValueOnce({ data: profile, success: true })
|
||||
|
||||
const marketplace = useMarketplaceStore()
|
||||
const response = await marketplace.authenticate('register', 'secret12', 7)
|
||||
|
||||
expect(response).toEqual({ data: profile, success: true })
|
||||
expect(mockNuiCall).toHaveBeenCalledWith('marketplace:auth', {
|
||||
avatarMediaId: 7,
|
||||
mode: 'register',
|
||||
password: 'secret12',
|
||||
})
|
||||
expect(marketplace.profile).toEqual(profile)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,19 @@ export const useMarketplaceStore = defineStore('marketplace', {
|
||||
profile: null as MarketplaceProfile | null,
|
||||
}),
|
||||
actions: {
|
||||
async authenticate(
|
||||
mode: 'login' | 'register',
|
||||
password: string,
|
||||
avatarMediaId = 0,
|
||||
): Promise<NuiResponse<MarketplaceProfile>> {
|
||||
const response = await nuiCall<MarketplaceProfile>('marketplace:auth', {
|
||||
avatarMediaId,
|
||||
mode,
|
||||
password,
|
||||
})
|
||||
if (response.success && response.data) this.profile = response.data
|
||||
return response
|
||||
},
|
||||
async loadProfile(): Promise<boolean> {
|
||||
const response = await nuiCall<MarketplaceProfile>('marketplace:profile')
|
||||
if (response.success && response.data) this.profile = response.data
|
||||
|
||||
@@ -3159,18 +3159,29 @@ const defaultLocales: LocaleTree = {
|
||||
signInTitle: 'Sign in to Sky Cloud',
|
||||
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',
|
||||
authLoginTitle: 'Welcome back',
|
||||
authRegisterTitle: 'Create your profile',
|
||||
authLoginBody: 'Sign in with the iFruit account linked to this phone.',
|
||||
authRegisterBody:
|
||||
'Confirm your linked iFruit account to create a CityMarkt profile.',
|
||||
login: 'Sign in',
|
||||
register: 'Create profile',
|
||||
authPassword: 'iFruit password',
|
||||
authPasswordPlaceholder: 'Enter your iFruit password',
|
||||
authPasswordHelp: 'Use 6–64 characters.',
|
||||
authConfirmPassword: 'Repeat password',
|
||||
authConfirmPlaceholder: 'Repeat your iFruit password',
|
||||
passwordsMismatch: 'The passwords do not match.',
|
||||
authErrors: {
|
||||
no_ifruit_account: 'Connect a Sky Cloud account in Settings first.',
|
||||
invalid_username: 'Enter the username of your CityMarkt profile.',
|
||||
invalid_password: 'Password must be 6–64 characters.',
|
||||
invalid_credentials: 'The iFruit email or password is incorrect.',
|
||||
invalid_profile_image: 'Choose a valid photo from this phone.',
|
||||
profile_not_found: 'No CityMarkt profile exists for this iFruit email.',
|
||||
profile_exists:
|
||||
'A CityMarkt profile already exists. Use Login instead.',
|
||||
'A CityMarkt profile already exists. Use Sign in instead.',
|
||||
rate_limited: 'Too many attempts. Try again in a minute.',
|
||||
default: 'CityMarkt authentication failed.',
|
||||
},
|
||||
noMessages: 'No conversations',
|
||||
noMessagesBody: 'Messages about offers will appear here.',
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
MessageCircle,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Phone as PhoneIcon,
|
||||
Rows3,
|
||||
Search,
|
||||
Send,
|
||||
@@ -38,6 +39,7 @@ import {
|
||||
SkyButton,
|
||||
SkyGlass,
|
||||
SkyIcon,
|
||||
SkyLink,
|
||||
SkyNotification,
|
||||
SkyNavbar,
|
||||
SkyAppPage,
|
||||
@@ -46,7 +48,7 @@ import {
|
||||
SkyTabButton,
|
||||
SkyToolbarPane,
|
||||
} from '@/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import CityMarktSelect from '@/components/citymarkt/CityMarktSelect.vue'
|
||||
@@ -63,6 +65,7 @@ import { useMessageMediaStore } from '@/stores/messageMedia'
|
||||
import { usePagesStore } from '@/stores/pages'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
import { parseDatabaseDate, type DatabaseDateValue } from '@/utils/date'
|
||||
import { formatPhoneNumber } from '@/utils/phone'
|
||||
import type {
|
||||
MarketplaceCategory,
|
||||
MarketplaceChat,
|
||||
@@ -128,10 +131,13 @@ const pages = usePagesStore()
|
||||
const logoutDialogOpen = ref(false)
|
||||
const authMode = ref<'login' | 'register'>('login')
|
||||
const authError = ref('')
|
||||
const authPassword = ref('')
|
||||
const authConfirmPassword = ref('')
|
||||
const tab = ref<Tab>('discover')
|
||||
const screen = ref<Screen>('main')
|
||||
const selectedListing = ref<MarketplaceListing | null>(null)
|
||||
const selectedChat = ref<MarketplaceChat | null>(null)
|
||||
const chatMessages = ref<HTMLElement | null>(null)
|
||||
const search = ref('')
|
||||
const listingLayout = ref<'compact' | 'wide'>('compact')
|
||||
const category = ref<MarketplaceCategory | 'all'>('all')
|
||||
@@ -558,61 +564,57 @@ async function finishAuthentication(): Promise<void> {
|
||||
function switchAuthMode(mode: 'login' | 'register'): void {
|
||||
authMode.value = mode
|
||||
authError.value = ''
|
||||
profileDraft.value.displayName =
|
||||
mode === 'login'
|
||||
? (marketplace.profile?.display_name ?? '')
|
||||
: (account.email.split('@')[0] ?? '')
|
||||
authPassword.value = ''
|
||||
authConfirmPassword.value = ''
|
||||
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')
|
||||
if (authPassword.value.length < 6 || authPassword.value.length > 64) {
|
||||
authError.value = phone.t('Apps.citymarkt.authErrors.invalid_password')
|
||||
return
|
||||
}
|
||||
if (
|
||||
authMode.value === 'register' &&
|
||||
authConfirmPassword.value !== authPassword.value
|
||||
) {
|
||||
authError.value = phone.t('Apps.citymarkt.passwordsMismatch')
|
||||
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
|
||||
}
|
||||
const response = await marketplace.authenticate(
|
||||
authMode.value,
|
||||
authPassword.value,
|
||||
selectedProfilePhoto.value?.id ?? profileDraft.value.avatarMediaId,
|
||||
)
|
||||
profilePending.value = false
|
||||
if (!response.success) {
|
||||
const knownErrors = [
|
||||
'invalid_credentials',
|
||||
'invalid_password',
|
||||
'invalid_profile_image',
|
||||
'profile_exists',
|
||||
'profile_not_found',
|
||||
'rate_limited',
|
||||
]
|
||||
authError.value = phone.t(
|
||||
`Apps.citymarkt.authErrors.${
|
||||
response.error && knownErrors.includes(response.error)
|
||||
? response.error
|
||||
: 'default'
|
||||
}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
authPassword.value = ''
|
||||
authConfirmPassword.value = ''
|
||||
appAuth.signIn('citymarkt', account.email)
|
||||
await finishAuthentication()
|
||||
}
|
||||
@@ -839,9 +841,16 @@ async function sendFirstMessage(): Promise<void> {
|
||||
if (chatResponse.success && chatResponse.data) {
|
||||
selectedChat.value = chatResponse.data
|
||||
screen.value = 'chat'
|
||||
await scrollChatToBottom()
|
||||
}
|
||||
}
|
||||
|
||||
async function scrollChatToBottom(): Promise<void> {
|
||||
await nextTick()
|
||||
if (!chatMessages.value) return
|
||||
chatMessages.value.scrollTop = chatMessages.value.scrollHeight
|
||||
}
|
||||
|
||||
async function openChat(id: string): Promise<void> {
|
||||
const response = await marketplace.getInquiry(id)
|
||||
if (response.success && response.data) {
|
||||
@@ -849,6 +858,7 @@ async function openChat(id: string): Promise<void> {
|
||||
message.value = ''
|
||||
offerPanelOpen.value = false
|
||||
screen.value = 'chat'
|
||||
await scrollChatToBottom()
|
||||
await Promise.all([marketplace.loadInquiries(), marketplace.loadCounts()])
|
||||
}
|
||||
}
|
||||
@@ -933,7 +943,10 @@ async function sendChatMessage(): Promise<void> {
|
||||
const refreshed = await marketplace.getInquiry(
|
||||
selectedChat.value.inquiry.id,
|
||||
)
|
||||
if (refreshed.data) selectedChat.value = refreshed.data
|
||||
if (refreshed.data) {
|
||||
selectedChat.value = refreshed.data
|
||||
await scrollChatToBottom()
|
||||
}
|
||||
} else setFeedback(`Apps.citymarkt.errors.${response.error ?? 'default'}`)
|
||||
}
|
||||
|
||||
@@ -1076,7 +1089,20 @@ onMounted(async () => {
|
||||
: `Apps.citymarkt.tabs.${tab}`,
|
||||
)
|
||||
"
|
||||
/>
|
||||
>
|
||||
<template v-if="tab === 'profile'" #right>
|
||||
<sky-link
|
||||
component="button"
|
||||
icon-only
|
||||
type="button"
|
||||
:aria-label="phone.t('Common.signOut')"
|
||||
:title="phone.t('Common.signOut')"
|
||||
@click="logoutDialogOpen = true"
|
||||
>
|
||||
<LogOut :size="18" />
|
||||
</sky-link>
|
||||
</template>
|
||||
</sky-navbar>
|
||||
|
||||
<div v-if="!onboardingReady" class="citymarkt__gate-loading">
|
||||
{{ phone.t('Common.loading') }}
|
||||
@@ -1281,7 +1307,8 @@ onMounted(async () => {
|
||||
<div v-if="!isAuthenticated" class="citymarkt__auth">
|
||||
<CityMarktAuth
|
||||
v-model:mode="authMode"
|
||||
v-model:username="profileDraft.displayName"
|
||||
v-model:password="authPassword"
|
||||
v-model:confirm-password="authConfirmPassword"
|
||||
:avatar-url="selectedProfilePhoto?.url ?? null"
|
||||
:email="account.email"
|
||||
:error="authError"
|
||||
@@ -1470,16 +1497,6 @@ onMounted(async () => {
|
||||
>
|
||||
</div>
|
||||
</template>
|
||||
<sky-button
|
||||
large
|
||||
rounded
|
||||
outline
|
||||
class="citymarkt__logout"
|
||||
@click="logoutDialogOpen = true"
|
||||
>
|
||||
<LogOut :size="17" />
|
||||
{{ phone.t('Common.signOut') }}
|
||||
</sky-button>
|
||||
</template>
|
||||
</template>
|
||||
</section>
|
||||
@@ -1489,14 +1506,16 @@ onMounted(async () => {
|
||||
class="citymarkt__detail"
|
||||
>
|
||||
<div class="citymarkt__glass-actions">
|
||||
<sky-glass
|
||||
<sky-link
|
||||
component="button"
|
||||
icon-only
|
||||
type="button"
|
||||
class="citymarkt-detail-action"
|
||||
class="citymarkt-detail-action citymarkt-detail-action--back"
|
||||
:aria-label="phone.t('Common.back')"
|
||||
:title="phone.t('Common.back')"
|
||||
@click="screen = 'main'"
|
||||
><ArrowLeft :size="19"
|
||||
/></sky-glass>
|
||||
/></sky-link>
|
||||
<div>
|
||||
<sky-glass
|
||||
v-if="!selectedListing.is_owner"
|
||||
@@ -1536,7 +1555,13 @@ onMounted(async () => {
|
||||
:next-label="phone.t('Apps.citymarkt.nextPhoto')"
|
||||
:photo-label="phone.t('Apps.citymarkt.photo')"
|
||||
/>
|
||||
<div class="citymarkt__detail-body">
|
||||
<div
|
||||
class="citymarkt__detail-body"
|
||||
:class="{
|
||||
'citymarkt__detail-body--contact':
|
||||
!selectedListing.is_owner && isAuthenticated,
|
||||
}"
|
||||
>
|
||||
<div class="citymarkt__price-row">
|
||||
<div>
|
||||
<h2>{{ formatPrice(selectedListing) }}</h2>
|
||||
@@ -1575,10 +1600,18 @@ onMounted(async () => {
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="selectedListing.phone_number" class="citymarkt__phone">
|
||||
{{ phone.t('Apps.citymarkt.phone') }}:
|
||||
{{ selectedListing.phone_number }}
|
||||
</p>
|
||||
<sky-glass
|
||||
v-if="selectedListing.phone_number"
|
||||
class="citymarkt__phone"
|
||||
>
|
||||
<span class="citymarkt__phone-icon"><PhoneIcon :size="18" /></span>
|
||||
<span class="citymarkt__phone-copy">
|
||||
<small>{{ phone.t('Apps.citymarkt.phone') }}</small>
|
||||
<strong>{{
|
||||
formatPhoneNumber(selectedListing.phone_number)
|
||||
}}</strong>
|
||||
</span>
|
||||
</sky-glass>
|
||||
<button
|
||||
v-if="
|
||||
selectedListing.status === 'active' ||
|
||||
@@ -1624,7 +1657,8 @@ onMounted(async () => {
|
||||
><button
|
||||
v-if="
|
||||
selectedListing.status === 'reserved' ||
|
||||
selectedListing.status === 'expired'
|
||||
selectedListing.status === 'expired' ||
|
||||
selectedListing.status === 'sold'
|
||||
"
|
||||
@click="setListingStatus('active')"
|
||||
>
|
||||
@@ -1655,20 +1689,23 @@ onMounted(async () => {
|
||||
v-model="firstMessage"
|
||||
maxlength="1000"
|
||||
:placeholder="phone.t('Apps.citymarkt.messagePlaceholder')"
|
||||
/><button
|
||||
:disabled="!firstMessage.trim()"
|
||||
@click="sendFirstMessage"
|
||||
>
|
||||
<MessageCircle :size="17" />{{
|
||||
phone.t('Apps.citymarkt.contactSeller')
|
||||
}}
|
||||
</button>
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<sky-glass v-else class="citymarkt__glass-auth">{{
|
||||
phone.t('Apps.citymarkt.signInToMessage')
|
||||
}}</sky-glass>
|
||||
</div>
|
||||
<button
|
||||
v-if="!selectedListing.is_owner && isAuthenticated"
|
||||
class="citymarkt__contact-fixed"
|
||||
:disabled="!firstMessage.trim()"
|
||||
@click="sendFirstMessage"
|
||||
>
|
||||
<MessageCircle :size="17" />{{
|
||||
phone.t('Apps.citymarkt.contactSeller')
|
||||
}}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section v-else-if="screen === 'sell'" class="citymarkt__sell">
|
||||
@@ -1905,6 +1942,15 @@ onMounted(async () => {
|
||||
<small
|
||||
><MapPin :size="13" />
|
||||
{{ label('districts', draft.district) }}</small
|
||||
><div
|
||||
v-if="draft.showPhone && phone.device?.sim?.number"
|
||||
class="citymarkt__preview-phone"
|
||||
>
|
||||
<span class="citymarkt__phone-icon"><PhoneIcon :size="16" /></span>
|
||||
<span class="citymarkt__phone-copy">
|
||||
<small>{{ phone.t('Apps.citymarkt.phone') }}</small>
|
||||
<strong>{{ formatPhoneNumber(phone.device.sim.number) }}</strong>
|
||||
</span></div
|
||||
></template
|
||||
>
|
||||
</div>
|
||||
@@ -1925,7 +1971,14 @@ onMounted(async () => {
|
||||
class="citymarkt__chat"
|
||||
>
|
||||
<header>
|
||||
<sky-button component="button" clear rounded @click="closeChat"
|
||||
<sky-button
|
||||
component="button"
|
||||
class="citymarkt__chat-back"
|
||||
clear
|
||||
rounded
|
||||
:aria-label="phone.t('Common.back')"
|
||||
:title="phone.t('Common.back')"
|
||||
@click="closeChat"
|
||||
><ArrowLeft :size="19"
|
||||
/></sky-button>
|
||||
<div>
|
||||
@@ -1944,7 +1997,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
<i>{{ label('status', selectedChat.inquiry.status) }}</i></sky-glass
|
||||
>
|
||||
<div class="citymarkt__messages">
|
||||
<div ref="chatMessages" class="citymarkt__messages">
|
||||
<template v-for="item in chatTimeline" :key="item.key">
|
||||
<article
|
||||
v-if="item.kind === 'message'"
|
||||
@@ -2030,7 +2083,13 @@ onMounted(async () => {
|
||||
v-model="message"
|
||||
maxlength="1000"
|
||||
:placeholder="phone.t('Apps.citymarkt.writeMessage')"
|
||||
/><button :disabled="!message.trim()"><Send :size="17" /></button>
|
||||
/><button
|
||||
:aria-label="phone.t('Common.send')"
|
||||
:title="phone.t('Common.send')"
|
||||
:disabled="!message.trim()"
|
||||
>
|
||||
<Send :size="17" />
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@@ -2039,7 +2098,13 @@ onMounted(async () => {
|
||||
class="citymarkt__report"
|
||||
>
|
||||
<header>
|
||||
<sky-button component="button" clear rounded @click="screen = 'detail'"
|
||||
<sky-button
|
||||
component="button"
|
||||
clear
|
||||
rounded
|
||||
:aria-label="phone.t('Common.back')"
|
||||
:title="phone.t('Common.back')"
|
||||
@click="screen = 'detail'"
|
||||
><ArrowLeft :size="19" /></sky-button
|
||||
><strong>{{ phone.t('Apps.citymarkt.reportListing') }}</strong>
|
||||
</header>
|
||||
@@ -2275,7 +2340,7 @@ onMounted(async () => {
|
||||
}
|
||||
.citymarkt__section-title div {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.citymarkt__section-title strong {
|
||||
@@ -2561,6 +2626,18 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
background: #151613;
|
||||
}
|
||||
.citymarkt__detail {
|
||||
padding-top: var(--sky-safe-area-top);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.citymarkt__chat {
|
||||
padding-top: var(--sky-safe-area-top);
|
||||
padding-bottom: calc(var(--sky-safe-area-bottom) + 4px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
isolation: isolate;
|
||||
}
|
||||
.citymarkt--light .citymarkt__detail,
|
||||
.citymarkt--light .citymarkt__sell,
|
||||
.citymarkt--light .citymarkt__chat,
|
||||
@@ -2596,10 +2673,14 @@ onMounted(async () => {
|
||||
font-size: 8px;
|
||||
}
|
||||
.citymarkt__detail-body {
|
||||
height: calc(100% - 205px);
|
||||
min-height: 0;
|
||||
padding: 13px 15px 35px;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.citymarkt__detail-body--contact {
|
||||
padding-bottom: calc(var(--sky-safe-area-bottom) + 72px);
|
||||
}
|
||||
.citymarkt__price-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -2655,11 +2736,48 @@ onMounted(async () => {
|
||||
.citymarkt__seller small {
|
||||
display: block;
|
||||
}
|
||||
.citymarkt__seller small,
|
||||
.citymarkt__phone {
|
||||
.citymarkt__seller small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
.citymarkt__phone,
|
||||
.citymarkt__preview-phone {
|
||||
margin: 4px 0 10px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.citymarkt__phone-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
flex: 0 0 36px;
|
||||
border-radius: 11px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: #ffcc001a;
|
||||
color: var(--yellow);
|
||||
}
|
||||
.citymarkt__phone-copy {
|
||||
min-width: 0;
|
||||
}
|
||||
.citymarkt__phone-copy small,
|
||||
.citymarkt__phone-copy strong {
|
||||
display: block;
|
||||
}
|
||||
.citymarkt__phone-copy small {
|
||||
margin-bottom: 2px;
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.citymarkt__phone-copy strong {
|
||||
font-size: 14px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
letter-spacing: 0.02em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.citymarkt__composer textarea {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
@@ -2672,6 +2790,7 @@ onMounted(async () => {
|
||||
font-size: 10px;
|
||||
}
|
||||
.citymarkt__composer button,
|
||||
.citymarkt__contact-fixed,
|
||||
.citymarkt__owner-actions button,
|
||||
.citymarkt__report > div button {
|
||||
width: 100%;
|
||||
@@ -2688,6 +2807,19 @@ onMounted(async () => {
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.citymarkt__contact-fixed {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
right: calc(var(--sky-safe-area-right) + 15px);
|
||||
bottom: calc(var(--sky-safe-area-bottom) + 10px);
|
||||
left: calc(var(--sky-safe-area-left) + 15px);
|
||||
width: auto;
|
||||
min-height: 44px;
|
||||
margin: 0;
|
||||
}
|
||||
.citymarkt__contact-fixed:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
.citymarkt__owner-actions {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
@@ -2708,7 +2840,6 @@ onMounted(async () => {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
}
|
||||
.citymarkt__sell > header,
|
||||
.citymarkt__chat > header,
|
||||
.citymarkt__report > header {
|
||||
height: 54px;
|
||||
@@ -2718,8 +2849,12 @@ onMounted(async () => {
|
||||
gap: 8px;
|
||||
border-bottom: 1px solid #ffffff12;
|
||||
}
|
||||
.citymarkt__sell > header > button:first-child,
|
||||
.citymarkt__chat > header > button,
|
||||
.citymarkt__chat > header {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
flex: none;
|
||||
background: inherit;
|
||||
}
|
||||
.citymarkt__report > header > button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
@@ -2730,31 +2865,23 @@ onMounted(async () => {
|
||||
place-items: center;
|
||||
background: var(--panel);
|
||||
}
|
||||
.citymarkt__sell > header > div,
|
||||
.citymarkt__chat > header > div {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.citymarkt__sell > header strong,
|
||||
.citymarkt__sell > header small,
|
||||
.citymarkt__chat > header strong,
|
||||
.citymarkt__chat > header small {
|
||||
display: block;
|
||||
}
|
||||
.citymarkt__sell > header small,
|
||||
.citymarkt__chat > header small {
|
||||
color: var(--muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
.citymarkt__sell > header > button:last-child {
|
||||
padding: 6px;
|
||||
border: 0;
|
||||
background: none;
|
||||
color: var(--yellow);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.citymarkt__sell > header > button:disabled {
|
||||
opacity: 0.35;
|
||||
.citymarkt__chat > header strong,
|
||||
.citymarkt__chat > header small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.citymarkt__progress {
|
||||
height: 3px;
|
||||
@@ -2887,6 +3014,10 @@ onMounted(async () => {
|
||||
gap: 3px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.citymarkt__sell-body > .citymarkt__preview-phone {
|
||||
margin-top: 8px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.citymarkt__previous {
|
||||
position: absolute;
|
||||
bottom: 33px;
|
||||
@@ -2955,11 +3086,10 @@ onMounted(async () => {
|
||||
text-align: right;
|
||||
}
|
||||
.citymarkt__chat-composer {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 29px;
|
||||
left: 8px;
|
||||
position: static;
|
||||
flex: none;
|
||||
height: 40px;
|
||||
margin: 0 8px;
|
||||
padding: 4px 4px 4px 10px;
|
||||
border-radius: 14px;
|
||||
display: flex;
|
||||
@@ -3121,22 +3251,23 @@ onMounted(async () => {
|
||||
background: #0000000b;
|
||||
}
|
||||
.citymarkt__messages {
|
||||
position: absolute;
|
||||
top: 158px;
|
||||
right: 0;
|
||||
bottom: 116px;
|
||||
left: 0;
|
||||
position: static;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
height: auto;
|
||||
}
|
||||
.citymarkt__messages .citymarkt-offer {
|
||||
width: 92%;
|
||||
max-width: 92%;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.citymarkt__messages .citymarkt-offer--own {
|
||||
align-self: flex-end;
|
||||
}
|
||||
.citymarkt__chat-actions {
|
||||
position: absolute;
|
||||
right: 9px;
|
||||
bottom: 75px;
|
||||
left: 9px;
|
||||
position: static;
|
||||
flex: none;
|
||||
margin: 0 9px 6px;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
@@ -3695,8 +3826,7 @@ onMounted(async () => {
|
||||
.citymarkt__list b,
|
||||
.citymarkt__profile small,
|
||||
.citymarkt__price-row > small,
|
||||
.citymarkt__seller small,
|
||||
.citymarkt__phone {
|
||||
.citymarkt__seller small {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
.citymarkt__inquiries small,
|
||||
@@ -3721,6 +3851,7 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
.citymarkt__composer button,
|
||||
.citymarkt__contact-fixed,
|
||||
.citymarkt__owner-actions button,
|
||||
.citymarkt__report > div button {
|
||||
font-size: 13px;
|
||||
@@ -3842,9 +3973,9 @@ onMounted(async () => {
|
||||
.citymarkt__glass-actions {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 52px;
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
top: calc(var(--sky-safe-area-top) + 8px);
|
||||
right: calc(var(--sky-safe-area-right) + 12px);
|
||||
left: calc(var(--sky-safe-area-left) + 12px);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -3862,6 +3993,16 @@ onMounted(async () => {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.citymarkt button.citymarkt-detail-action--back {
|
||||
border: 1px solid #ffffff26;
|
||||
background: #181916b8;
|
||||
color: #fff;
|
||||
box-shadow: 0 5px 14px #00000059;
|
||||
backdrop-filter: blur(12px) saturate(1.15);
|
||||
}
|
||||
.citymarkt button.citymarkt-detail-action--back:hover {
|
||||
background: #24251fd9;
|
||||
}
|
||||
.citymarkt-detail-action.active {
|
||||
color: var(--yellow);
|
||||
}
|
||||
@@ -4003,6 +4144,8 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
line-height: 15px;
|
||||
white-space: normal;
|
||||
overflow-wrap: anywhere;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
}
|
||||
@@ -4220,12 +4363,16 @@ onMounted(async () => {
|
||||
color: var(--yellow);
|
||||
}
|
||||
.citymarkt__glass-chat-listing {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
flex: none;
|
||||
margin: 8px 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 11px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 8px 18px #0000004d;
|
||||
}
|
||||
.citymarkt__glass-chat-listing strong,
|
||||
.citymarkt__glass-chat-listing span {
|
||||
@@ -4240,7 +4387,6 @@ onMounted(async () => {
|
||||
font-style: normal;
|
||||
}
|
||||
.citymarkt-create-navbar {
|
||||
--sky-safe-area-top: 46px;
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
top: 0;
|
||||
@@ -4261,9 +4407,26 @@ onMounted(async () => {
|
||||
font-size: 13px;
|
||||
}
|
||||
.citymarkt__profile-editor {
|
||||
min-height: 100%;
|
||||
padding: 14px;
|
||||
border: 1px solid #ffffff14;
|
||||
border-radius: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--panel);
|
||||
}
|
||||
.citymarkt__chat-back {
|
||||
width: var(--sky-touch-target);
|
||||
min-width: var(--sky-touch-target);
|
||||
max-width: var(--sky-touch-target);
|
||||
height: var(--sky-touch-target);
|
||||
min-height: var(--sky-touch-target);
|
||||
max-height: var(--sky-touch-target);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
flex: 0 0 var(--sky-touch-target);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--panel);
|
||||
}
|
||||
.citymarkt__profile-editor > header {
|
||||
@@ -4360,7 +4523,8 @@ onMounted(async () => {
|
||||
min-height: 72px;
|
||||
}
|
||||
.citymarkt__profile-actions {
|
||||
margin-top: 14px;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -4380,11 +4544,6 @@ onMounted(async () => {
|
||||
.citymarkt__profile-actions button:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.citymarkt__logout {
|
||||
width: 100%;
|
||||
margin-top: 12px;
|
||||
color: #ff796f;
|
||||
}
|
||||
:global(.citymarkt--light) .citymarkt__profile-editor {
|
||||
border-color: #00000012;
|
||||
}
|
||||
@@ -4424,14 +4583,19 @@ onMounted(async () => {
|
||||
.citymarkt__sell > .citymarkt__progress {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
top: 101px;
|
||||
top: calc(
|
||||
var(--sky-safe-area-top) + var(--sky-navbar-height) + var(--sky-space-3)
|
||||
);
|
||||
right: 0;
|
||||
left: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.citymarkt__sell > .citymarkt__sell-body {
|
||||
position: absolute;
|
||||
top: 104px;
|
||||
top: calc(
|
||||
var(--sky-safe-area-top) + var(--sky-navbar-height) + var(--sky-space-3) +
|
||||
3px
|
||||
);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
@@ -4526,6 +4690,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.citymarkt__content--auth {
|
||||
padding: 68px 18px 34px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 54px 18px 30px;
|
||||
}
|
||||
.citymarkt__content--auth .citymarkt__auth {
|
||||
width: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -9697,6 +9697,53 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (endpoint === 'marketplace:auth') {
|
||||
const password = String(request.body.password ?? '')
|
||||
if (password.length < 6 || password.length > 64) {
|
||||
response.json({
|
||||
success: false,
|
||||
error:
|
||||
request.body.mode === 'register'
|
||||
? 'invalid_password'
|
||||
: 'invalid_credentials',
|
||||
})
|
||||
return
|
||||
}
|
||||
if (request.body.mode === 'login') {
|
||||
response.json(
|
||||
marketplaceProfile.exists
|
||||
? { success: true, data: marketplaceProfile }
|
||||
: { success: false, error: 'profile_not_found' },
|
||||
)
|
||||
return
|
||||
}
|
||||
if (request.body.mode !== 'register') {
|
||||
response.json({ success: false, error: 'invalid_request' })
|
||||
return
|
||||
}
|
||||
if (marketplaceProfile.exists) {
|
||||
response.json({ success: false, error: 'profile_exists' })
|
||||
return
|
||||
}
|
||||
const avatarMediaId = Number(request.body.avatarMediaId) || 0
|
||||
const avatar = mockMedia.find(
|
||||
(item) => item.id === avatarMediaId && item.mediaType === 'photo',
|
||||
)
|
||||
if (avatarMediaId > 0 && !avatar) {
|
||||
response.json({ success: false, error: 'invalid_profile_image' })
|
||||
return
|
||||
}
|
||||
marketplaceProfile = {
|
||||
...marketplaceProfile,
|
||||
avatar_media_id: avatarMediaId || null,
|
||||
avatar_url: avatar?.url ?? null,
|
||||
bio: '',
|
||||
display_name: linkedAccount?.email?.split('@')[0] ?? 'CityMarkt',
|
||||
exists: true,
|
||||
}
|
||||
response.json({ success: true, data: marketplaceProfile })
|
||||
return
|
||||
}
|
||||
if (endpoint.startsWith('marketplace:') && !authenticated) {
|
||||
response.json({ success: false, error: 'not_authenticated' })
|
||||
return
|
||||
@@ -9792,7 +9839,7 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
item_condition: request.body.condition,
|
||||
price_type: request.body.priceType,
|
||||
show_phone: request.body.showPhone ? 1 : 0,
|
||||
phone_number: null,
|
||||
phone_number: request.body.showPhone ? (mockSim?.number ?? null) : null,
|
||||
status: 'active',
|
||||
revision: 1,
|
||||
created_at: '2026-08-06 11:00:00',
|
||||
@@ -9831,8 +9878,10 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
image: selected[0]?.gradient ?? null,
|
||||
images: selected,
|
||||
item_condition: request.body.condition,
|
||||
phone_number: request.body.showPhone ? (mockSim?.number ?? null) : null,
|
||||
price_type: request.body.priceType,
|
||||
revision: item.revision + 1,
|
||||
show_phone: request.body.showPhone ? 1 : 0,
|
||||
})
|
||||
response.json({ success: true, data: { revision: item.revision } })
|
||||
return
|
||||
@@ -9849,7 +9898,21 @@ app.post('/api/:endpoint', (request, response) => {
|
||||
const item = marketplaceListings.find(
|
||||
(listing) => listing.id === request.body.id,
|
||||
)
|
||||
if (item) item.status = request.body.status
|
||||
if (!item) {
|
||||
response.json({ success: false, error: 'listing_not_found' })
|
||||
return
|
||||
}
|
||||
const validTransitions = {
|
||||
active: ['reserved', 'expired', 'sold'],
|
||||
removed: ['active', 'reserved', 'expired'],
|
||||
reserved: ['active'],
|
||||
sold: ['active', 'reserved'],
|
||||
}
|
||||
if (!validTransitions[request.body.status]?.includes(item.status)) {
|
||||
response.json({ success: false, error: 'invalid_status' })
|
||||
return
|
||||
}
|
||||
item.status = request.body.status
|
||||
response.json({ success: true })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1533,14 +1533,22 @@ Locales["de"] = {
|
||||
free = "Frei", negotiablePrice = "${price} verhandelbar", money = "${price}",
|
||||
hoursAgo = "{count}Vor", daysAgo = "{count}Vor", photos = "Fotos", activeListings = "aktive Auflistungen",
|
||||
signInTitle = "Bei Sky Cloud anmelden", signInBody = "Logge dich in dein CityMarkt Profil ein, um zu verkaufen, zu speichern oder zu nachrichten.",
|
||||
authEyebrow = "Konto CityMarkt", authTitle = "Willkommen bei CityMarkt",
|
||||
authBody = "Deine iFruit-E-Mail wird automatisch verknüpft. Verwende deinen CityMarkt-Benutzernamen, um fortzufahren.",
|
||||
login = "Anmelden", register = "Registrieren", authUsername = "Benutzername",
|
||||
authEyebrow = "CityMarkt-Konto", authLoginTitle = "Willkommen zurück", authRegisterTitle = "Profil erstellen",
|
||||
authLoginBody = "Melde dich mit dem iFruit-Konto an, das mit diesem Telefon verknüpft ist.",
|
||||
authRegisterBody = "Bestätige dein verknüpftes iFruit-Konto, um ein CityMarkt-Profil zu erstellen.",
|
||||
login = "Anmelden", register = "Profil erstellen", authPassword = "iFruit-Passwort",
|
||||
authPasswordPlaceholder = "iFruit-Passwort eingeben", authPasswordHelp = "Verwende 6–64 Zeichen.",
|
||||
authConfirmPassword = "Passwort wiederholen", authConfirmPlaceholder = "iFruit-Passwort wiederholen",
|
||||
passwordsMismatch = "Die Passwörter stimmen nicht überein.",
|
||||
authErrors = {
|
||||
no_ifruit_account = "Schließ zuerst ein Sky Cloud-Konto in Einstellungen an.",
|
||||
invalid_username = "Gib den Benutzernamen deines CityMarkt Profils ein.",
|
||||
profile_not_found = "Für diese iFruit E-Mail existiert kein CityMarkt Profil.",
|
||||
profile_exists = "Ein CityMarkt-Profil existiert bereits. Verwende stattdessen die Anmeldung.",
|
||||
no_ifruit_account = "Verbinde zuerst in den Einstellungen ein Sky-Cloud-Konto.",
|
||||
invalid_password = "Das Passwort muss 6–64 Zeichen lang sein.",
|
||||
invalid_credentials = "Die iFruit-E-Mail oder das Passwort ist falsch.",
|
||||
invalid_profile_image = "Wähle ein gültiges Foto von diesem Telefon.",
|
||||
profile_not_found = "Für diese iFruit-E-Mail existiert kein CityMarkt-Profil.",
|
||||
profile_exists = "Ein CityMarkt-Profil existiert bereits. Verwende stattdessen Anmelden.",
|
||||
rate_limited = "Zu viele Versuche. Versuche es in einer Minute erneut.",
|
||||
default = "Die CityMarkt-Anmeldung ist fehlgeschlagen.",
|
||||
},
|
||||
noMessages = "Keine Gespräche", noMessagesBody = "Hier werden Nachrichten über Angebote erscheinen.",
|
||||
myListings = "Meine Einträge", favorites = "Favoriten", addFavorite = "Zu Favoriten hinzufügen", removeFavorite = "Von Favoriten entfernen", noProfileListings = "Hier ist noch nichts.",
|
||||
|
||||
@@ -1533,14 +1533,22 @@ Locales["en"] = {
|
||||
free = "Free", negotiablePrice = "${price} negotiable", money = "${price}",
|
||||
hoursAgo = "{count}h ago", daysAgo = "{count}d ago", photos = "photos", activeListings = "active listings",
|
||||
signInTitle = "Sign in to Sky Cloud", 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",
|
||||
authEyebrow = "CityMarkt account", authLoginTitle = "Welcome back", authRegisterTitle = "Create your profile",
|
||||
authLoginBody = "Sign in with the iFruit account linked to this phone.",
|
||||
authRegisterBody = "Confirm your linked iFruit account to create a CityMarkt profile.",
|
||||
login = "Sign in", register = "Create profile", authPassword = "iFruit password",
|
||||
authPasswordPlaceholder = "Enter your iFruit password", authPasswordHelp = "Use 6–64 characters.",
|
||||
authConfirmPassword = "Repeat password", authConfirmPlaceholder = "Repeat your iFruit password",
|
||||
passwordsMismatch = "The passwords do not match.",
|
||||
authErrors = {
|
||||
no_ifruit_account = "Connect a Sky Cloud account in Settings first.",
|
||||
invalid_username = "Enter the username of your CityMarkt profile.",
|
||||
invalid_password = "Password must be 6–64 characters.",
|
||||
invalid_credentials = "The iFruit email or password is incorrect.",
|
||||
invalid_profile_image = "Choose a valid photo from this phone.",
|
||||
profile_not_found = "No CityMarkt profile exists for this iFruit email.",
|
||||
profile_exists = "A CityMarkt profile already exists. Use Login instead.",
|
||||
profile_exists = "A CityMarkt profile already exists. Use Sign in instead.",
|
||||
rate_limited = "Too many attempts. Try again in a minute.",
|
||||
default = "CityMarkt authentication failed.",
|
||||
},
|
||||
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",
|
||||
|
||||
@@ -61,6 +61,7 @@ local server_callbacks = {
|
||||
"mail:empty-trash",
|
||||
"marketplace:list",
|
||||
"marketplace:profile",
|
||||
"marketplace:auth",
|
||||
"marketplace:profile-save",
|
||||
"marketplace:get",
|
||||
"marketplace:list-own",
|
||||
|
||||
@@ -123,6 +123,66 @@ Bridge.Callbacks.Register("sky_phone:marketplace:profile", function(source)
|
||||
return { success = true, data = profile_dto(account) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:marketplace:auth", function(source, data)
|
||||
local account, error_response = require_account(source)
|
||||
if not account then return error_response end
|
||||
if not SkyPhone.AllowOperation(source, "marketplace:auth", 8, 60) then
|
||||
return { success = false, error = "rate_limited" }
|
||||
end
|
||||
if type(data) ~= "table" or (data.mode ~= "login" and data.mode ~= "register") then
|
||||
return { success = false, error = "invalid_request" }
|
||||
end
|
||||
if not valid_text(data.password, Config.Mail.PasswordMinLength, Config.Mail.PasswordMaxLength) then
|
||||
return {
|
||||
success = false,
|
||||
error = data.mode == "register" and "invalid_password" or "invalid_credentials",
|
||||
}
|
||||
end
|
||||
|
||||
local credentials = Bridge.Database.Query([[
|
||||
SELECT `id` FROM `sky_phone_accounts`
|
||||
WHERE `id` = ? AND `email` = ? AND `password` = ?
|
||||
LIMIT 1
|
||||
]], { account.id, account.email, data.password })
|
||||
if not credentials[1] then
|
||||
return { success = false, error = "invalid_credentials" }
|
||||
end
|
||||
|
||||
local profiles = Bridge.Database.Query([[
|
||||
SELECT `account_id` FROM `sky_phone_marketplace_profiles`
|
||||
WHERE `account_id` = ? LIMIT 1
|
||||
]], { account.id })
|
||||
if data.mode == "login" then
|
||||
if not profiles[1] then
|
||||
return { success = false, error = "profile_not_found" }
|
||||
end
|
||||
return { success = true, data = profile_dto(account) }
|
||||
end
|
||||
if profiles[1] then
|
||||
return { success = false, error = "profile_exists" }
|
||||
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, tostring(avatar_media_id), "photo")
|
||||
then
|
||||
Bridge.Debug("warn", "[sky_phone] Rejected unowned CityMarkt registration image from source %s.", tostring(source))
|
||||
return { success = false, error = "invalid_profile_image" }
|
||||
end
|
||||
|
||||
local result = Bridge.Database.Query([[
|
||||
INSERT IGNORE INTO `sky_phone_marketplace_profiles` (`account_id`, `display_name`, `bio`, `avatar_media_id`)
|
||||
VALUES (?, ?, '', NULLIF(?, 0))
|
||||
]], { account.id, default_display_name(account), avatar_media_id })
|
||||
if affected_rows(result) ~= 1 then
|
||||
return { success = false, error = "profile_exists" }
|
||||
end
|
||||
return { success = true, data = profile_dto(account) }
|
||||
end)
|
||||
|
||||
Bridge.Callbacks.Register("sky_phone:marketplace:profile-save", function(source, data)
|
||||
local account, error_response = require_account(source)
|
||||
if not account then return error_response end
|
||||
@@ -428,7 +488,8 @@ Bridge.Callbacks.Register("sky_phone:marketplace:get", function(source, data)
|
||||
end
|
||||
listing.images = load_images(id)
|
||||
listing.is_owner = account_id and tonumber(listing.seller_account_id) == account_id or false
|
||||
listing.phone_number = listing.show_phone == 1 and listing.phone_number or nil
|
||||
listing.show_phone = listing.show_phone == true or tonumber(listing.show_phone) == 1
|
||||
listing.phone_number = listing.show_phone and listing.phone_number or nil
|
||||
listing.reserved_account_id = listing.is_owner and listing.reserved_account_id or nil
|
||||
return { success = true, data = listing }
|
||||
end)
|
||||
@@ -585,7 +646,7 @@ Bridge.Callbacks.Register("sky_phone:marketplace:set-status", function(source, d
|
||||
if not inquiries[1] then return { success = false, error = "inquiry_not_found" } end
|
||||
reserved_account_id = inquiries[1].buyer_account_id
|
||||
elseif data.status == "active" then
|
||||
if current.status ~= "reserved" and current.status ~= "expired" then
|
||||
if current.status ~= "reserved" and current.status ~= "expired" and current.status ~= "sold" then
|
||||
return { success = false, error = "invalid_status" }
|
||||
end
|
||||
elseif data.status == "sold" then
|
||||
|
||||
Reference in New Issue
Block a user