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:
smx.pusha
2026-08-18 07:31:55 +02:00
parent 0f39d6ab8a
commit 77c1790f8b
11 changed files with 811 additions and 279 deletions
+133 -124
View File
@@ -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>
+24
View File
@@ -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)
})
})
+13
View File
@@ -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
+19 -8
View File
@@ -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 664 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 664 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.',
+295 -124
View File
@@ -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>
+65 -2
View File
@@ -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
}
+15 -7
View File
@@ -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 664 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 664 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.",
+13 -5
View File
@@ -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 664 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 664 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",
+1
View File
@@ -61,6 +61,7 @@ local server_callbacks = {
"mail:empty-trash",
"marketplace:list",
"marketplace:profile",
"marketplace:auth",
"marketplace:profile-save",
"marketplace:get",
"marketplace:list-own",
+63 -2
View File
@@ -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