Compare commits

...

9 Commits

Author SHA1 Message Date
Julien Calixte
0099e51f94 fix(transaction): initialize CurrencyInput with a real currency code
vue-currency-input required a currency code for Intl.NumberFormat;
passing undefined threw inside the mount watcher, leaving the input
without listeners so amount stayed null on submit.

Use CurrencyDisplay.hidden to keep the symbol out of the field since
the currency selector is rendered separately.
2026-06-02 21:24:39 +02:00
Julien Calixte
901f7f4c02 fix(sync): send credentials on cross-origin couchdb requests
PouchDB 8+ ignores the legacy ajax config, so withCredentials had no
effect and the AuthSession cookie was stripped from cross-origin sync
requests. Switch to a fetch wrapper that sets credentials: 'include'
so replication authenticates and pushes local docs to the remote.
2026-06-02 21:23:47 +02:00
Julien Calixte
7c3d34d02e refactor(forms): use arktype schemas for form validation
Replaces imperative validate() blocks in TransactionCreate, signup,
and AccountNew with schema-driven validation. Drops the dead
"(!currency && !date && payFor.length === 0)" branch in
TransactionCreate that was hiding behind the generic error.
2026-06-01 22:17:56 +02:00
Julien Calixte
0fc7118fd4 feat(validation): add arktype form schemas and i18n keys
Introduces field-specific validation messages so toasts name the
exact field and rule that failed instead of a catch-all "tous les
champs sont requis".
2026-06-01 22:17:50 +02:00
Julien Calixte
d61f391203 chore: add arktype dependency 2026-06-01 22:17:44 +02:00
Julien Calixte
360244dc08 refactor(exchange): switch rates API to frankfurter.dev
Drop HRK and RUB from the currency list since frankfurter.dev no longer
publishes them, and request only the rates we need via the symbols param.
2026-06-01 21:47:19 +02:00
Julien Calixte
39accae46e refactor(map): switch reverse geocoding from Bing to Mapbox
Reuse the existing Mapbox token instead of maintaining a separate Bing
Maps key. Note the coordinate order flips to lon,lat for the Mapbox
geocoding endpoint.
2026-06-01 21:47:09 +02:00
Julien Calixte
029fbae471 chore(deploy): add Dockerfile and nginx config for Coolify SPA hosting
Multi-stage build: node:22-alpine + pnpm builds, nginx:1.27-alpine
serves. VITE_* env vars are passed as build args because Vite inlines
them into the JS bundle at build time. nginx config does SPA fallback,
caches /assets/* immutably, and forces no-store on sw.js/index.html so
PWA updates propagate.
2026-06-01 21:41:21 +02:00
Julien Calixte
31ac3ba8ee migration 2026-06-01 21:38:24 +02:00
22 changed files with 315 additions and 122 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.gitignore
node_modules
dist
.DS_Store
.env
.env.*
!.env.example
.vscode
.idea
*.log
README.md
src-legacy
tests/examples

5
.env
View File

@@ -1,3 +1,2 @@
VUE_APP_COUCHDB=https://couch.li212.fr VITE_COUCHDB=https://couch.apoena.dev
VUE_APP_MAP_KEY=AnLDo_m_IGvMsQPLuBak9igWj3gNYvqBodj0esZZ7VfI1OkqVWvg04eTZ4U9R0Y2 VITE_MAPBOX_KEY=pk.eyJ1IjoiamNhbGl4dGUiLCJhIjoiY2s3cmo1aHhlMDZ3dDNtc2Z0OXl3M3c5dSJ9.rkkIAZ4lKSJZssoFvH7Fpw
VUE_APP_MAPBOX_KEY=pk.eyJ1IjoiamNhbGl4dGUiLCJhIjoiY2s3cmo1aHhlMDZ3dDNtc2Z0OXl3M3c5dSJ9.rkkIAZ4lKSJZssoFvH7Fpw

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
# syntax=docker/dockerfile:1.7
# -------- Build stage --------
FROM node:22-alpine AS builder
WORKDIR /app
# pnpm via corepack — pin the version that matches package.json
RUN corepack enable && corepack prepare pnpm@11.5.0 --activate
# Install deps first (cached as long as the lockfile is unchanged)
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
# Build-time env (Vite inlines VITE_* into the bundle)
ARG VITE_COUCHDB
ARG VITE_MAPBOX_KEY
ARG VITE_MAP_KEY
ENV VITE_COUCHDB=$VITE_COUCHDB \
VITE_MAPBOX_KEY=$VITE_MAPBOX_KEY \
VITE_MAP_KEY=$VITE_MAP_KEY
COPY . .
RUN pnpm build
# -------- Runtime stage --------
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget -qO- http://127.0.0.1/ >/dev/null || exit 1

View File

@@ -36,5 +36,4 @@ Create `.env` (gitignored) with:
``` ```
VITE_COUCHDB=https://your-couchdb-host VITE_COUCHDB=https://your-couchdb-host
VITE_MAPBOX_KEY=pk.your-mapbox-token VITE_MAPBOX_KEY=pk.your-mapbox-token
VITE_MAP_KEY=your-bing-maps-key
``` ```

59
nginx.conf Normal file
View File

@@ -0,0 +1,59 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Compression
gzip on;
gzip_comp_level 5;
gzip_min_length 256;
gzip_proxied any;
gzip_vary on;
gzip_types
application/javascript
application/json
application/manifest+json
application/wasm
application/xml
font/woff
font/woff2
image/svg+xml
text/css
text/plain;
# Vite emits hashed file names under /assets/ — cache hard
location /assets/ {
access_log off;
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# PWA control surfaces — never cache, so updates propagate
location = /sw.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location = /registerSW.js {
add_header Cache-Control "no-store";
try_files $uri =404;
}
location = /manifest.webmanifest {
add_header Cache-Control "no-store";
types { } default_type application/manifest+json;
try_files $uri =404;
}
# Workbox precache + sourcemap helpers
location ~* ^/workbox-.*\.js$ {
add_header Cache-Control "public, max-age=31536000, immutable";
try_files $uri =404;
}
# SPA fallback — every other path serves index.html (no cache)
location / {
add_header Cache-Control "no-store";
try_files $uri $uri/ /index.html;
}
}

View File

@@ -20,6 +20,7 @@
"dependencies": { "dependencies": {
"@tabler/icons-webfont": "^3.30.0", "@tabler/icons-webfont": "^3.30.0",
"@vueuse/core": "^12.0.0", "@vueuse/core": "^12.0.0",
"arktype": "^2.2.0",
"axios": "^1.7.7", "axios": "^1.7.7",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"events": "^3.3.0", "events": "^3.3.0",

31
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
'@vueuse/core': '@vueuse/core':
specifier: ^12.0.0 specifier: ^12.0.0
version: 12.8.2(typescript@5.9.3) version: 12.8.2(typescript@5.9.3)
arktype:
specifier: ^2.2.0
version: 2.2.0
axios: axios:
specifier: ^1.7.7 specifier: ^1.7.7
version: 1.16.1 version: 1.16.1
@@ -153,6 +156,12 @@ packages:
peerDependencies: peerDependencies:
ajv: '>=8' ajv: '>=8'
'@ark/schema@0.56.0':
resolution: {integrity: sha512-ECg3hox/6Z/nLajxXqNhgPtNdHWC9zNsDyskwO28WinoFEnWow4IsERNz9AnXRhTZJnYIlAJ4uGn3nlLk65vZA==}
'@ark/util@0.56.0':
resolution: {integrity: sha512-BghfRC8b9pNs3vBoDJhcta0/c1J1rsoS1+HgVUreMFPdhz/CRAKReAu57YEllNaSy98rWAdY1gE+gFup7OXpgA==}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'} engines: {node: '>=6.9.0'}
@@ -2001,6 +2010,12 @@ packages:
argsarray@0.0.1: argsarray@0.0.1:
resolution: {integrity: sha512-u96dg2GcAKtpTrBdDoFIM7PjcBA+6rSP0OR94MOReNRyUECL6MtQt5XXmRr4qrftYaef9+l5hcpO5te7sML1Cg==} resolution: {integrity: sha512-u96dg2GcAKtpTrBdDoFIM7PjcBA+6rSP0OR94MOReNRyUECL6MtQt5XXmRr4qrftYaef9+l5hcpO5te7sML1Cg==}
arkregex@0.0.5:
resolution: {integrity: sha512-ncYjBdLlh5/QnVsAA8De16Tc9EqmYM7y/WU9j+236KcyYNUXogpz3sC4ATIZYzzLxwI+0sEOaQLEmLmRleaEXw==}
arktype@2.2.0:
resolution: {integrity: sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ==}
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@@ -4718,6 +4733,12 @@ snapshots:
jsonpointer: 5.0.1 jsonpointer: 5.0.1
leven: 3.1.0 leven: 3.1.0
'@ark/schema@0.56.0':
dependencies:
'@ark/util': 0.56.0
'@ark/util@0.56.0': {}
'@babel/code-frame@7.29.7': '@babel/code-frame@7.29.7':
dependencies: dependencies:
'@babel/helper-validator-identifier': 7.29.7 '@babel/helper-validator-identifier': 7.29.7
@@ -6512,6 +6533,16 @@ snapshots:
argsarray@0.0.1: {} argsarray@0.0.1: {}
arkregex@0.0.5:
dependencies:
'@ark/util': 0.56.0
arktype@2.2.0:
dependencies:
'@ark/schema': 0.56.0
'@ark/util': 0.56.0
arkregex: 0.0.5
array-buffer-byte-length@1.0.2: array-buffer-byte-length@1.0.2:
dependencies: dependencies:
call-bound: 1.0.4 call-bound: 1.0.4

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { watch } from 'vue' import { watch } from 'vue'
import { useCurrencyInput } from 'vue-currency-input' import { useCurrencyInput, CurrencyDisplay } from 'vue-currency-input'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -23,7 +23,8 @@ const props = withDefaults(
defineEmits<{ 'update:modelValue': [value: number | null] }>() defineEmits<{ 'update:modelValue': [value: number | null] }>()
const { inputRef, setValue } = useCurrencyInput({ const { inputRef, setValue } = useCurrencyInput({
currency: undefined as unknown as string, currency: 'USD',
currencyDisplay: CurrencyDisplay.hidden,
locale: props.locale, locale: props.locale,
precision: props.precision, precision: props.precision,
hideCurrencySymbolOnFocus: false, hideCurrencySymbolOnFocus: false,

View File

@@ -14,11 +14,12 @@ import TransactionType from '@/enums/TransactionType'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag' import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
import exchangeService from '@/services/ExchangeService' import exchangeService from '@/services/ExchangeService'
import transactionService from '@/services/TransactionService' import transactionService from '@/services/TransactionService'
import queueNotifService from '@/services/QueueNotifService'
import mapService from '@/services/MapService' import mapService from '@/services/MapService'
import { money } from '@/utils/format' import { money } from '@/utils/format'
import { findContrastColor } from '@/utils' import { findContrastColor } from '@/utils'
import notif from '@/utils/notif' import notif from '@/utils/notif'
import { validateAndNotify } from '@/utils/validate'
import { transactionFormSchema, MAX_TRANSACTION_AMOUNT } from '@/schemas/transaction'
import FabButton from '@/components/FabButton.vue' import FabButton from '@/components/FabButton.vue'
import TransactionSplit from '@/components/TransactionSplit.vue' import TransactionSplit from '@/components/TransactionSplit.vue'
import TransactionTagUpdate from '@/components/TransactionTagUpdate.vue' import TransactionTagUpdate from '@/components/TransactionTagUpdate.vue'
@@ -46,7 +47,7 @@ const date = ref(today)
const currency = ref<ICurrency | null>(null) const currency = ref<ICurrency | null>(null)
const payBy = ref('') const payBy = ref('')
const payFor = reactive<ISplit[]>([]) const payFor = reactive<ISplit[]>([])
const maxAmount = 1_000_000 const maxAmount = MAX_TRANSACTION_AMOUNT
const displayLocationModal = ref(false) const displayLocationModal = ref(false)
const isSetLocation = ref(false) const isSetLocation = ref(false)
const location = ref<ILocation | null>(null) const location = ref<ILocation | null>(null)
@@ -120,23 +121,12 @@ const validLocation = async () => {
const validate = (): boolean => { const validate = (): boolean => {
const el = document.querySelector(':focus') as HTMLInputElement | null const el = document.querySelector(':focus') as HTMLInputElement | null
el?.blur() el?.blur()
if (!name.value || !amount.value || !payBy.value || (!currency.value && !date.value && payFor.length === 0)) { return validateAndNotify(
if (amount.value === 0) { transactionFormSchema,
queueNotifService.error('Le montant doit être supérieur à 0.') { name: name.value, amount: amount.value, payBy: payBy.value },
} else { t,
queueNotifService.error('Tous les champs sont requis.') { amount: { max: money(maxAmount, currency.value) } }
} )
return false
}
if (isNaN(amount.value) || amount.value > maxAmount) {
queueNotifService.error(`Veuillez saisir un montant numérique inférieur à ${money(maxAmount, currency.value)}.`)
return false
}
if (amount.value < 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
return false
}
return true
} }
const submitTransaction = async (finish: () => void) => { const submitTransaction = async (finish: () => void) => {

View File

@@ -15,8 +15,6 @@ export default [
{ name: 'Swiss franc', code: 'CHF' }, { name: 'Swiss franc', code: 'CHF' },
{ name: 'Icelandic krona', code: 'ISK', symbol: 'kr' }, { name: 'Icelandic krona', code: 'ISK', symbol: 'kr' },
{ name: 'Norwegian krone', code: 'NOK', symbol: 'øre' }, { name: 'Norwegian krone', code: 'NOK', symbol: 'øre' },
{ name: 'Croatian kuna', code: 'HRK', symbol: 'kn' },
{ name: 'Russian rouble', code: 'RUB' },
{ name: 'Turkish lira', code: 'TRY' }, { name: 'Turkish lira', code: 'TRY' },
{ name: 'Australian dollar', code: 'AUD', symbol: 'A$' }, { name: 'Australian dollar', code: 'AUD', symbol: 'A$' },
{ name: 'Brazilian real', code: 'BRL', symbol: 'R$' }, { name: 'Brazilian real', code: 'BRL', symbol: 'R$' },

View File

@@ -47,6 +47,20 @@ export default {
}, },
validation: { validation: {
max_char: max_char:
'no character available | 1 character max | {max} character maximum' 'no character available | 1 character max | {max} character maximum',
transaction_name_required: 'Transaction name is required.',
amount_required: 'Amount is required.',
amount_must_be_positive: 'Amount must be greater than 0.',
amount_too_large: 'Amount must be less than {max}.',
currency_required: 'Currency is required.',
date_required: 'Date is required.',
pay_by_required: 'Payer is required.',
pay_for_required: 'At least one share must be assigned.',
user_id_required: 'A username is required.',
passwords_mismatch: 'Passwords must match.',
account_name_required: 'Account name is required.',
account_needs_one_user: 'At least one person must be added to the account.',
aliases_must_be_unique: 'Aliases must be unique.',
user_ids_must_be_unique: 'Usernames must be unique.'
} }
} }

View File

@@ -64,6 +64,20 @@ export default {
pseudo: 'identifiant' pseudo: 'identifiant'
}, },
validation: { validation: {
max_char: 'limite atteinte | 1 caractère maximum | {max} caractères maximum' max_char: 'limite atteinte | 1 caractère maximum | {max} caractères maximum',
transaction_name_required: 'Le nom de la dépense est requis.',
amount_required: 'Le montant est requis.',
amount_must_be_positive: 'Le montant doit être supérieur à 0.',
amount_too_large: 'Le montant doit être inférieur à {max}.',
currency_required: 'La devise est requise.',
date_required: 'La date est requise.',
pay_by_required: 'Le payeur est requis.',
pay_for_required: 'Au moins une part doit être attribuée.',
user_id_required: 'Un pseudo est obligatoire.',
passwords_mismatch: 'Les mots de passe doivent être identiques.',
account_name_required: 'Le nom du compte est requis.',
account_needs_one_user: 'Au moins une personne doit être ajoutée au compte.',
aliases_must_be_unique: 'Les alias doivent être uniques.',
user_ids_must_be_unique: 'Les identifiants doivent être uniques.'
} }
} }

View File

@@ -1,44 +1,11 @@
export default interface ILocationQuery { export default interface ILocationQuery {
authenticationResultCode: string
brandLogoUri: string
copyright: string
resourceSets: [
{
estimatedTotal: number
resources: [
{
__type: string
bbox: number[]
name: string
point: {
type: string type: string
coordinates: number[] features: Array<{
} id: string
address: {
addressLine: string
adminDistrict: string
adminDistrict2: string
countryRegion: string
formattedAddress: string
locality: string
postalCode: string
}
confidence: string
entityType: string
geocodePoints: [
{
type: string type: string
coordinates: number[] place_type: string[]
calculationMethod: string text: string
usageTypes: string[] place_name: string
} center: number[]
] }>
matchCodes: string[]
}
]
}
]
statusCode: number
statusDescription: string
traceId: string
} }

20
src/schemas/account.ts Normal file
View File

@@ -0,0 +1,20 @@
import { type } from 'arktype'
export const accountFormSchema = type({
name: 'unknown',
aliases: 'unknown',
userIds: 'unknown'
}).narrow((data, ctx) => {
if (typeof data.name !== 'string' || data.name.trim().length === 0) {
ctx.reject({ message: 'validation.account_name_required', path: ['name'] })
}
if (!Array.isArray(data.aliases) || data.aliases.length === 0) {
ctx.reject({ message: 'validation.account_needs_one_user', path: ['aliases'] })
} else if (new Set(data.aliases).size !== data.aliases.length) {
ctx.reject({ message: 'validation.aliases_must_be_unique', path: ['aliases'] })
}
if (Array.isArray(data.userIds) && new Set(data.userIds).size !== data.userIds.length) {
ctx.reject({ message: 'validation.user_ids_must_be_unique', path: ['userIds'] })
}
return !ctx.hasError()
})

View File

@@ -0,0 +1,24 @@
import { type } from 'arktype'
export const MAX_TRANSACTION_AMOUNT = 1_000_000
export const transactionFormSchema = type({
name: 'unknown',
amount: 'unknown',
payBy: 'unknown'
}).narrow((data, ctx) => {
if (typeof data.name !== 'string' || data.name.trim().length === 0) {
ctx.reject({ message: 'validation.transaction_name_required', path: ['name'] })
}
if (typeof data.amount !== 'number' || Number.isNaN(data.amount)) {
ctx.reject({ message: 'validation.amount_required', path: ['amount'] })
} else if (data.amount <= 0) {
ctx.reject({ message: 'validation.amount_must_be_positive', path: ['amount'] })
} else if (data.amount > MAX_TRANSACTION_AMOUNT) {
ctx.reject({ message: 'validation.amount_too_large', path: ['amount'] })
}
if (typeof data.payBy !== 'string' || data.payBy.length === 0) {
ctx.reject({ message: 'validation.pay_by_required', path: ['payBy'] })
}
return !ctx.hasError()
})

15
src/schemas/user.ts Normal file
View File

@@ -0,0 +1,15 @@
import { type } from 'arktype'
export const signupFormSchema = type({
userId: 'unknown',
password: 'unknown',
confirmPassword: 'unknown'
}).narrow((data, ctx) => {
if (typeof data.userId !== 'string' || data.userId.trim().length === 0) {
ctx.reject({ message: 'validation.user_id_required', path: ['userId'] })
}
if (data.password !== data.confirmPassword) {
ctx.reject({ message: 'validation.passwords_mismatch', path: ['confirmPassword'] })
}
return !ctx.hasError()
})

View File

@@ -11,11 +11,11 @@ PouchDb.plugin(PouchDbAuthentication)
const emit = debounce((ev: typeof SYNC, arr?: string[]) => bus.emit(ev, arr), 150) const emit = debounce((ev: typeof SYNC, arr?: string[]) => bus.emit(ev, arr), 150)
const remoteConfig = { const remoteConfig: PouchDB.Configuration.RemoteDatabaseConfiguration = {
skip_setup: true, skip_setup: true,
ajax: { fetch: (url, opts) => {
cache: true, const init: RequestInit = { ...(opts ?? {}), credentials: 'include' }
withCredentials: true return PouchDb.fetch(url as RequestInfo, init)
} }
} }
const LOCALE_DB = 'VAQUANT_LOCALE_DB' const LOCALE_DB = 'VAQUANT_LOCALE_DB'

View File

@@ -3,7 +3,7 @@ import couchService from '@/services/CouchService'
import formatDate from '@/utils/format-date' import formatDate from '@/utils/format-date'
class ExchangeService { class ExchangeService {
private baseUrl: string = 'https://api.exchangeratesapi.io/' private baseUrl: string = 'https://api.frankfurter.dev/v1/'
/** /**
* Get an exchange with defined date and desired currencies * Get an exchange with defined date and desired currencies
@@ -42,12 +42,16 @@ class ExchangeService {
} }
} }
} else { } else {
const url = new URL( const url = new URL(`${this.baseUrl}${date ? formattedDate : 'latest'}`)
date ? `${this.baseUrl}${formattedDate}` : 'latest'
)
if (base) { if (base) {
url.searchParams.append('base', base) url.searchParams.append('base', base)
} }
if (rates.length) {
url.searchParams.append(
'symbols',
rates.filter((r) => r !== base).join(',')
)
}
const response = await fetch(url.toString()) const response = await fetch(url.toString())
if (response.ok) { if (response.ok) {
exchange = await response.json() exchange = await response.json()

View File

@@ -2,7 +2,7 @@ import ILocation from '@/models/ILocation'
import ILocationQuery from '@/models/ILocationQuery' import ILocationQuery from '@/models/ILocationQuery'
class MapService { class MapService {
private key: string = import.meta.env.VITE_MAP_KEY || '' private token: string = import.meta.env.VITE_MAPBOX_KEY || ''
public async getPosition(): Promise<ILocation | null> { public async getPosition(): Promise<ILocation | null> {
const position = await this.getCurrentPosition() const position = await this.getCurrentPosition()
@@ -28,20 +28,11 @@ class MapService {
return '' return ''
} }
const json: ILocationQuery = await result.json() const json: ILocationQuery = await result.json()
if ( return json?.features?.[0]?.text ?? ''
!json ||
!json.resourceSets ||
!json.resourceSets.length ||
!json.resourceSets[0].resources.length
) {
return ''
}
const address = json.resourceSets[0].resources[0].address
return address ? address.locality : ''
} }
private url(latitude: number, longitude: number) { private url(latitude: number, longitude: number) {
return `https://dev.virtualearth.net/REST/v1/Locations/${latitude},${longitude}?key=${this.key}` return `https://api.mapbox.com/geocoding/v5/mapbox.places/${longitude},${latitude}.json?types=place&limit=1&access_token=${this.token}`
} }
private getCurrentPosition(): Promise<GeolocationPosition | null> { private getCurrentPosition(): Promise<GeolocationPosition | null> {

29
src/utils/validate.ts Normal file
View File

@@ -0,0 +1,29 @@
import { ArkErrors } from 'arktype'
import type { Type } from 'arktype'
import type { Composer } from 'vue-i18n'
import queueNotifService from '@/services/QueueNotifService'
type TranslateFn = Composer['t']
type MessageParams = Record<string, unknown>
export type ParamsByPath = Record<string, MessageParams>
export function validateAndNotify(
schema: Type,
data: unknown,
t: TranslateFn,
paramsByPath: ParamsByPath = {}
): boolean {
const result = schema(data)
if (!(result instanceof ArkErrors)) return true
const seen = new Set<string>()
for (const error of result) {
const pathKey = error.path.join('.')
// Skip duplicate field-level errors at the same path so the user gets one toast per field.
if (seen.has(pathKey)) continue
seen.add(pathKey)
const params = paramsByPath[pathKey] ?? {}
queueNotifService.error(t(error.message, params))
}
return false
}

View File

@@ -7,6 +7,8 @@ import type IUser from '@/models/IUser'
import userService from '@/services/UserService' import userService from '@/services/UserService'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
import { slug } from '@/utils' import { slug } from '@/utils'
import { validateAndNotify } from '@/utils/validate'
import { signupFormSchema } from '@/schemas/user'
import LangChanger from '@/components/LangChanger.vue' import LangChanger from '@/components/LangChanger.vue'
import AccountList from '@/components/AccountList.vue' import AccountList from '@/components/AccountList.vue'
import ConfirmButton from '@/components/ConfirmButton.vue' import ConfirmButton from '@/components/ConfirmButton.vue'
@@ -51,17 +53,16 @@ const signin = async () => {
isLoading.value = false isLoading.value = false
} }
const validateRegistration = (): boolean => { const validateRegistration = (): boolean =>
if (!userId.value) { validateAndNotify(
queueNotifService.error('Un pseudo est obligatoire.') signupFormSchema,
return false {
} userId: userId.value,
if (password.value !== confirmPassword.value) { password: password.value,
queueNotifService.error('Les mots de passe doivent être identique.') confirmPassword: confirmPassword.value
return false },
} t
return true )
}
const register = async (confirmed: boolean) => { const register = async (confirmed: boolean) => {
if (!validateRegistration()) return if (!validateRegistration()) return

View File

@@ -11,6 +11,8 @@ import type IUser from '@/models/IUser'
import accountService from '@/services/AccountService' import accountService from '@/services/AccountService'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
import { slug } from '@/utils' import { slug } from '@/utils'
import { validateAndNotify } from '@/utils/validate'
import { accountFormSchema } from '@/schemas/account'
import ColorPicker from '@/components/ColorPicker.vue' import ColorPicker from '@/components/ColorPicker.vue'
import AccountUserNew from '@/components/AccountUserNew.vue' import AccountUserNew from '@/components/AccountUserNew.vue'
@@ -55,27 +57,13 @@ watch(
) )
const validate = (allUsers: IUser[]): boolean => { const validate = (allUsers: IUser[]): boolean => {
if (!name.value) {
queueNotifService.error('Le nom doit être rempli.')
return false
}
const aliases = allUsers.map((u) => (u.alias ? u.alias.toLowerCase() : '')) const aliases = allUsers.map((u) => (u.alias ? u.alias.toLowerCase() : ''))
if (aliases.length === 0) { const userIds = user.value ? allUsers.map((u) => u.userId) : undefined
queueNotifService.error('Au moins une personne doit être ajoutée au compte.') return validateAndNotify(
return false accountFormSchema,
} { name: name.value, aliases, userIds },
if (aliases.length !== new Set(aliases).size) { t
queueNotifService.error('Les alias doivent être uniques.') )
return false
}
if (user.value) {
const userIds = allUsers.map((u) => u.userId)
if (userIds.length !== new Set(userIds).size) {
queueNotifService.error('Les identifiants doivent être uniques.')
return false
}
}
return true
} }
const submitAccount = async () => { const submitAccount = async () => {