Compare commits

..

19 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
Julien Calixte
1ee1430e30 docs(readme): document pnpm + Vite workflow
Replaces the Vue CLI / npm commands with pnpm dev/build/typecheck/lint
and lists the VITE_* env vars the new code expects.
2026-06-01 21:03:41 +02:00
Julien Calixte
d4dab8c03f feat!: rewrite frontend in Vue 3 with Pinia, DaisyUI, vite-plugin-pwa
Migrates every component from class-based vue-property-decorator to
<script setup> + Composition API. Replaces Vuex 3 + vuex-class with a
single Pinia store (persisted via pinia-plugin-persistedstate). Swaps
Bulma + bulma-{checkradio,switch,pricingtable} for DaisyUI 5 utilities
on Tailwind 4. Replaces register-service-worker with vite-plugin-pwa
(workbox, skipWaiting/clientsClaim preserved).

Plugins replaced:
- vue-class-component / vue-property-decorator -> <script setup>
- vuex / vuex-class / vuex-persist -> pinia + persistedstate
- vue-i18n 8 -> vue-i18n 11 (composition mode, legacy: false)
- vue-click-outside -> @vueuse/core onClickOutside
- @xkeshi/vue-qrcode -> qrcode.vue
- vue-currency-input 1 -> vue-currency-input 3 (composable wrapper)
- bus-event (Vue instance) -> mitt
- Vue filters -> plain functions imported per component

BREAKING: drops Stripe / pricing entirely (Payment, PricingTable,
/pricing route, vue-stripe-checkout, bulma-pricingtable).

Clears unused Cypress and Jest test scaffolding; leaves a Vitest
harness behind for future tests.
2026-06-01 21:03:34 +02:00
Julien Calixte
f6a518a43d chore(build): adopt Vite, pnpm, ESLint flat config; drop Vue CLI
Replaces Vue CLI 4 with Vite 7 + @tailwindcss/vite + vite-plugin-pwa,
yarn with pnpm, tslint with ESLint 9 flat config, and Jest with Vitest.
Drops Netlify deploy config and the dead FontAwesome npm token.

Bumps the entire dependency surface: Vue 3.5, Pinia 2, vue-router 4,
vue-i18n 11, Tailwind 4, DaisyUI 5, TypeScript 5.7, axios 1.x,
mapbox-gl 3.x, date-fns 4.x, uuid 11.x, pouchdb-browser 9.x.
2026-06-01 21:03:09 +02:00
Julien Calixte
37019b655a chore(deps): migrate from node-sass to dart-sass
node-sass is deprecated and its native bindings fail to build on
recent Node versions. Replace with the maintained `sass` package and
bump sass-loader to v10 (last major supporting webpack 4).
2026-06-01 18:42:05 +02:00
Julien Calixte
639429e18b refactor(icons): replace FontAwesome with Tabler Icons
Drop the three @fortawesome/* packages (v5, with a stagnant Vue 2
binding) in favor of @tabler/icons-webfont. The old <awe-icon>
wrapper is replaced by <vaquant-icon>, named after the project so
future icon-library swaps don't require another rename.
2026-06-01 18:33:58 +02:00
Julien Calixte
5fe9905df7 Merge pull request #8 from jcalixte/dependabot/npm_and_yarn/ini-1.3.8
⬆️ Bump ini from 1.3.5 to 1.3.8
2020-12-13 20:46:10 +01:00
dependabot[bot]
48a6ab9012 ⬆️ Bump ini from 1.3.5 to 1.3.8
Bumps [ini](https://github.com/isaacs/ini) from 1.3.5 to 1.3.8.
- [Release notes](https://github.com/isaacs/ini/releases)
- [Commits](https://github.com/isaacs/ini/compare/v1.3.5...v1.3.8)

Signed-off-by: dependabot[bot] <support@github.com>
2020-12-12 13:40:27 +00:00
d764a817da 🐛 (transaction) set default locale 2020-08-29 14:54:32 +02:00
12e33cf1de 🔊 (transaction) display variables for debug transaction creation 2020-08-29 14:29:35 +02:00
a041b0fa48 🐛 (transaction) display pay for 2020-08-29 12:18:45 +02:00
129 changed files with 13493 additions and 19845 deletions

View File

@@ -1,3 +0,0 @@
> 1%
last 2 versions
not ie <= 8

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

4
.gitignore vendored
View File

@@ -2,7 +2,9 @@
node_modules node_modules
node_modules_bak node_modules_bak
/dist /dist
/cypress/videos /dev/dist
src-legacy
.eslintcache
# local env files # local env files
.env.local .env.local

1
.node-version Normal file
View File

@@ -0,0 +1 @@
24

2
.npmrc
View File

@@ -1,2 +0,0 @@
@fortawesome:registry=https://npm.fontawesome.com/
//npm.fontawesome.com/:_authToken=2C545253-5017-402B-A924-6E3689616391

View File

@@ -1,5 +0,0 @@
module.exports = {
plugins: {
autoprefixer: {}
}
}

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

@@ -1,26 +1,39 @@
# Vaquant # Vaquant
## Project setup Manage group budgets — Vue 3 + Vite + Pinia + Tailwind/DaisyUI + CouchDB (PouchDB).
```
npm install ## Requirements
- Node 22+ (see `.node-version`)
- pnpm 10+ (`corepack enable && corepack prepare pnpm@latest --activate`)
## Setup
```sh
pnpm install
``` ```
### Compiles and hot-reloads for development ## Develop
```
npm run serve ```sh
pnpm dev # Vite dev server on http://localhost:8080
pnpm typecheck # vue-tsc --noEmit
pnpm lint # ESLint
pnpm test # Vitest
``` ```
### Compiles and minifies for production ## Build
```
npm run build ```sh
pnpm build # Type-check, then build to dist/
pnpm preview # Serve the production build locally
``` ```
### Lints and fixes files ## Environment
```
npm run lint Create `.env` (gitignored) with:
```
### Run your unit tests
``` ```
npm run test:unit VITE_COUCHDB=https://your-couchdb-host
VITE_MAPBOX_KEY=pk.your-mapbox-token
``` ```

View File

@@ -1,5 +0,0 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset'
]
}

29
eslint.config.js Normal file
View File

@@ -0,0 +1,29 @@
import pluginVue from 'eslint-plugin-vue'
import vueTsConfig from '@vue/eslint-config-typescript'
export default [
{
ignores: [
'dist/**',
'node_modules/**',
'src-legacy/**',
'dev/**',
'src/utils/smtp.js'
]
},
...pluginVue.configs['flat/recommended'],
...vueTsConfig(),
{
rules: {
'vue/multi-word-component-names': 'off',
'vue/max-attributes-per-line': 'off',
'vue/singleline-html-element-content-newline': 'off',
'vue/html-self-closing': 'off',
'vue/attributes-order': 'off',
'vue/first-attribute-linebreak': 'off',
'vue/no-mutating-props': 'warn',
'@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }]
}
}
]

36
index.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/favicon.ico" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta
name="viewport"
content="width=device-width,initial-scale=1.0, maximum-scale=5.0"
/>
<meta
name="description"
content="application pour réaliser des balances équitables de vos budgets entre amis lors de vos colocations, vacances, séjours, voyages, etc"
/>
<meta
name="keywords"
content="budget en vacances, budget aventure, collocation, budgets entre amis, budgets partagés"
/>
<meta
name="twitter:card"
content="Vaquant | application pour réaliser des balances équitables de vos budgets entre amis"
/>
<link rel="canonical" href="https://vaquant.space" />
<title>Vaquant</title>
</head>
<body>
<noscript>
<strong
>We're sorry but Vaquant doesn't work properly without JavaScript
enabled. Please enable it to continue.</strong
>
</noscript>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -1,25 +0,0 @@
module.exports = {
moduleFileExtensions: [
'js',
'jsx',
'json',
'vue',
'ts',
'tsx'
],
transform: {
'^.+\\.vue$': 'vue-jest',
'.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub',
'^.+\\.tsx?$': 'ts-jest'
},
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1'
},
snapshotSerializers: [
'jest-serializer-vue'
],
testMatch: [
'**/tests/unit/**/*.spec.(js|jsx|ts|tsx)|**/__tests__/*.(js|jsx|ts|tsx)'
],
testURL: 'http://localhost/'
}

View File

@@ -1,4 +0,0 @@
[[redirects]]
from = "/*"
to = "/"
status = 200

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

@@ -1,69 +1,70 @@
{ {
"name": "vaquant", "name": "vaquant",
"version": "0.8.0", "version": "0.9.0",
"private": true, "private": true,
"type": "module",
"packageManager": "pnpm@11.5.0",
"engines": {
"node": ">=22"
},
"scripts": { "scripts": {
"serve": "vue-cli-service serve", "dev": "vite",
"build": "vue-cli-service build", "build": "vue-tsc --noEmit && vite build",
"test:unit": "vue-cli-service test:unit", "preview": "vite preview",
"lint": "vue-cli-service lint", "lint": "eslint . --cache",
"build-couch-design": "ts-node src/scripts/validate-doc-update.ts" "typecheck": "vue-tsc --noEmit",
"test": "vitest",
"test:run": "vitest run",
"build-couch-design": "tsx src/scripts/validate-doc-update.ts"
}, },
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.28", "@tabler/icons-webfont": "^3.30.0",
"@fortawesome/free-solid-svg-icons": "^5.13.0", "@vueuse/core": "^12.0.0",
"@fortawesome/vue-fontawesome": "^0.1.9", "arktype": "^2.2.0",
"@xkeshi/vue-qrcode": "^1.0.0", "axios": "^1.7.7",
"axios": "^0.19.2", "date-fns": "^4.1.0",
"bulma": "^0.8.1", "events": "^3.3.0",
"bulma-checkradio": "^1.1.1",
"bulma-pricingtable": "^0.2.0",
"bulma-switch": "^2.0.0",
"core-js": "^3.6.5",
"date-fns": "^2.16.0",
"font-color-contrast": "^1.0.3", "font-color-contrast": "^1.0.3",
"lightness": "^1.0.0", "lightness": "^1.0.0",
"lodash-es": "^4.17.15", "lodash-es": "^4.17.21",
"mapbox-gl": "^1.9.1", "mapbox-gl": "^3.7.0",
"md5": "^2.2.1", "md5": "^2.3.0",
"notyf": "^3.6.0", "mitt": "^3.0.1",
"notyf": "^3.10.0",
"pinia": "^2.2.6",
"pinia-plugin-persistedstate": "^4.1.3",
"pouchdb-authentication": "^1.1.3", "pouchdb-authentication": "^1.1.3",
"pouchdb-browser": "^7.2.1", "pouchdb-browser": "^9.0.0",
"register-service-worker": "^1.7.1", "qrcode.vue": "^3.5.1",
"uuid": "^7.0.3", "uuid": "^11.0.3",
"vue": "^2.6.11", "vue": "^3.5.13",
"vue-class-component": "^7.2.3", "vue-currency-input": "^3.2.0",
"vue-click-outside": "^1.0.7", "vue-i18n": "^11.0.0",
"vue-currency-input": "^1.18.0", "vue-router": "^4.5.0"
"vue-i18n": "^8.16.0",
"vue-property-decorator": "^8.4.1",
"vue-router": "^3.1.6",
"vue-stripe-checkout": "^3.2.2",
"vuex": "^3.1.3",
"vuex-class": "^0.3.2",
"vuex-persist": "^2.2.0"
}, },
"devDependencies": { "devDependencies": {
"@types/chartist": "^0.9.48", "@tailwindcss/vite": "^4.0.0",
"@types/jest": "^25.2.1", "@types/lodash-es": "^4.17.12",
"@types/lodash-es": "^4.17.3", "@types/mapbox-gl": "^3.4.0",
"@types/mapbox-gl": "^1.8.3", "@types/md5": "^2.3.5",
"@types/md5": "^2.1.33", "@types/pouchdb": "^6.4.2",
"@types/notyf": "^3.0.0", "@vitejs/plugin-vue": "^5.2.1",
"@types/pouchdb": "^6.4.0", "@vue/eslint-config-typescript": "^14.1.4",
"@types/uuid": "^7.0.2", "@vue/test-utils": "^2.4.6",
"@vue/cli-plugin-babel": "^4.3.1", "@vue/tsconfig": "^0.7.0",
"@vue/cli-plugin-pwa": "^4.3.1", "daisyui": "^5.0.0",
"@vue/cli-plugin-typescript": "^4.3.1", "eslint": "^9.16.0",
"@vue/cli-plugin-unit-jest": "^4.3.1", "eslint-plugin-vue": "^9.32.0",
"@vue/cli-service": "^4.3.1", "happy-dom": "^15.11.7",
"@vue/test-utils": "^1.0.0-beta.33", "prettier": "^3.4.2",
"babel-core": "7.0.0-bridge.0", "sass": "^1.83.0",
"node-sass": "^4.13.1", "tailwindcss": "^4.0.0",
"sass-loader": "^8.0.2", "tsx": "^4.19.2",
"ts-jest": "^25.3.1", "typescript": "^5.7.2",
"ts-node": "^8.8.2", "vite": "^7.0.0",
"typescript": "^3.8.3", "vite-plugin-pwa": "^0.21.1",
"vue-template-compiler": "^2.6.11" "vitest": "^2.1.8",
"vue-tsc": "^2.1.10",
"workbox-window": "^7.3.0"
} }
} }

9646
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

5
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,5 @@
allowBuilds:
'@parcel/watcher': true
esbuild: true
ttf2woff2: true
vue-demi: true

View File

@@ -1,167 +1,123 @@
<script setup lang="ts">
import { ref, computed, onMounted, useTemplateRef } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { throttle } from 'lodash-es'
import { onClickOutside } from '@vueuse/core'
import { useUserStore } from '@/stores/user'
import { COFFEE_LINK } from '@/utils/constants'
import OnlineView from '@/components/OnlineView.vue'
import QueueNotif from '@/components/QueueNotif.vue'
import logoUrl from '@/assets/vaquant-root-white.png'
import cloudOffIcon from '@/assets/icons/baseline_cloud_off_white_48dp.png'
const userStore = useUserStore()
const { user, title } = storeToRefs(userStore)
const { t } = useI18n()
const username = computed(() => userStore.username)
const menuOpen = ref(false)
const shadow = ref(false)
const coffee = COFFEE_LINK
const navRef = useTemplateRef<HTMLElement>('navRef')
onClickOutside(navRef, () => {
menuOpen.value = false
})
onMounted(() => {
const main = document.getElementById('main')
if (main) {
shadow.value = main.getBoundingClientRect().top < 0
document.addEventListener(
'scroll',
throttle(() => {
shadow.value = main.getBoundingClientRect().top < 0
}, 150)
)
}
})
const toggleMenu = () => {
menuOpen.value = !menuOpen.value
}
const hideMenu = () => {
menuOpen.value = false
}
</script>
<template> <template>
<div id="app" class="app"> <div id="app" class="app">
<nav <nav
class="nav navbar is-fixed-top is-primary" ref="navRef"
role="navigation" class="navbar bg-primary text-primary-content fixed top-0 left-0 right-0 z-40 px-4"
aria-label="main navigation"
:class="{ shadow }" :class="{ shadow }"
v-click-outside="hideMenu" aria-label="main navigation"
> >
<div class="navbar-brand"> <div class="flex-1 flex items-center gap-2">
<router-link class="navbar-item" to="/"> <router-link to="/" class="flex items-center gap-2">
<img src="./assets/vaquant-root-white.png" title="vaquant" /> <img :src="logoUrl" alt="vaquant" class="h-8" />
</router-link> </router-link>
<online-view class="online-view"> <OnlineView>
<div slot="offline"> <template #offline>
<img <img :src="cloudOffIcon" class="h-5 w-auto" alt="offline" />
class="icon" </template>
src="./assets/icons/baseline_cloud_off_white_48dp.png" </OnlineView>
alt="offline" <QueueNotif />
/> <span class="vaquant-title ml-2" :class="{ 'opacity-100': shadow, 'opacity-0': !shadow }">
{{ title }}
</span>
</div> </div>
</online-view> <div class="flex-none md:hidden">
<queue-notif class="nav-button navbar-item" /> <button class="btn btn-ghost btn-square" @click="toggleMenu">
<span class="vaquant-title" :class="{ visible: shadow }">{{ <vaquant-icon icon="menu-2" />
title </button>
}}</span>
<div
class="navbar-burger burger"
data-target="navbar-main"
@click="toggleMenu"
:class="{ 'is-active': menuOpen }"
>
<span></span>
<span></span>
<span></span>
</div> </div>
</div>
<div <div
id="navbar-main" id="navbar-main"
class="navbar-menu" class="hidden md:flex flex-none gap-1"
:class="{ 'is-active': menuOpen }" :class="{ '!flex flex-col absolute top-full right-0 bg-primary p-2': menuOpen }"
@click="hideMenu" @click="hideMenu"
> >
<div class="navbar-end"> <router-link class="btn btn-ghost btn-sm" :to="{ name: 'about' }">
<router-link {{ t('title.about') }}
class="nav-button navbar-item" </router-link>
:to="{ name: 'about' }"
v-t="'title.about'"
></router-link>
<!-- <router-link class="navbar-item" :to="{ name: 'pricing' }" v-t="'title.pricing'"></router-link> -->
<a <a
class="nav-button navbar-item" class="btn btn-ghost btn-sm"
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
:href="coffee" :href="coffee"
>Supportez Vaquant avec un !</a
> >
<router-link class="nav-button navbar-item" :to="{ name: 'user' }">{{ Supportez Vaquant avec un !
username ? username : $t('user.loginsignup') </a>
}}</router-link> <router-link class="btn btn-ghost btn-sm" :to="{ name: 'user' }">
</div> {{ username || t('user.loginsignup') }}
</router-link>
</div> </div>
</nav> </nav>
<main id="main"> <main id="main" class="pt-16 min-h-screen">
<router-view v-slot="{ Component }">
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<router-view /> <component :is="Component" />
</transition> </transition>
</router-view>
</main> </main>
</div> </div>
</template> </template>
<script lang="ts"> <style>
import { Component, Vue } from 'vue-property-decorator'
import { throttle } from 'lodash-es'
import ClickOutside from 'vue-click-outside'
import { Action, Getter } from 'vuex-class'
import IUser from '@/models/IUser'
import { COFFEE_LINK } from '@/utils/constants'
@Component({
directives: {
ClickOutside
},
components: {
'online-view': () => import('@/components/OnlineView.vue'),
'queue-notif': () => import('@/components/QueueNotif.vue')
}
})
export default class App extends Vue {
@Getter
public user!: IUser | null
@Getter
public username!: string
@Getter
public title!: string | null
public menuOpen: boolean = false
public transitionName: string = ''
public shadow: boolean = false
public coffee: string = COFFEE_LINK
public mounted(): void {
const main = document.getElementById('main')
if (main) {
this.shadow = main.getBoundingClientRect().top < 0
document.addEventListener(
'scroll',
throttle((evt) => {
this.shadow = main.getBoundingClientRect().top < 0
}, 150)
)
}
}
public toggleMenu(): void {
this.menuOpen = !this.menuOpen
}
public hideMenu(): void {
this.menuOpen = false
}
public back(): void {
window.history.back()
}
}
</script>
<style lang="scss">
$icon_size: 18px;
.navbar {
color: red;
.vaquant-title { .vaquant-title {
opacity: 0; line-height: 2rem;
line-height: 52px;
transition: opacity 0.25s ease-out; transition: opacity 0.25s ease-out;
&.visible {
opacity: 1;
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-out;
} }
.online-view { .fade-enter-from,
position: absolute; .fade-leave-to {
bottom: 7px;
left: calc(#{$icon_size} * 2);
width: $icon_size;
height: auto;
}
&::before {
content: '';
position: absolute;
right: 0;
bottom: -5px;
left: 0;
width: 100%;
height: 5px;
transition: opacity 0.25s ease-out;
pointer-events: none;
opacity: 0; opacity: 0;
box-shadow: inset 0 5px 6px -3px rgba(0, 0, 0, 0.842);
will-change: opacity;
}
&.shadow::before {
opacity: 1;
}
} }
</style> </style>

View File

@@ -1,49 +0,0 @@
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import bus, { SYNC } from '@/utils/bus-event'
import IAccount from '@/models/IAccount'
import IUser from '@/models/IUser'
import { findContrastColor } from '@/utils'
@Component
export default class BaseAccount extends Vue {
@Getter public user!: IUser | null
@Prop({ type: String, required: true })
public id!: string
public account: IAccount | null = null
public removing: boolean = false
public async mounted(): Promise<void> {
bus.$on(SYNC, this.getData)
await this.getData()
const anchor = this.$router.currentRoute.hash
if (anchor && document.querySelector(anchor)) {
this.$nextTick(() => {
location.href = anchor
// add scroll to manage fixed header
window.scrollBy(0, -60)
})
}
}
public colorStyle(display: boolean = true) {
if (!this.account || !display) {
return {}
}
return {
backgroundColor: this.account.color,
borderColor: this.account.color,
color: findContrastColor(this.account.color)
}
}
public beforeDestroy(): void {
bus.$off(SYNC, this.getData)
}
public async getData(docIds?: string[]): Promise<void> {
throw new Error(
`abstract method needs to be overrided ${docIds && docIds.join(', ')}`
)
}
}

View File

@@ -1,112 +1,60 @@
<template> <script setup lang="ts">
<div import { ref, computed, onMounted } from 'vue'
class="account-card box" import { useRouter } from 'vue-router'
:style="getColor(account.color)" import type IAccount from '@/models/IAccount'
@click="goToAccount(account._id || '')" import type ITransaction from '@/models/ITransaction'
>
<a
class="account-name"
:class="{ 'is-primary': !account.color, 'is-white': account.color }"
:style="`color: ${findDarkValue(account.color || '')}`"
href="#"
>{{ account.name }}</a
>
<span v-if="account.isPublic">
&nbsp;
<awe-icon icon="users" />
</span>
<div class="numeric">
{{ totalCost | moneypad(account.mainCurrency, false) }}
</div>
<div class="account-users">
{{ account.users && account.users.map((u) => u.alias).join(', ') }}
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { findDarkValue, findLightValue } from '@/utils' import { findDarkValue, findLightValue } from '@/utils'
import bus, { SYNC } from '@/utils/bus-event' import { moneypad } from '@/utils/format'
import accountService from '@/services/AccountService' import transactionService from '@/services/TransactionService'
import IAccount from '@/models/IAccount'
import IColor from '@/models/IColor'
import FabButton from '@/components/FabButton.vue'
import ITransaction from '../models/ITransaction'
import transactionService from '../services/TransactionService'
import TransactionType from '../enums/TransactionType'
import colors from '../data/colors'
const GRADIENT = false const props = defineProps<{ account: IAccount }>()
@Component const router = useRouter()
export default class AccountCard extends Vue { const transactions = ref<ITransaction[]>([])
@Prop({ type: Object, required: true })
public account!: IAccount
public transactions: ITransaction[] = []
public async mounted() { onMounted(async () => {
this.transactions = await transactionService.getAllByAccountId( transactions.value = await transactionService.getAllByAccountId(props.account._id)
this.account._id })
const totalCost = computed(() =>
transactionService.getTotalCost(transactions.value, props.account.mainCurrency.code)
) )
const goToAccount = (id: string) => {
router.push({ name: 'account', params: { id } })
} }
public findDarkValue(color: string): string | null { const getColor = (color: string | null) => {
return findDarkValue(color) if (!color) return null
}
public goToAccount(id: string): void {
this.$router.push({ name: 'account', params: { id } })
}
public getColor(color: string | null): any | null {
if (!color) {
return null
}
return { return {
background: GRADIENT background: color,
? `linear-gradient(135deg, ${color}, ${findLightValue(color)})` color: findDarkValue(color) ?? undefined
: color,
color: findDarkValue(color)
}
}
private get totalCost(): number {
return transactionService.getTotalCost(
this.transactions,
this.account.mainCurrency.code
)
} }
} }
</script> </script>
<style lang="scss" scoped> <template>
@import '../styles/variables'; <div
class="account-card card bg-base-100 shadow p-4 cursor-pointer hover:shadow-lg transition-shadow"
.account-name { :style="getColor(account.color ?? null) ?? undefined"
font-weight: bold; @click="goToAccount(account._id || '')"
} >
<a
.box, href="#"
.box:not(:last-child) { class="account-name font-bold text-lg"
display: flex; :style="`color: ${findDarkValue(account.color ?? '') ?? ''}`"
flex-direction: column; @click.prevent
justify-content: center; >
border-radius: 8px; {{ account.name }}
word-wrap: break-word; </a>
hyphens: auto; <span v-if="account.isPublic">
margin-bottom: 0; &nbsp;<vaquant-icon icon="users" />
</span>
a { <div class="numeric mt-1">
font-size: 1.2rem; {{ moneypad(totalCost, account.mainCurrency, false) }}
color: $main; </div>
} <div class="account-users text-sm opacity-80 mt-1 truncate">
&:hover { {{ account.users && account.users.map((u) => u.alias).join(', ') }}
cursor: pointer; </div>
} </div>
.account-users { </template>
text-overflow: ellipsis;
}
}
</style>

View File

@@ -1,76 +1,46 @@
<template> <script setup lang="ts">
<div class="account-list"> import { ref, onMounted, onBeforeUnmount } from 'vue'
<div class="account-grid" v-if="accounts.length"> import { storeToRefs } from 'pinia'
<account-card import { useUserStore } from '@/stores/user'
v-for="(account, k) in accounts"
:key="k"
:account="account"
/>
</div>
<div v-else-if="fetched">
<awe-icon icon="inbox" />&nbsp;Les comptes créés s'afficheront ici.
</div>
<fab-button :to="{ name: 'account-new' }" :margin="true">
<span slot="fulltext">créer un compte</span>
</fab-button>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import { findContrastColor } from '@/utils'
import bus, { SYNC } from '@/utils/bus-event' import bus, { SYNC } from '@/utils/bus-event'
import type IAccount from '@/models/IAccount'
import accountService from '@/services/AccountService' import accountService from '@/services/AccountService'
import IAccount from '@/models/IAccount'
import IColor from '@/models/IColor'
import IUser from '@/models/IUser'
import FabButton from '@/components/FabButton.vue' import FabButton from '@/components/FabButton.vue'
import AccountCard from '@/components/AccountCard.vue' import AccountCard from '@/components/AccountCard.vue'
@Component({ const props = withDefaults(defineProps<{ archived?: boolean }>(), { archived: false })
components: {
'fab-button': FabButton, const userStore = useUserStore()
'account-card': AccountCard const { user: _user } = storeToRefs(userStore)
const accounts = ref<IAccount[]>([])
const fetched = ref(false)
const getData = async () => {
accounts.value = await accountService.getAll(props.archived)
fetched.value = true
} }
onMounted(() => {
getData()
bus.on(SYNC, getData)
}) })
export default class AccountList extends Vue {
@Getter
public user!: IUser | null
@Prop({ type: Boolean, default: false })
public archived!: boolean
public accounts: IAccount[] = []
public fetched: boolean = false
public textColor: string = ''
public async created(): Promise<void> {
this.getData()
bus.$on(SYNC, this.getData)
}
public beforeDestroy() { onBeforeUnmount(() => {
bus.$off(SYNC, this.getData) bus.off(SYNC, getData)
} })
public async getData(): Promise<void> {
this.accounts = await accountService.getAll(this.archived)
this.fetched = true
}
}
</script> </script>
<style lang="scss" scoped> <template>
.account-grid { <div class="account-list">
display: grid; <div v-if="accounts.length" class="grid gap-4 mx-4" style="grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));">
gap: 1rem; <AccountCard v-for="(account, k) in accounts" :key="k" :account="account" />
margin: 0 1rem; </div>
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr)); <div v-else-if="fetched" class="p-4 text-center opacity-70">
justify-content: center; <vaquant-icon icon="inbox" />&nbsp;Les comptes créés s'afficheront ici.
} </div>
<FabButton :to="{ name: 'account-new' }" :margin="true">
li { <template #fulltext>créer un compte</template>
margin-bottom: 10px; </FabButton>
&:last-child { </div>
margin-bottom: 0; </template>
}
}
</style>

View File

@@ -1,163 +1,71 @@
<template> <script setup lang="ts">
<div class="account-setting no-margin"> import { ref, computed } from 'vue'
<div class="columns is-centered"> import { useI18n } from 'vue-i18n'
<div class="column is-one-third"> import { useRouter } from 'vue-router'
<color-picker v-model="account.color" @color-change="save" /> import { storeToRefs } from 'pinia'
</div> import { useUserStore } from '@/stores/user'
</div> import type IAccount from '@/models/IAccount'
<hr /> import type IUser from '@/models/IUser'
<div>
<h3 class="subtitle is-3" v-t="'account.users'"></h3>
<div class="columns is-multiline is-centered">
<div class="column">
{{ account.users.map((u) => getUserInfo(u)).join(', ') }}
</div>
</div>
<h3 class="subtitle is-3">Ajouter des amis</h3>
<div class="columns is-centered">
<div class="column is-one-third">
<account-user-new
v-model="newUsers"
:default-user="false"
:show-title="false"
/>
</div>
</div>
<div class="columns is-centered" v-if="newUsers.length">
<div class="column is-one-third">
<button
@click="saveUsers"
class="button is-primary is-fullwidth is-large"
>
<awe-icon icon="check" />
</button>
</div>
</div>
</div>
<hr />
<div class="columns is-centered">
<div class="column is-one-third" v-if="isMultiUser">
<account-share :account="account" :can-close="false" />
</div>
<div class="column is-one-third">
<article class="message is-primary is-medium">
<div class="message-header">
<p>Actions</p>
</div>
<div class="buttons action-buttons is-centered">
<confirm-button
v-if="account.archive"
class="is-warning"
@confirm="active"
>
<span v-t="'account.open'"></span>
</confirm-button>
<confirm-button v-else class="is-warning" @confirm="archive">
<span v-t="'account.close'"></span>
</confirm-button>
<confirm-button class="is-danger" @confirm="remove">
<span v-t="'account.delete'"></span>
</confirm-button>
</div>
</article>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import BaseAccount from '@/base-components/BaseAccount'
import { confirmation } from '@/utils' import { confirmation } from '@/utils'
import bus, { SYNC } from '@/utils/bus-event'
import IAccount from '@/models/IAccount'
import 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 ConfirmButton from '@/components/ConfirmButton.vue'
import ColorPicker from '@/components/ColorPicker.vue'
import AccountShare from '@/components/AccountShare.vue'
import AccountUserNew from '@/components/AccountUserNew.vue'
@Component({ const props = defineProps<{ account: IAccount }>()
components: {
'confirm-button': () => import('@/components/ConfirmButton.vue'), const userStore = useUserStore()
'color-picker': () => import('@/components/ColorPicker.vue'), const { user } = storeToRefs(userStore)
'account-share': () => import('@/components/AccountShare.vue'), const { t } = useI18n()
'account-user-new': () => import('@/components/AccountUserNew.vue') const router = useRouter()
const newUsers = ref<IUser[]>([])
const isMultiUser = computed(() => props.account.users.length > 1)
const color = computed({
get: () => props.account.color ?? null,
set: (val) => {
props.account.color = val
} }
}) })
export default class AccountSetting extends Vue {
@Getter
public user!: IUser | null
@Prop({ type: Object, required: true })
public account!: IAccount
public removing: boolean = false
public newUsers: IUser[] = []
public async active(): Promise<void> { const save = async () => {
const ok: boolean = await accountService.active(this.account._id || '') if (!props.account || !props.account._id) return
if (ok) { const ok = await accountService.update(props.account._id, props.account)
this.$router.push({ name: 'home' }) if (!ok) {
confirmation(this.$t('account.closed').toString()) queueNotifService.error(`Une erreur s'est produite.`)
} }
} }
public async archive(): Promise<void> { const active = async () => {
const ok: boolean = await accountService.archive(this.account._id || '') const ok = await accountService.active(props.account._id || '')
if (ok) { if (ok) {
this.$router.push({ name: 'home' }) router.push({ name: 'home' })
confirmation(this.$t('account.closed').toString()) confirmation(t('account.closed'))
} }
} }
public async remove(): Promise<void> { const archive = async () => {
const ok: boolean = await accountService.remove(this.account._id || '') const ok = await accountService.archive(props.account._id || '')
if (ok) { if (ok) {
this.$router.push({ name: 'home' }) router.push({ name: 'home' })
confirmation(t('account.closed'))
}
}
const remove = async () => {
const ok = await accountService.remove(props.account._id || '')
if (ok) {
router.push({ name: 'home' })
confirmation('Compte supprimée') confirmation('Compte supprimée')
} }
} }
public async save(): Promise<void> { const validate = (): boolean => {
if (!this.account || !this.account._id) { const allUsers: IUser[] = [...props.account.users, ...newUsers.value]
return const aliases = allUsers.map((u) => (u.alias ? u.alias.toLowerCase() : ''))
}
const ok: boolean = await accountService.update(
this.account._id,
this.account
)
if (!ok) {
queueNotifService.error(`Une erreur s'est produite.`)
}
}
public async saveUsers(): Promise<void> {
if (!this.account._id || !this.validate()) {
return
}
this.account.users = [...this.account.users, ...this.newUsers]
const ok: boolean = await accountService.update(
this.account._id,
this.account
)
if (!ok) {
queueNotifService.error(`Une erreur s'est produite.`)
return
}
queueNotifService.success(
this.$tc('account.newUserAdded', this.newUsers.length).toString()
)
this.newUsers = []
}
public getUserInfo(user: IUser): string {
const email = (user.email && ` - ${user.email}`) || ''
return `${user.alias} (${user.userId}${email})`
}
public validate(): boolean {
const allUsers: IUser[] = [...this.account.users, ...this.newUsers]
const aliases: string[] = allUsers.map((user: IUser) =>
user.alias ? user.alias.toLowerCase() : ''
)
if (aliases.length === 0) { if (aliases.length === 0) {
queueNotifService.error('Au moins une personne doit être ajoutée au compte.') queueNotifService.error('Au moins une personne doit être ajoutée au compte.')
return false return false
@@ -166,47 +74,84 @@ export default class AccountSetting extends Vue {
queueNotifService.error('Les alias doivent être uniques.') queueNotifService.error('Les alias doivent être uniques.')
return false return false
} }
const userIds = allUsers.map((u) => u.userId)
const userIds: string[] = allUsers.map((user: IUser) => user.userId)
if (userIds.length !== new Set(userIds).size) { if (userIds.length !== new Set(userIds).size) {
queueNotifService.error('Les identifiants doivent être uniques.') queueNotifService.error('Les identifiants doivent être uniques.')
return false return false
} }
return true return true
} }
public get isAdmin(): boolean { const saveUsers = async () => {
if (!this.user) { if (!props.account._id || !validate()) return
return true props.account.users = [...props.account.users, ...newUsers.value]
const ok = await accountService.update(props.account._id, props.account)
if (!ok) {
queueNotifService.error(`Une erreur s'est produite.`)
return
} }
return ( queueNotifService.success(t('account.newUserAdded', newUsers.value.length))
!this.account.admin || newUsers.value = []
this.account.admin.slugEmail === this.user.slugEmail
)
} }
public get backgroundColor(): any | null { const getUserInfo = (u: IUser): string => {
if (!this.account.color) { const email = (u.email && ` - ${u.email}`) || ''
return null return `${u.alias} (${u.userId}${email})`
}
return {
backgroundColor: this.account.color,
color: 'white'
}
}
public get isMultiUser(): boolean {
return this.account.users.length > 1
}
} }
</script> </script>
<style> <template>
.user-id { <div class="account-setting">
font-family: 'Cutive Mono', monospace; <div class="flex justify-center">
} <div class="w-full md:w-1/3">
.action-buttons { <ColorPicker v-model="color" @color-change="save" />
margin: 15px; </div>
} </div>
</style> <hr class="my-6" />
<div>
<h3 class="text-2xl font-semibold mb-3">{{ t('account.users') }}</h3>
<p class="text-center mb-4">
{{ account.users.map((u) => getUserInfo(u)).join(', ') }}
</p>
<h3 class="text-2xl font-semibold mb-3">Ajouter des amis</h3>
<div class="flex justify-center">
<div class="w-full md:w-1/3">
<AccountUserNew v-model="newUsers" :default-user="false" :show-title="false" />
</div>
</div>
<div v-if="newUsers.length" class="flex justify-center mt-4">
<button class="btn btn-primary btn-block btn-lg max-w-sm" @click="saveUsers">
<vaquant-icon icon="check" />
</button>
</div>
</div>
<hr class="my-6" />
<div class="grid md:grid-cols-2 gap-4 max-w-3xl mx-auto">
<div v-if="isMultiUser">
<AccountShare :account="account" :can-close="false" />
</div>
<div class="alert alert-info flex-col items-stretch">
<p class="font-semibold">Actions</p>
<div class="flex flex-wrap gap-2 mt-3 justify-center">
<ConfirmButton
v-if="account.archive"
class="btn-warning"
@confirm="active"
>
<span>{{ t('account.open') }}</span>
</ConfirmButton>
<ConfirmButton
v-else
class="btn-warning"
@confirm="archive"
>
<span>{{ t('account.close') }}</span>
</ConfirmButton>
<ConfirmButton class="btn-error" @confirm="remove">
<span>{{ t('account.delete') }}</span>
</ConfirmButton>
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,131 +1,105 @@
<template> <script setup lang="ts">
<article class="account-share message is-success is-medium" v-if="show"> import { ref, computed } from 'vue'
<div class="message-header"> import QrCode from 'qrcode.vue'
<p>Partagez le compte</p> import { storeToRefs } from 'pinia'
<button import { useUserStore } from '@/stores/user'
class="delete" import type IAccount from '@/models/IAccount'
v-if="canClose"
aria-label="close"
@click="hide"
></button>
</div>
<div class="message-body">
<button
class="button is-primary is-medium"
@click="share"
v-if="canShareAPI"
>
Partager
</button>
<div class="field is-grouped is-grouped-centered" v-else>
<div class="field" :class="{ 'has-addons': canClipboard }">
<div class="control">
<input class="input" type="text" readonly :value="accountUrl" />
</div>
<div class="control" if="canClipboard">
<a href="#" class="button is-primary" @click.prevent="copy"
>Copier</a
>
</div>
</div>
</div>
<div class="qr-code">
<p>ou via ce QR code :</p>
<qr-code :value="accountUrl" />
</div>
</div>
</article>
</template>
<script lang="ts">
declare const navigator: any
import { Component, Vue, Prop } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import IAccount from '@/models/IAccount'
import IUser from '@/models/IUser'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
import QrCode from '@xkeshi/vue-qrcode'
@Component({ const props = withDefaults(
components: { defineProps<{ account: IAccount; canClose?: boolean }>(),
'qr-code': QrCode { canClose: true }
} )
})
export default class Home extends Vue {
@Getter
public user!: IUser | null
@Prop({ type: Object, required: true })
public account!: IAccount
@Prop({ type: Boolean, default: true })
public canClose!: boolean
public show: boolean = true
public hide(): void { const userStore = useUserStore()
if (this.canClose) { const { user } = storeToRefs(userStore)
this.show = false
const show = ref(true)
const hide = () => {
if (props.canClose) {
show.value = false
} }
} }
public share(): void { const accountUrl = computed(() => {
if (this.canShareAPI) {
navigator.share({
title: `Vaquant - Compte ${this.account.name}`,
text: `${this.username} vous invite sur Vaquant pour gérer vos dépenses sur le compte ${this.account.name}.`,
url: this.accountUrl
})
}
}
public async copy(): Promise<void> {
if (this.canClipboard) {
await navigator.clipboard.writeText(this.accountUrl)
queueNotifService.success('Adresse copiée')
}
}
public get accountUrl(): string {
let url = `${window.location.origin}${window.location.pathname}` let url = `${window.location.origin}${window.location.pathname}`
if (this.account.isPublic) { if (props.account.isPublic) {
url = `${url}/public` url = `${url}/public`
} }
return url return url
} })
public get username(): string { const username = computed(() => {
if (!this.user) { if (!user.value) return ''
return ''
}
return ( return (
`${this.user.firstname} ${this.user.lastname}`.trim() || this.user.email `${user.value.firstname} ${user.value.lastname}`.trim() ||
user.value.email
) )
})
const canShareAPI = computed(() => typeof navigator.share === 'function')
const canClipboard = computed(() => !!navigator.clipboard)
const share = () => {
if (canShareAPI.value) {
navigator.share({
title: `Vaquant - Compte ${props.account.name}`,
text: `${username.value} vous invite sur Vaquant pour gérer vos dépenses sur le compte ${props.account.name}.`,
url: accountUrl.value
})
}
} }
public get canShareAPI(): boolean { const copy = async () => {
return !!navigator.share if (canClipboard.value) {
} await navigator.clipboard.writeText(accountUrl.value)
queueNotifService.success('Adresse copiée')
public get canClipboard(): boolean {
return !!navigator.clipboard
} }
} }
</script> </script>
<style lang="scss" scoped> <template>
$padding-fab: 24px; <div v-if="show" class="account-share alert alert-success flex-col items-stretch">
<div class="flex justify-between items-center w-full">
.is-fab { <p class="font-semibold">Partagez le compte</p>
bottom: $padding-fab; <button
position: fixed; v-if="canClose"
right: $padding-fab; class="btn btn-ghost btn-sm btn-square"
} aria-label="close"
@click="hide"
.fab-margin { >
margin: calc(#{$padding-fab} * 2) 0; <vaquant-icon icon="x" />
} </button>
</div>
.qr-code { <div class="mt-3 w-full">
canvas { <button
margin: 1em 0; v-if="canShareAPI"
} class="btn btn-primary"
} @click="share"
</style> >
Partager
</button>
<div v-else class="join">
<input
:value="accountUrl"
type="text"
readonly
class="input input-bordered join-item flex-1"
/>
<a
v-if="canClipboard"
href="#"
class="btn btn-primary join-item"
@click.prevent="copy"
>
Copier
</a>
</div>
<div class="qr-code mt-3 text-center">
<p class="mb-2">ou via ce QR code :</p>
<QrCode :value="accountUrl" :size="180" class="inline-block" />
</div>
</div>
</div>
</template>

View File

@@ -1,79 +1,107 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import type IAccount from '@/models/IAccount'
import type ITransaction from '@/models/ITransaction'
import { findContrastColor } from '@/utils'
import { date as formatDate, moneypad } from '@/utils/format'
const props = withDefaults(
defineProps<{
account?: IAccount | null
transactions: ITransaction[]
filter?: boolean
head?: boolean
foot?: boolean
}>(),
{ account: null, filter: true, head: true, foot: false }
)
const filteredBy = ref('')
const total = computed(() =>
props.transactions.reduce((a, t) => a + t.amount, 0)
)
const filteredTransactions = computed(() =>
filteredBy.value
? props.transactions.filter((t) => t.payBy === filteredBy.value)
: props.transactions
)
const isMultiUser = computed(() => {
const payBy = new Set(props.transactions.map((t) => t.payBy))
return payBy.size > 1
})
const showFilter = computed(() => props.filter && isMultiUser.value)
const hasMultipleCurrencies = computed(
() => (props.account && props.account.currencies.length > 1) || false
)
</script>
<template> <template>
<div class="account-transaction-list" v-if="account"> <div v-if="account" class="account-transaction-list">
<div <div v-if="showFilter" class="join justify-center mb-3">
class="field has-addons has-addons-centered is-narrow" <button
v-if="showFilter" v-if="filteredBy"
type="submit"
class="btn join-item"
@click="filteredBy = ''"
> >
<div class="control" v-if="filteredBy"> <vaquant-icon icon="x" />
<button type="submit" class="button" @click="filteredBy = ''">
<awe-icon icon="times" />
</button> </button>
</div> <select v-model="filteredBy" class="select select-bordered join-item">
<div class="control"> <option value="">Tous</option>
<div class="select is-fullwidth"> <option v-for="(u, k) in account.users" :key="k" :value="u.alias">
<select v-model="filteredBy"> {{ u.alias }}
<option value>Tous</option>
<option
v-for="(user, k) in account.users"
:key="k"
:value="user.alias"
>
{{ user.alias }}
</option> </option>
</select> </select>
</div> </div>
</div> <div class="overflow-x-auto">
</div> <table class="table table-zebra">
<div class="table-container">
<table class="table is-hoverable is-striped">
<thead v-if="head"> <thead v-if="head">
<tr> <tr>
<th scope="col"> <th scope="col">
<awe-icon icon="minus" /> <vaquant-icon icon="minus" />
</th> </th>
<th scope="col" class="align"> <th scope="col" class="text-center">
<awe-icon icon="user" /> <vaquant-icon icon="user" />
</th> </th>
<th scope="col" class="align"> <th scope="col" class="text-center">
<awe-icon icon="money-bill-wave" /> <vaquant-icon icon="cash" />
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="(transaction, k) in filteredTransactions" v-for="(t, k) in filteredTransactions"
:id="t._id"
:key="k" :key="k"
:data-index="k" :data-index="k"
:id="transaction._id"
>
<td class="multiline">
<router-link
:to="{ name: 'transaction', params: { id: transaction._id } }"
>{{ transaction.name }}</router-link
> >
<td>
<router-link :to="{ name: 'transaction', params: { id: t._id } }">
{{ t.name }}
</router-link>
<br /> <br />
<span class="date">{{ transaction.date | date }}</span> <span class="italic text-sm">{{ formatDate(String(t.date)) }}</span>
</td> </td>
<td class="pay-by">{{ transaction.payBy }}</td> <td>{{ t.payBy }}</td>
<td class="numeric"> <td class="numeric text-right">
{{ {{ moneypad(t.amount, t.mainCurrency, hasMultipleCurrencies) }}
transaction.amount
| moneypad(transaction.mainCurrency, hasMultipleCurrencies)
}}
</td> </td>
</tr> </tr>
</tbody> </tbody>
<tfoot v-if="foot"> <tfoot v-if="foot">
<tr <tr
class="is-selected"
:style="{ :style="{
'background-color': account.color, backgroundColor: account.color ?? undefined,
color: findContrastColor(account.color) color: findContrastColor(account.color ?? null) ?? undefined
}" }"
> >
<td colspan="2" class="total">total</td> <td colspan="2" class="font-bold">total</td>
<td class="numeric"> <td class="numeric text-right">
{{ total | moneypad(account.mainCurrency) }} {{ moneypad(total, account.mainCurrency) }}
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
@@ -81,82 +109,3 @@
</div> </div>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import IAccount from '@/models/IAccount'
import ITransaction from '@/models/ITransaction'
import { findContrastColor } from '@/utils'
@Component
export default class AccountTransactionList extends Vue {
@Prop({ type: Object, default: () => null })
public account!: IAccount | null
@Prop({ type: Array, required: true })
public transactions!: ITransaction[]
@Prop({ type: Boolean, default: true })
public filter!: boolean
@Prop({ type: Boolean, default: true })
public head!: boolean
@Prop({ type: Boolean, default: false })
public foot!: boolean
public filteredBy: string = ''
public findContrastColor = findContrastColor
public get total(): number {
return this.transactions.reduce(
(a: number, transaction: ITransaction) => a + transaction.amount,
0
)
}
public get filteredTransactions(): ITransaction[] {
if (!this.filteredBy) {
return this.transactions
}
return this.transactions.filter(
(transaction: ITransaction) => transaction.payBy === this.filteredBy
)
}
public get isMultiUser(): boolean {
const payBy = new Set(
this.transactions.map((transaction: ITransaction) => transaction.payBy)
)
return payBy.size > 1
}
public get showFilter(): boolean {
return this.filter && this.isMultiUser
}
public get hasMultipleCurrencies(): boolean {
return (this.account && this.account.currencies.length > 1) || false
}
}
</script>
<style lang="scss" scoped>
.account-transaction-list {
margin: auto;
}
.table-container {
overflow-x: auto;
}
.multiline {
line-height: 15px;
}
.date {
font-style: italic;
font-size: 14px;
}
th {
&.align {
text-align: center;
}
}
</style>

View File

@@ -1,31 +1,75 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import type IUser from '@/models/IUser'
import { useUserStore } from '@/stores/user'
import UserNew from '@/components/UserNew.vue'
const users = defineModel<IUser[]>({ required: true })
const props = withDefaults(
defineProps<{ defaultUser?: boolean; showTitle?: boolean }>(),
{ defaultUser: true, showTitle: true }
)
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { t } = useI18n()
const localeUsers = ref<IUser[]>([])
const newUserId = ref('')
onMounted(() => {
localeUsers.value = users.value
})
watch(users, (value) => {
localeUsers.value = value
})
watch(localeUsers, (value) => {
users.value = value
}, { deep: true })
const newUser = () => {
if (!newUserId.value) return
localeUsers.value.push({
userId: newUserId.value,
email: '',
premium: user.value ? user.value.premium : false,
slugEmail: '',
alias: newUserId.value,
firstname: '',
lastname: ''
})
newUserId.value = ''
}
const remove = (index: number) => {
localeUsers.value = localeUsers.value.filter((_u, i) => i !== index)
}
</script>
<template> <template>
<div class="account-user-new"> <div class="account-user-new">
<h3 v-if="showTitle" class="subtitle is-3"> <h3 v-if="showTitle" class="text-2xl font-semibold mb-3">
{{ {{ t('account.friends', { count: localeUsers.length }, localeUsers.length) }}
$tc('account.friends', localeUsers.length, {
count: localeUsers.length
})
}}
</h3> </h3>
<user-new v-if="user && defaultUser" v-model="user" /> <UserNew v-if="user && defaultUser" v-model="user" />
<div v-for="(user, k) in localeUsers" :key="k"> <div v-for="(u, k) in localeUsers" :key="k">
<user-new v-model="localeUsers[k]" @remove="() => remove(k)" /> <UserNew v-model="localeUsers[k]" @remove="() => remove(k)" />
</div> </div>
<br /> <div class="join mt-4 w-full">
<div class="field has-addons">
<div class="control">
<input <input
class="input" v-model="newUserId"
type="text" type="text"
placeholder="nouvel ami" placeholder="nouvel ami"
v-model="newUserId" class="input input-bordered join-item flex-1"
@keyup.enter.prevent="newUser" @keyup.enter.prevent="newUser"
/> />
</div>
<div class="control">
<button <button
type="button" type="button"
class="button is-primary" class="btn btn-primary join-item"
:disabled="!newUserId" :disabled="!newUserId"
@click.prevent="newUser" @click.prevent="newUser"
> >
@@ -33,70 +77,4 @@
</button> </button>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts">
import { Component, Model, Prop, Vue, Watch } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import IUser from '@/models/IUser'
@Component({
components: {
'user-new': () => import('@/components/UserNew.vue')
}
})
export default class HelloWorld extends Vue {
@Getter
public user!: IUser | null
@Model('input', { type: Array, required: true })
public users!: IUser[]
@Prop({ type: Boolean, default: true })
public defaultUser!: boolean
@Prop({ type: Boolean, default: true })
public showTitle!: boolean
public localeUsers: IUser[] = []
public userId: string = ''
public newUserId: string = ''
public mounted(): void {
this.localeUsers = this.users
}
public newUser(): void {
if (!this.newUserId) {
return
}
this.localeUsers.push({
userId: this.newUserId,
email: '',
premium: this.user ? this.user.premium : false,
slugEmail: '',
alias: this.newUserId,
firstname: '',
lastname: ''
})
this.newUserId = ''
}
public remove(index: number): void {
this.localeUsers = this.localeUsers.filter(
(u: IUser, i: number) => i !== index
)
}
public removeUser(): void {
this.localeUsers.pop()
}
@Watch('users')
public onUsersChange(users: IUser[]): void {
this.localeUsers = users
}
@Watch('localeUsers')
public onUserChange(users: IUser[]): void {
this.$emit('input', users)
}
}
</script>

View File

@@ -1,21 +1,18 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const isInstalled = ref(false)
onMounted(() => {
isInstalled.value =
typeof window.matchMedia === 'function' &&
window.matchMedia('(display-mode: standalone)').matches
})
</script>
<template> <template>
<span> <span>
<slot v-if="isInstalled" name="app-installed"></slot> <slot v-if="isInstalled" name="app-installed" />
<slot v-else name="app-not-installed"></slot> <slot v-else name="app-not-installed" />
</span> </span>
</template> </template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class AppInstalled extends Vue {
private isInstalled: boolean = false
private mounted() {
this.isInstalled =
window.matchMedia &&
window.matchMedia('(display-mode: standalone)').matches
}
}
</script>

View File

@@ -1,13 +1,17 @@
<script setup lang="ts">
import { COFFEE_LINK, BRAVE_LINK } from '@/utils/constants'
import cloudOffIcon from '@/assets/icons/baseline-cloud_off-24px.svg'
const coffee = COFFEE_LINK
const brave = BRAVE_LINK
</script>
<template> <template>
<div class="app-resume" v-once> <div v-once class="app-resume">
<div class="columns is-multiline text-description is-centered"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 text-justify text-base md:text-lg">
<div class="column is-one-third"> <div class="p-6">
<div class="main-icon"> <div class="text-center text-4xl mb-2">
<img <img :src="cloudOffIcon" alt="offline" class="inline-block w-10 h-10" />
class="icon"
src="../assets/icons/baseline-cloud_off-24px.svg"
alt="offline"
/>
</div> </div>
<p> <p>
Vos données sont hors-lignes. Utilisez Vaquant vous voulez, seul ou Vos données sont hors-lignes. Utilisez Vaquant vous voulez, seul ou
@@ -15,12 +19,12 @@
connexion est disponible afin de les synchroniser avec vos amis. connexion est disponible afin de les synchroniser avec vos amis.
</p> </p>
</div> </div>
<div class="column is-one-third"> <div class="p-6">
<div class="main-icon"> <div class="text-center text-4xl mb-2">
<awe-icon icon="users"></awe-icon> <vaquant-icon icon="users" />
</div> </div>
<p>Choisissez de rendre le compte privé ou public :</p> <p>Choisissez de rendre le compte privé ou public :</p>
<ul> <ul class="list-disc list-inside mt-2 space-y-1">
<li> <li>
Ainsi, seules les personnes du compte privé accéderont aux dépenses. Ainsi, seules les personnes du compte privé accéderont aux dépenses.
</li> </li>
@@ -30,20 +34,11 @@
</li> </li>
</ul> </ul>
</div> </div>
<!-- <div class="column is-one-third"> <div class="p-6">
<div class="main-icon"> <div class="text-center text-4xl mb-2">
<awe-icon icon="user-lock"/> <vaquant-icon icon="currency-euro" />&nbsp;
</div> <vaquant-icon icon="arrows-exchange" />&nbsp;
<p> <vaquant-icon icon="currency-dollar" />
Les informations sensibles de vos comptes et transactions sont chiffrées de bout en bout.
Seuls les membres d'un compte peuvent visualiser ses données.
</p>
</div>-->
<div class="column is-one-third">
<div class="main-icon">
<awe-icon icon="euro-sign" />&nbsp;
<awe-icon icon="exchange-alt" />&nbsp;
<awe-icon icon="dollar-sign" />
</div> </div>
<p> <p>
Vaquant vous permet d'enregistrer les dépenses dans la monnaie locale Vaquant vous permet d'enregistrer les dépenses dans la monnaie locale
@@ -51,27 +46,27 @@
le jour. le jour.
</p> </p>
</div> </div>
<div class="column is-one-third"> <div class="p-6">
<div class="main-icon"> <div class="text-center text-4xl mb-2">
<awe-icon icon="sync-alt" /> <vaquant-icon icon="refresh" />
</div> </div>
<p> <p>
Vaquant se met à jour en direct. Vous recevez automatiquement les Vaquant se met à jour en direct. Vous recevez automatiquement les
nouveaux comptes et dépenses sans même vous en rendre compte. nouveaux comptes et dépenses sans même vous en rendre compte.
</p> </p>
</div> </div>
<div class="column is-one-third"> <div class="p-6">
<div class="main-icon"> <div class="text-center text-4xl mb-2">
<awe-icon icon="mobile" /> <vaquant-icon icon="device-mobile" />
</div> </div>
<p> <p>
Vaquant est simple, sécurisée, rapide et accessible depuis un simple Vaquant est simple, sécurisée, rapide et accessible depuis un simple
lien internet&nbsp;! Votre partenaire idéal pour vos voyages&nbsp;! lien internet&nbsp;! Votre partenaire idéal pour vos voyages&nbsp;!
</p> </p>
</div> </div>
<div class="column is-one-third"> <div class="p-6">
<div class="main-icon"> <div class="text-center text-4xl mb-2">
<awe-icon icon="hand-holding-usd" /> <vaquant-icon icon="pig-money" />
</div> </div>
<p> <p>
Vaquant propose une offre gratuite sans publicité. Si vous souhaitez Vaquant propose une offre gratuite sans publicité. Si vous souhaitez
@@ -87,39 +82,3 @@
</div> </div>
</div> </div>
</template> </template>
<script lang="ts">
import Vue from 'vue'
import { COFFEE_LINK, BRAVE_LINK } from '@/utils/constants'
export default Vue.extend({
data() {
return {
coffee: COFFEE_LINK,
brave: BRAVE_LINK
}
}
})
</script>
<style lang="scss" scoped>
.main-icon {
font-size: 34pt;
text-align: center;
}
.text-description {
font-size: 14pt;
text-align: justify;
text-align-last: center;
.column {
padding: 30px;
.emoji {
font-size: 20pt;
text-align: center;
}
}
}
</style>

View File

@@ -1,29 +1,19 @@
<script setup lang="ts">
import { computed } from 'vue'
import chartService from '@/services/ChartService'
import type IStat from '@/models/IStat'
const props = defineProps<{
stat: IStat
index: number
total: number
}>()
const point = computed(() =>
chartService.valueToPoint(props.stat.percent + 10, props.index, props.total)
)
</script>
<template> <template>
<text :x="point.x" :y="point.y">{{ stat.label }}</text> <text :x="point.x" :y="point.y">{{ stat.label }}</text>
</template> </template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import chartService from '@/services/ChartService'
import IStat from '@/models/IStat'
import IPoint from '@/models/IPoint'
@Component
export default class AxisLabel extends Vue {
@Prop({ type: Object, required: true })
public stat!: IStat
@Prop({ type: Number, required: true })
public index!: number
@Prop({ type: Number, required: true })
public total!: number
public get point(): IPoint {
return chartService.valueToPoint(
this.stat.percent + 10,
this.index,
this.total
)
}
}
</script>

View File

@@ -1,29 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import type IAccount from '@/models/IAccount'
import type ICurrency from '@/models/ICurrency'
import type IStat from '@/models/IStat'
import { findContrastColor } from '@/utils'
import { money } from '@/utils/format'
const props = defineProps<{
account: IAccount
stats: IStat[]
currency: ICurrency
}>()
const statOrdered = computed(() =>
[...props.stats].sort((a, b) => (a.balance.amount < b.balance.amount ? 1 : -1))
)
const totalCost = computed(() =>
props.stats.reduce((a, b) => a + b.value, 0)
)
const colorStyle = computed(() => {
if (!props.account) return {}
return {
backgroundColor: props.account.color ?? undefined,
color: findContrastColor(props.account.color ?? null) ?? undefined
}
})
const getColor = (amount: number): string =>
amount === 0 ? 'text-primary' : amount > 0 ? 'text-success' : 'text-error'
</script>
<template> <template>
<div class="chart-balance"> <div class="chart-balance overflow-x-auto">
<table class="table is-hoverable is-striped" v-if="stats.length"> <table v-if="stats.length" class="table table-zebra mx-auto">
<tbody> <tbody>
<tr v-for="(stat, k) in statOrdered" :key="k"> <tr v-for="(stat, k) in statOrdered" :key="k">
<th scope="row">{{ stat.label }}</th> <th scope="row">{{ stat.label }}</th>
<td class="numeric">{{ stat.value | money(currency) }}</td> <td class="numeric font-bold text-right">{{ money(stat.value, currency) }}</td>
<td <td
v-if="stat.balance.amount !== 0" v-if="stat.balance.amount !== 0"
class="numeric" class="numeric font-bold text-right"
:class="getColor(stat.balance.amount)" :class="getColor(stat.balance.amount)"
> >
{{ stat.balance.amount | money(currency) }} {{ money(stat.balance.amount, currency) }}
</td>
<td v-else class="text-right" :class="getColor(stat.balance.amount)">
👌🏾
</td> </td>
<td
v-else
class="conclude"
:class="getColor(stat.balance.amount)"
></td>
</tr> </tr>
</tbody> </tbody>
<tfoot> <tfoot>
<tr :style="colorStyle"> <tr :style="colorStyle">
<th :style="colorStyle" scope="row" class="total">total</th> <th :style="colorStyle" scope="row" class="font-bold">total</th>
<th :style="colorStyle" class="numeric" colspan="2"> <th :style="colorStyle" class="numeric text-right" colspan="2">
{{ totalCost | money(currency) }} {{ money(totalCost, currency) }}
</th> </th>
</tr> </tr>
</tfoot> </tfoot>
@@ -31,76 +63,3 @@
<div v-else>Aucune dépense faite</div> <div v-else>Aucune dépense faite</div>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import ICurrency from '@/models/ICurrency'
import IStat from '@/models/IStat'
import IAccount from '../models/IAccount'
import { findContrastColor } from '@/utils'
@Component({
components: {
'axis-label': () => import('@/components/AxisLabel.vue')
}
})
export default class ChartBalance extends Vue {
@Prop({ type: Object, required: true })
public account!: IAccount
@Prop({ type: Array, default: () => [] })
public stats!: IStat[]
@Prop({ type: Object, required: true })
public currency!: ICurrency
public getColor(amount: number): string {
return amount === 0 ? 'zero' : amount > 0 ? 'positive' : 'negative'
}
public get colorStyle() {
if (!this.account) {
return {}
}
return {
backgroundColor: this.account.color,
color: findContrastColor(this.account.color)
}
}
public get statOrdered(): IStat[] {
return [...this.stats].sort((a: IStat, b: IStat) =>
a.balance.amount < b.balance.amount ? 1 : -1
)
}
public get totalCost(): number {
return this.stats.reduce((a: number, b: IStat) => a + b.value, 0)
}
}
</script>
<style lang="scss">
@import '../styles/variables';
.chart-balance {
overflow-x: auto;
}
.table {
margin: auto;
.numeric {
font-weight: bold;
text-align: right;
}
}
.zero {
color: $primary;
&.conclude::after {
content: '👌🏾';
}
}
.positive {
color: $green;
}
.negative {
color: $red;
}
</style>

View File

@@ -1,85 +1,59 @@
<template> <script setup lang="ts">
<svg class="chart-pie" ref="chart" id="chart-pie" viewBox="-1 -1 2 2"></svg> import { ref, watch, onMounted } from 'vue'
</template> import type ISlice from '@/models/ISlice'
<script lang="ts"> const props = defineProps<{ slices: ISlice[] }>()
// https://codepen.io/soluhmin/pen/jexywg const chart = ref<SVGSVGElement | null>(null)
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
import ISlice from '@/models/ISlice'
@Component const getCoordinatesForPercent = (percent: number): [number, number] => [
export default class ChartPie extends Vue { Math.cos(2 * Math.PI * percent),
@Prop({ type: Array, default: () => [] }) Math.sin(2 * Math.PI * percent)
public slices!: ISlice[] ]
public svgEl: Element | null = null
public mounted(): void { const clearSvg = () => {
this.svgEl = this.$refs.chart as Element const svg = chart.value
this.constructChart() if (!svg) return
while (svg.lastChild) svg.removeChild(svg.lastChild)
} }
public getCoordinatesForPercent(percent: number): number[] { const constructChart = () => {
const x = Math.cos(2 * Math.PI * percent) const svg = chart.value
const y = Math.sin(2 * Math.PI * percent) if (!svg) return
return [x, y] clearSvg()
}
public clearSvg(): void {
if (!this.svgEl) {
return
}
const svgEl = this.svgEl
while (svgEl.lastChild) {
svgEl.removeChild(svgEl.lastChild)
}
}
public constructChart(): void {
if (!this.svgEl) {
return
}
const svgEl = this.svgEl
this.clearSvg()
let cumulativePercent = 0 let cumulativePercent = 0
props.slices.forEach((slice) => {
this.slices.forEach((slice: ISlice) => { const [startX, startY] = getCoordinatesForPercent(cumulativePercent)
const [startX, startY] = this.getCoordinatesForPercent(cumulativePercent)
cumulativePercent += slice.percent cumulativePercent += slice.percent
const [endX, endY] = this.getCoordinatesForPercent(cumulativePercent) const [endX, endY] = getCoordinatesForPercent(cumulativePercent)
/*
* if the slice is more than 50%,
* take the large arc (the long way around)
*/
const largeArcFlag = slice.percent > 0.5 ? 1 : 0 const largeArcFlag = slice.percent > 0.5 ? 1 : 0
const pathData = [ const pathData = [
`M ${startX} ${startY}`, // Move `M ${startX} ${startY}`,
`A 1 1 0 ${largeArcFlag} 1 ${endX} ${endY}`, // Arc `A 1 1 0 ${largeArcFlag} 1 ${endX} ${endY}`,
`L 0 0` // Line `L 0 0`
].join(' ') ].join(' ')
const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path')
const pathEl = document.createElementNS(
'http://www.w3.org/2000/svg',
'path'
)
pathEl.setAttribute('d', pathData) pathEl.setAttribute('d', pathData)
pathEl.setAttribute('fill', slice.color) pathEl.setAttribute('fill', slice.color)
svgEl.appendChild(pathEl) svg.appendChild(pathEl)
}) })
} }
@Watch('slices') onMounted(constructChart)
public onSliceChange(): void { watch(() => props.slices, constructChart, { deep: true })
this.constructChart()
}
}
</script> </script>
<template>
<svg
id="chart-pie"
ref="chart"
class="chart-pie"
viewBox="-1 -1 2 2"
/>
</template>
<style scoped> <style scoped>
.chart-pie { .chart-pie {
transform: rotate(-90deg); transform: rotate(-90deg);
/* height: 200px; */
max-width: 400pt; max-width: 400pt;
} }
</style> </style>

View File

@@ -1,36 +1,22 @@
<script setup lang="ts">
import type ISlice from '@/models/ISlice'
defineProps<{ slices: ISlice[] }>()
</script>
<template> <template>
<div class="chart-progress"> <div class="chart-progress flex items-center justify-center mx-auto flex-1">
<div <div
v-for="slice in slices" v-for="slice in slices"
:key="slice.key" :key="slice.key"
class="chart-progress-bar"
:style="{ flex: slice.percent, backgroundColor: slice.color }" :style="{ flex: slice.percent, backgroundColor: slice.color }"
></div> />
</div> </div>
</template> </template>
<script lang="ts"> <style scoped>
import { Component, Prop, Vue } from 'vue-property-decorator' .chart-progress-bar {
import ISlice from '@/models/ISlice' height: 1.2rem;
@Component
export default class ChartProgress extends Vue {
@Prop({ type: Array, default: () => [] })
public slices!: ISlice[]
}
</script>
<style scoped lang="scss">
$percent-height: 1.2rem;
.chart-progress {
margin: auto;
display: flex;
flex: 1;
justify-content: center;
align-items: center;
div {
height: $percent-height;
}
} }
</style> </style>

View File

@@ -1,109 +1,80 @@
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import type IColor from '@/models/IColor'
import colors from '@/data/colors'
import { findContrastColor } from '@/utils'
const model = defineModel<string | null>({ default: null })
const emit = defineEmits<{ 'color-change': [] }>()
const localColor = ref<string | null>(model.value)
const colorLabel = ref<string | null>(null)
onMounted(() => {
localColor.value = model.value
if (model.value) {
const color = colors.find((c: IColor) => c.value === model.value)
if (color) colorLabel.value = color.label
} else {
localColor.value = colors[0].value ?? null
}
})
const select = (color: IColor) => {
localColor.value = color.value
colorLabel.value = color.label
}
const colorBackground = (color: IColor) => ({
border: !color.label ? '1px solid #95afc0' : '',
backgroundColor: color.value ?? ''
})
const colorStyle = computed(() => ({
color: findContrastColor(localColor.value) ?? ''
}))
watch(localColor, (value) => {
if (model.value !== value) {
model.value = value
emit('color-change')
}
})
</script>
<template> <template>
<div class="color-picker"> <div class="color-picker">
<h3 class="subtitle is-3">Couleur</h3> <h3 class="text-2xl font-semibold mb-3">Couleur</h3>
<div class="columns is-mobile is-centered is-multiline"> <div class="flex flex-wrap justify-center gap-3">
<div class="column" v-for="(color, k) in colors" :key="k">
<div <div
@click.prevent="select(color)" v-for="(color, k) in colors"
:key="k"
class="color-container" class="color-container"
:class="{ white: color.label }" :class="{ white: color.label }"
:style="colorBackground(color)" :style="colorBackground(color)"
@click.prevent="select(color)"
> >
<awe-icon <vaquant-icon
:style="colorStyle"
v-if="color.value === localColor" v-if="color.value === localColor"
:style="colorStyle"
icon="check" icon="check"
/> />
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts"> <style scoped>
import { Component, Vue, Model, Watch } from 'vue-property-decorator'
import IColor from '@/models/IColor'
import colors from '@/data/colors'
import { findContrastColor } from '../utils'
@Component
export default class ColorPicker extends Vue {
@Model('input', { type: String, required: false })
public color!: string | null
public localColor: string | null = null
public colorLabel: string | null = null
public colors: IColor[] = colors
public mounted(): void {
this.localColor = this.color
if (this.color) {
const color: IColor | undefined = this.colors.find(
(c: IColor) => c.value === this.color
)
if (color) {
this.colorLabel = color.label
}
} else {
this.localColor = this.colors[0].value
}
}
public select(color: IColor): void {
this.localColor = color.value
this.colorLabel = color.label
}
public colorBackground(color: IColor): any {
return {
border: !color.label ? '1px solid #95afc0' : '',
backgroundColor: color.value
}
}
public get background(): any | null {
if (!this.localColor) {
return null
}
return {
'background-color': this.localColor
}
}
public get colorStyle() {
return {
color: findContrastColor(this.localColor)
}
}
@Watch('localColor')
public onColorChange(color: string | null, oldColor: string | null) {
if (this.color !== color) {
this.$emit('input', color)
this.$emit('color-change')
}
}
}
</script>
<style lang="scss" scoped>
.color-container { .color-container {
border-radius: 6px; border-radius: 6px;
width: 50px; width: 50px;
height: 50px; height: 50px;
line-height: 50px; line-height: 50px;
font-size: 25px; font-size: 25px;
margin: auto; text-align: center;
&.white {
color: white;
}
&:hover {
cursor: pointer; cursor: pointer;
} }
} .color-container.white {
.input { color: white;
width: 100px;
}
.button.is-static {
width: 50px;
} }
</style> </style>

View File

@@ -1,51 +1,46 @@
<script setup lang="ts">
import { ref, computed, useTemplateRef } from 'vue'
import { onClickOutside } from '@vueuse/core'
const emit = defineEmits<{ confirm: [done: () => void] }>()
const root = useTemplateRef<HTMLAnchorElement>('root')
const loading = ref(false)
const firstTap = ref(true)
const resetTap = () => {
firstTap.value = true
}
onClickOutside(root, resetTap)
const click = () => {
if (loading.value) return
if (firstTap.value) {
firstTap.value = false
return
}
loading.value = true
firstTap.value = true
emit('confirm', () => {
loading.value = false
})
}
const confirmMessage = computed(() => (firstTap.value ? '' : 'Confirmer pour valider'))
</script>
<template> <template>
<a <a
ref="root"
href="#" href="#"
class="button" class="btn"
:class="{ 'is-loading': loading }" :class="{ 'btn-loading': loading }"
@click.prevent="click" @click.prevent="click"
v-click-outside="resetTap"
> >
<span v-show="confirmMessage">{{ confirmMessage }}</span> <span v-show="confirmMessage">{{ confirmMessage }}</span>
<span v-show="!confirmMessage"> <span v-show="!confirmMessage">
<slot></slot> <slot />
</span> </span>
</a> </a>
</template> </template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import ClickOutside from 'vue-click-outside'
@Component({
directives: {
ClickOutside
}
})
export default class ConfirmButton extends Vue {
public loading: boolean = false
public firstTap: boolean = true
public resetTap(): void {
this.firstTap = true
}
public click(): void {
if (this.loading) {
return
}
if (this.firstTap) {
this.firstTap = false
return
}
this.loading = true
this.firstTap = true
this.$emit('confirm', () => {
this.loading = false
})
}
public get confirmMessage(): string {
return this.firstTap ? '' : 'Confirmer pour valider'
}
}
</script>

View File

@@ -0,0 +1,51 @@
<script setup lang="ts">
import { watch } from 'vue'
import { useCurrencyInput, CurrencyDisplay } from 'vue-currency-input'
const props = withDefaults(
defineProps<{
modelValue: number | null
locale?: string
precision?: number
allowNegative?: boolean
valueRange?: { min?: number; max?: number }
placeholder?: string
}>(),
{
locale: 'fr-FR',
precision: 2,
allowNegative: false,
valueRange: () => ({ min: 0, max: 1_000_000 }),
placeholder: ''
}
)
defineEmits<{ 'update:modelValue': [value: number | null] }>()
const { inputRef, setValue } = useCurrencyInput({
currency: 'USD',
currencyDisplay: CurrencyDisplay.hidden,
locale: props.locale,
precision: props.precision,
hideCurrencySymbolOnFocus: false,
hideGroupingSeparatorOnFocus: false,
valueRange: props.valueRange,
autoDecimalDigits: false
})
watch(
() => props.modelValue,
(value) => {
setValue(value)
}
)
</script>
<template>
<input
ref="inputRef"
type="text"
class="input input-bordered w-full"
:placeholder="placeholder"
/>
</template>

View File

@@ -1,119 +1,92 @@
<template> <script setup lang="ts">
<section id="earth" class="earth-container"></section> import { onMounted } from 'vue'
</template> import mapboxgl from 'mapbox-gl'
import 'mapbox-gl/dist/mapbox-gl.css'
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import mapboxgl from 'mapbox-gl/dist/mapbox-gl'
import { debounce } from 'lodash-es' import { debounce } from 'lodash-es'
import ILocation from '@/models/ILocation' import type ILocation from '@/models/ILocation'
@Component const props = withDefaults(
export default class EarthMap extends Vue { defineProps<{
@Prop({ type: Array, default: () => [] }) locations?: ILocation[]
private locations!: ILocation[] defineLocation?: boolean
@Prop({ type: Boolean, default: false }) }>(),
private defineLocation!: boolean { locations: () => [], defineLocation: false }
private earth: any | null = null )
private markerLocation: any | null = null
private emit = debounce((earthMap: EarthMap) => { const emit = defineEmits<{ located: [location: { lat: number; lon: number }] }>()
if (!earthMap.markerLocation) {
return let earth: mapboxgl.Map | null = null
} let markerLocation: mapboxgl.Marker | null = null
const lnglat = earthMap.markerLocation.getLngLat()
earthMap.$emit('located', { const fireLocated = debounce(() => {
lat: lnglat.lat, if (!markerLocation) return
lon: lnglat.lng const lnglat = markerLocation.getLngLat()
}) emit('located', { lat: lnglat.lat, lon: lnglat.lng })
}, 150) }, 150)
private mounted() { const initEarthMap = () => {
this.initEarthMap() mapboxgl.accessToken = import.meta.env.VITE_MAPBOX_KEY ?? ''
if (!this.earth) { const latitudes = props.locations.map((l) => l.lat)
return const longitudes = props.locations.map((l) => l.lon)
} const bounds = props.locations.length
? ([[Math.min(...longitudes), Math.min(...latitudes)], [Math.max(...longitudes), Math.max(...latitudes)]] as [
const markers = this.locations.map((location) => [ [number, number],
location.lon, [number, number]
location.lat
]) ])
markers.forEach((marker) => {
const el = document.createElement('i')
el.classList.add('marker', 'gg-pin-alt')
new mapboxgl.Marker(el).setLngLat(marker).addTo(this.earth)
})
}
private initEarthMap() {
mapboxgl.accessToken = process.env.VUE_APP_MAPBOX_KEY
const latitudes = this.locations.map((location: ILocation) => location.lat)
const longitudes = this.locations.map((location: ILocation) => location.lon)
const minLat = Math.min(...latitudes)
const maxLat = Math.max(...latitudes)
const minLon = Math.min(...longitudes)
const maxLon = Math.max(...longitudes)
// [sw, ne]
const bounds = this.locations.length
? [
[minLon, minLat],
[maxLon, maxLat]
]
: undefined : undefined
this.earth = new mapboxgl.Map({ earth = new mapboxgl.Map({
container: 'earth', container: 'earth',
style: 'mapbox://styles/mapbox/streets-v11', style: 'mapbox://styles/mapbox/streets-v12',
bounds, bounds,
fitBoundsOptions: { fitBoundsOptions: { padding: 30, maxZoom: 12 }
padding: 30,
maxZoom: 12
}
}) })
this.earth.addControl(new mapboxgl.NavigationControl()) earth.addControl(new mapboxgl.NavigationControl())
const geolocControl = new mapboxgl.GeolocateControl({ const geolocControl = new mapboxgl.GeolocateControl({
positionOptions: { positionOptions: { enableHighAccuracy: true },
enableHighAccuracy: true
},
trackUserLocation: true trackUserLocation: true
}) })
earth.addControl(geolocControl)
this.earth.addControl(geolocControl) if (!props.defineLocation) return
if (!this.defineLocation) {
return
}
geolocControl.trigger() geolocControl.trigger()
const el = document.createElement('i') const el = document.createElement('i')
el.classList.add('gg-pin-alt', 'marker', 'marker-location') el.classList.add('ti', 'ti-map-pin', 'marker', 'marker-location')
markerLocation = new mapboxgl.Marker(el).setLngLat(earth.getCenter()).addTo(earth)
this.markerLocation = new mapboxgl.Marker(el) earth.on('move', () => {
.setLngLat(this.earth.getCenter()) if (earth && markerLocation) {
.addTo(this.earth) markerLocation.setLngLat(earth.getCenter())
fireLocated()
this.earth.on('move', (evt: any) => { }
this.markerLocation.setLngLat(this.earth.getCenter())
this.emit(this)
}) })
} }
}
onMounted(() => {
initEarthMap()
if (!earth) return
props.locations.forEach((loc) => {
const el = document.createElement('i')
el.classList.add('ti', 'ti-map-pin', 'marker')
new mapboxgl.Marker(el).setLngLat([loc.lon, loc.lat]).addTo(earth!)
})
})
</script> </script>
<style lang="scss"> <template>
@import '../styles/earth-map.css'; <section id="earth" class="earth-container" />
</template>
<style scoped>
.earth-container { .earth-container {
min-height: 50vh; min-height: 50vh;
height: 100%; height: 100%;
} }
.marker-location { :global(.marker-location) {
color: var(--primary-color); color: var(--color-primary);
z-index: 1; z-index: 1;
} }
</style> </style>

View File

@@ -1,133 +1,112 @@
<script setup lang="ts">
import { ref, computed, onMounted, useSlots } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import { throttle } from 'lodash-es'
const props = withDefaults(
defineProps<{
margin?: boolean
to?: RouteLocationRaw | null
buttonStyle?: Record<string, string | number>
}>(),
{ margin: false, to: null, buttonStyle: () => ({}) }
)
const emit = defineEmits<{ valid: [done: () => void] }>()
const slots = useSlots()
const uniqueId = Date.now().toString()
const isLoading = ref(false)
const showFull = ref(true)
const buttonClass = computed(() => ({ 'btn-loading': isLoading.value }))
const valid = () => {
isLoading.value = true
emit('valid', () => {
isLoading.value = false
})
}
onMounted(() => {
if (!slots.fulltext) return
const main = document.getElementById('main')
const fabElm = document.getElementById(`fab-${uniqueId}`)
if (!main || !fabElm) return
document.addEventListener(
'scroll',
throttle(() => {
showFull.value = main.getBoundingClientRect().top > 0
fabElm.classList.toggle('fab-small', !showFull.value)
}, 150)
)
})
</script>
<template> <template>
<div class="fab-button" :id="`fab-${uniqueId}`"> <div :id="`fab-${uniqueId}`" class="fab-button">
<router-link <router-link
v-if="to" v-if="to"
class="button is-primary is-large is-fab" class="btn btn-primary btn-lg btn-circle fab-fixed"
:class="getClass" :class="buttonClass"
:style="buttonStyle" :style="buttonStyle"
:to="to" :to="to"
@click.native="valid" @click="valid"
> >
<span class="icon is-medium"> <span class="fab-icon">
<slot> <slot>
<awe-icon icon="plus" /> <vaquant-icon icon="plus" />
</slot> </slot>
</span> </span>
<span class="fulltext" v-if="$slots.fulltext"> <span v-if="slots.fulltext" class="fab-fulltext">
<slot name="fulltext"></slot> <slot name="fulltext" />
</span> </span>
</router-link> </router-link>
<button <button
v-else v-else
type="submit" type="submit"
class="button is-primary is-large is-fab" class="btn btn-primary btn-lg btn-circle fab-fixed"
:class="getClass" :class="buttonClass"
:style="buttonStyle" :style="buttonStyle"
@click="valid" @click="valid"
> >
<span class="icon is-medium"> <span class="fab-icon">
<slot> <slot>
<awe-icon icon="plus" /> <vaquant-icon icon="plus" />
</slot> </slot>
</span> </span>
<span class="fulltext" v-if="$slots.fulltext"> <span v-if="slots.fulltext" class="fab-fulltext">
<slot name="fulltext"></slot> <slot name="fulltext" />
</span> </span>
</button> </button>
<div class="fab-margin" v-if="margin"></div> <div v-if="margin" class="fab-margin" />
</div> </div>
</template> </template>
<script lang="ts"> <style scoped>
import { Component, Vue, Prop } from 'vue-property-decorator' .fab-fixed {
import { throttle } from 'lodash-es' position: fixed;
bottom: 24px;
@Component right: 24px;
export default class FabButton extends Vue { z-index: 50;
@Prop({ type: Boolean, default: false }) border: 2px solid white;
public margin!: boolean
@Prop({ type: Object, default: () => null })
public to!: any | null
@Prop({ type: Object, required: false, default: () => ({}) })
public buttonStyle!: any
public uniqueId: string = Date.now().toString()
public isLoading: boolean = false
public showFull: boolean = true
public mounted(): void {
if (!this.$slots.fulltext) {
return
} }
const main = document.getElementById('main') .fab-icon {
const fabElm = document.getElementById(`fab-${this.uniqueId}`) font-size: 1.25rem;
if (main && fabElm) {
document.addEventListener(
'scroll',
throttle((evt) => {
this.showFull = main.getBoundingClientRect().top > 0
if (this.showFull) {
fabElm.classList.remove('small')
} else {
fabElm.classList.add('small')
} }
}, 150) .fab-fulltext {
)
}
}
public loading(): void {
this.isLoading = true
}
public valid(): void {
this.isLoading = true
this.$emit('valid', () => {
this.isLoading = false
})
}
public get getClass(): any {
return { 'is-loading': this.isLoading }
}
}
</script>
<style lang="scss" scoped>
@import '../styles/variables';
$padding-fab: 24px;
.fab-button {
.button {
transition: padding-right 0.3s cubic-bezier(0.55, 0, 0.1, 1);
}
.fulltext {
display: inline-block; display: inline-block;
max-width: 450px; max-width: 450px;
margin-left: 0.5rem;
opacity: 1; opacity: 1;
transition: max-width 0.3s cubic-bezier(0.55, 0, 0.1, 1), transition: max-width 0.3s, opacity 0.3s;
opacity 0.3s cubic-bezier(0.55, 0, 0.1, 1);
} }
&.small { .fab-button.fab-small .fab-fulltext {
.button {
padding-right: 0.2em;
transition: padding-right 0.3s cubic-bezier(0.55, 0, 0.1, 1);
}
.fulltext {
max-width: 0; max-width: 0;
opacity: 0; opacity: 0;
transition: max-width 0.3s cubic-bezier(0.55, 0, 0.1, 1), margin-left: 0;
opacity 0.3s cubic-bezier(0.55, 0, 0.1, 1);
} }
}
}
.is-fab {
bottom: $padding-fab;
position: fixed;
right: $padding-fab;
z-index: 5;
border: 2px solid $white;
}
.fab-margin { .fab-margin {
margin: calc(#{$padding-fab} * 2 + 30px) 0 calc(#{$padding-fab} * 2); margin: calc(24px * 2 + 30px) 0 calc(24px * 2);
} }
</style> </style>

View File

@@ -1,12 +0,0 @@
<template>
<div class="hello">
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
@Component
export default class HelloWorld extends Vue {
}
</script>

View File

@@ -1,72 +1,47 @@
<template> <script setup lang="ts">
<div class="lang-changer field is-horizontal"> import { ref, watch } from 'vue'
<div class="field-label is-normal"> import { useI18n } from 'vue-i18n'
<label class="label">Changer de langue</label> import { storeToRefs } from 'pinia'
</div> import { useUserStore } from '@/stores/user'
<div class="field-body">
<div class="field is-narrow">
<div class="control">
<div class="select is-fullwidth">
<select name="lang-changer" v-model="lang">
<option
v-for="(locale, k) in locales"
:key="k"
:value="locale.code"
>{{ locale.label }}</option>
</select>
</div>
</div>
</div>
</div>
</div>
</template>
<script lang="ts"> const userStore = useUserStore()
import { Component, Vue, Watch } from 'vue-property-decorator' const { locale: storedLocale } = storeToRefs(userStore)
import { Action, Getter } from 'vuex-class' const { locale: i18nLocale } = useI18n()
interface ILocale { const lang = ref(storedLocale.value)
code: string
label: string
}
@Component const locales = [
export default class LangChanger extends Vue { { code: 'en', label: 'English' },
@Getter { code: 'fr', label: 'Français' }
public locale!: string
@Action
public setLocale!: any
public lang: string = ''
public locales: ILocale[] = [
{
code: 'en',
label: 'English'
},
{
code: 'fr',
label: 'Français'
}
] ]
public mounted(): void { watch(lang, (value) => {
this.lang = this.locale if (value !== storedLocale.value) {
userStore.setLocale(value)
} }
if (value !== i18nLocale.value) {
i18nLocale.value = value
}
})
@Watch('lang') watch(
public onlangChange(locale: string): void { storedLocale,
if (locale !== this.locale) { (value) => {
this.setLocale(locale) if (value !== lang.value) {
} lang.value = value
if (locale !== this.$i18n.locale) {
this.$i18n.locale = locale
}
}
@Watch('locale', { immediate: true })
public onLocaleChange(locale: string): void {
if (locale !== this.lang) {
this.lang = locale
}
}
} }
},
{ immediate: true }
)
</script> </script>
<template>
<div class="lang-changer flex flex-row items-center gap-3">
<label class="label">Changer de langue</label>
<select v-model="lang" name="lang-changer" class="select select-bordered">
<option v-for="locale in locales" :key="locale.code" :value="locale.code">
{{ locale.label }}
</option>
</select>
</div>
</template>

View File

@@ -1,31 +1,30 @@
<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount } from 'vue'
const emit = defineEmits<{ online: []; offline: [] }>()
const online = ref(navigator.onLine)
const onchange = () => {
online.value = navigator.onLine
if (online.value) emit('online')
else emit('offline')
}
onMounted(() => {
window.addEventListener('online', onchange)
window.addEventListener('offline', onchange)
onchange()
})
onBeforeUnmount(() => {
window.removeEventListener('online', onchange)
window.removeEventListener('offline', onchange)
})
</script>
<template> <template>
<div class="online-view"> <div class="online-view">
<slot v-if="online"></slot> <slot v-if="online" />
<slot v-else name="offline"></slot> <slot v-else name="offline" />
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
@Component
export default class OnlineView extends Vue {
public online: boolean = navigator.onLine
public mounted(): void {
window.addEventListener('online', this.onchange)
window.addEventListener('offline', this.onchange)
this.onchange()
}
public beforeDestroy(): void {
window.removeEventListener('online', this.onchange)
window.removeEventListener('offline', this.onchange)
}
public onchange(): void {
this.online = navigator.onLine
this.$emit(this.online ? 'online' : 'offline')
}
}
</script>

View File

@@ -1,64 +0,0 @@
<template>
<div class="payment">
<stripe-checkout
ref="checkoutRef"
:pk="publishableKey"
:items="items"
:successUrl="successUrl"
:cancelUrl="cancelUrl"
:clientReferenceId="clientReferenceId"
:customerEmail="email"
>
<template slot="checkout-button">
<button class="button is-primary" @click="pay">
Payer par carte bleue
</button>
</template>
</stripe-checkout>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Action, Getter } from 'vuex-class'
import { StripeCheckout } from 'vue-stripe-checkout'
import IUser from '@/models/IUser'
import IPaymentIntent from '@/models/IPaymentIntent'
import IPayment from '@/models/IPayment'
@Component({
components: {
StripeCheckout
}
})
export default class Payment extends Vue {
@Getter
public user!: IUser | null
private publishableKey = 'pk_test_CO1FMasxNSwIX0P9FgzmDMyp'
private successUrl = window.location.href
private cancelUrl = window.location.href
private items = [
{
plan: 'plan_GmrqYW6obrGUE6',
quantity: 1
}
]
private checkoutRef: any = null
public async mounted() {
this.checkoutRef = this.$refs.checkoutRef
}
private async pay() {
this.checkoutRef.redirectToCheckout()
}
private get clientReferenceId() {
return this.user?.userId ?? ''
}
private get email() {
return this.user?.email ?? ''
}
}
</script>

View File

@@ -1,77 +0,0 @@
<template>
<div class="pricing-table">
<div class="pricing-plan" :class="{ 'is-active': plan === 'free' }">
<div class="plan-header">Gratuit</div>
Parfait pour démarrer rapidement
<div class="plan-price">
<span class="plan-price-amount"
><span class="plan-price-currency"></span>0</span
>/mois
</div>
<div class="plan-items">
<div class="plan-item">jusqu'à 20 comptes</div>
<div class="plan-item">jusqu'à 20 amis/compte</div>
<div class="plan-item">jusqu'à 200 dépenses/compte</div>
<div class="plan-item">33 devises</div>
</div>
<div class="plan-footer">
<button class="button is-fullwidth" @click="choose('free')">
<span v-if="plan !== 'free'">Choix par défaut</span>
<span v-else>Sélectionné</span>
</button>
</div>
</div>
<div
class="pricing-plan is-success"
:class="{ 'is-active': plan === 'premium' }"
>
<div class="plan-header">Premium</div>
Vaquant sans aucune limite !
<div class="plan-price">
<span class="plan-price-amount">
<span class="plan-price-currency">€</span>2</span
>/mois
</div>
<div class="plan-items">
<div class="plan-item"><awe-icon icon="infinity" /> comptes</div>
<div class="plan-item"><awe-icon icon="infinity" /> amis/compte</div>
<div class="plan-item">
<awe-icon icon="infinity" /> dépenses/compte
</div>
<div class="plan-item">pièces jointes disponibles</div>
</div>
<div class="plan-footer">
<button class="button is-fullwidth" @click="choose('premium')">
Bientôt disponible
<!-- <span v-if="plan !== 'premium'">Passer en premium</span>
<span v-else>Sélectionné</span> -->
</button>
</div>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
@Component
export default class PrincingTable extends Vue {
@Prop({ type: String, default: '' })
public firstPlan!: string
public plan: string = ''
public mounted(): void {
this.plan = this.firstPlan
}
public choose(plan: string): void {
this.plan = plan
this.$emit(plan)
}
}
</script>
<style lang="scss">
@import '../styles/variables';
@import '~bulma-pricingtable';
</style>

View File

@@ -1,40 +1,39 @@
<template> <script setup lang="ts">
<a href="#" class="queue-notif" v-if="newVersion" @click.prevent="reloadApp"> import { ref, onMounted, onBeforeUnmount } from 'vue'
nouvelle version disponible
</a>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
import notif from '@/utils/notif' import notif from '@/utils/notif'
@Component const newVersion = ref(false)
export default class QueueNotif extends Vue {
private newVersion = queueNotifService.getNewVersion
private mounted() { onMounted(() => {
queueNotifService.subscribe((notification) => { queueNotifService.subscribe((notification) => {
switch (notification.type) { if (notification.type === 'success') {
case 'success':
notif.success(notification.message) notif.success(notification.message)
break } else if (notification.type === 'error') {
case 'error':
notif.error(notification.message) notif.error(notification.message)
break
} }
}) })
queueNotifService.subscribeToNewVersion((newVersion: boolean) => { queueNotifService.subscribeToNewVersion((hasNew: boolean) => {
this.newVersion = newVersion newVersion.value = hasNew
})
}) })
}
private beforeDestroy() { onBeforeUnmount(() => {
queueNotifService.unsubscribe() queueNotifService.unsubscribe()
} })
private reloadApp() { const reloadApp = () => {
location.reload(true) location.reload()
}
} }
</script> </script>
<template>
<a
v-if="newVersion"
href="#"
class="queue-notif alert alert-info"
@click.prevent="reloadApp"
>
nouvelle version disponible
</a>
</template>

View File

@@ -1,165 +1,95 @@
<template> <script setup lang="ts">
<div class="refund-transaction card"> import { ref, computed } from 'vue'
<div class="card-content"> import { storeToRefs } from 'pinia'
<div class="content" v-if="refunded">Somme remboursée...</div>
<div class="content" v-else>
<span v-if="toMe">
<span class="people">{{ refund.from.alias }}</span>
vous doit
<span class="numeric">{{
refund.amount | money(account.mainCurrency)
}}</span>
</span>
<span v-else-if="fromMe">
Vous devez
<span class="numeric">{{
refund.amount | money(account.mainCurrency)
}}</span>
à
<span class="people">{{ refund.to.alias }}</span>
</span>
<span v-else>
<span class="people">{{ refund.from.alias }}</span>
doit
<span class="numeric">{{
refund.amount | money(account.mainCurrency)
}}</span>
à
<span class="people">{{ refund.to.alias }}</span>
</span>
</div>
</div>
<footer class="card-footer" v-if="canRefund && !refunded">
<confirm-button class="is-text is-fullwidth" @confirm="refundTo"
>Rembourser</confirm-button
>
</footer>
</div>
</template>
<script lang="ts">
import { Component, Vue, Prop } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import bus, { SYNC } from '@/utils/bus-event' import bus, { SYNC } from '@/utils/bus-event'
import ClickOutside from 'vue-click-outside' import { useUserStore } from '@/stores/user'
import transactionService from '@/services/TransactionService' import transactionService from '@/services/TransactionService'
import IAccount from '@/models/IAccount'
import ICurrency from '@/models/ICurrency'
import IRefund from '@/models/IRefund'
import ITransaction from '@/models/ITransaction'
import IUser from '@/models/IUser'
import TransactionTag from '@/enums/TransactionTag'
import TransactionType from '@/enums/TransactionType'
import exchangeService from '@/services/ExchangeService' import exchangeService from '@/services/ExchangeService'
import { confirmation } from '@/utils' import { confirmation } from '@/utils'
import { money } from '@/utils/format'
import type IAccount from '@/models/IAccount'
import type IRefund from '@/models/IRefund'
import type ITransaction from '@/models/ITransaction'
import TransactionTag from '@/enums/TransactionTag'
import TransactionType from '@/enums/TransactionType'
import ConfirmButton from '@/components/ConfirmButton.vue'
@Component({ const props = defineProps<{ refund: IRefund; account: IAccount }>()
directives: {
ClickOutside const userStore = useUserStore()
}, const { user } = storeToRefs(userStore)
components: {
'confirm-button': () => import('@/components/ConfirmButton.vue') const refunded = ref(false)
}
const canRefund = computed(() => {
if (!user.value || !props.account.admin) return true
return [props.account.admin.email, props.refund.from.email].includes(user.value.email)
}) })
export default class RefundTransaction extends Vue {
@Getter
public user!: IUser | null
@Prop({ type: Object, required: true })
public refund!: IRefund
@Prop({ type: Object, required: true })
public account!: IAccount
public firstTap: boolean = true
public refunded: boolean = false
public resetTap(): void {
this.firstTap = true
}
public async refundTo(done: any): Promise<void> { const fromMe = computed(
if (!this.account._id) { () => !!user.value && !!props.refund.from && props.refund.from.userId === user.value.userId
)
const toMe = computed(
() => !!user.value && !!props.refund.to && props.refund.to.userId === user.value.userId
)
const refundTo = async (done: () => void) => {
if (!props.account._id) {
done() done()
return return
} }
const date: Date = new Date() const date = new Date()
const currencies: string[] = this.account.currencies.map( const currencies = props.account.currencies.map((c) => c.code)
(c: ICurrency) => c.code
)
const transaction: ITransaction = { const transaction: ITransaction = {
accountId: this.account._id, accountId: props.account._id,
doctype: 'transaction', doctype: 'transaction',
name: 'Remboursement', name: 'Remboursement',
date, date,
tag: TransactionTag.None, tag: TransactionTag.None,
amount: this.refund.amount || 0, amount: props.refund.amount || 0,
payBy: this.refund.from.alias || '', payBy: props.refund.from.alias || '',
payFor: [ payFor: [{ alias: props.refund.to.alias || '', weight: 1 }],
{ userIds: props.account.userIds,
alias: this.refund.to.alias || '', mainCurrency: props.account.mainCurrency,
weight: 1 currencies: props.account.currencies,
}
],
userIds: this.account.userIds,
mainCurrency: this.account.mainCurrency,
currencies: this.account.currencies,
transactionType: TransactionType.refund, transactionType: TransactionType.refund,
exchange: await exchangeService.get( exchange: await exchangeService.get(props.account.mainCurrency.code, date, currencies)
this.account.mainCurrency.code,
date,
currencies
)
} }
const response: PouchDB.Core.Response = await transactionService.add( const response = await transactionService.add(props.account._id, transaction)
this.account._id,
transaction
)
if (response.ok) { if (response.ok) {
this.refunded = true refunded.value = true
confirmation('Remboursement effectué avec succès') confirmation('Remboursement effectué avec succès')
} }
bus.$emit(SYNC) bus.emit(SYNC)
done() done()
} }
public get canRefund(): boolean {
if (!this.user || !this.account.admin) {
return true
}
const emails: string[] = [this.account.admin.email, this.refund.from.email]
return emails.includes(this.user.email)
}
public get fromMe(): boolean {
if (!this.user || !this.refund.from) {
return false
}
return this.refund.from.userId === this.user.userId
}
public get toMe(): boolean {
if (!this.user || !this.refund.to) {
return false
}
return this.refund.to.userId === this.user.userId
}
}
</script> </script>
<style lang="scss" scoped> <template>
.people { <div class="refund-transaction card bg-base-100 shadow my-2">
font-size: 12pt; <div class="card-body">
font-weight: bold; <div v-if="refunded">Somme remboursée...</div>
&.to-me { <div v-else>
font-style: italic; <span v-if="toMe">
&:after { <span class="font-bold">{{ refund.from.alias }}</span> vous doit
font-style: normal; <span class="numeric">{{ money(refund.amount, account.mainCurrency) }}</span>
content: ' (🤗)'; </span>
} <span v-else-if="fromMe">
} Vous devez
&.from-me { <span class="numeric">{{ money(refund.amount, account.mainCurrency) }}</span> à
font-style: italic; <span class="font-bold">{{ refund.to.alias }}</span>
&:after { </span>
font-style: normal; <span v-else>
content: ' (🤨)'; <span class="font-bold">{{ refund.from.alias }}</span> doit
} <span class="numeric">{{ money(refund.amount, account.mainCurrency) }}</span> à
} <span class="font-bold">{{ refund.to.alias }}</span>
} </span>
</style> </div>
</div>
<div v-if="canRefund && !refunded" class="card-actions justify-end p-2 border-t">
<ConfirmButton class="btn-ghost btn-block" @confirm="refundTo">
Rembourser
</ConfirmButton>
</div>
</div>
</template>

View File

@@ -1,70 +1,122 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import colors from '@/data/colors'
import type IAccount from '@/models/IAccount'
import type ITransaction from '@/models/ITransaction'
import type ISlice from '@/models/ISlice'
import TransactionTag, { type ITagLabel, TransactionTagLabel } from '@/enums/TransactionTag'
import ChartProgress from '@/components/Charts/ChartProgress.vue'
import AccountTransactionList from '@/components/AccountTransactionList.vue'
interface ICategory {
[key: string]: { tag: ITagLabel; transactions: ITransaction[] }
}
const props = withDefaults(
defineProps<{ account: IAccount; transactions?: ITransaction[] }>(),
{ transactions: () => [] }
)
const { t } = useI18n()
const filterCategory = ref('')
const categories = computed<ICategory>(() =>
props.transactions.reduce<ICategory>((acc, t) => {
if (!acc[t.tag]) {
acc[t.tag] = {
tag: TransactionTagLabel[t.tag] ?? TransactionTagLabel[TransactionTag.None],
transactions: []
}
}
acc[t.tag].transactions.push(t)
return acc
}, {})
)
const filteredCategories = computed<ICategory>(() => {
if (filterCategory.value && filterCategory.value in categories.value) {
return { [filterCategory.value]: categories.value[filterCategory.value] }
}
return categories.value
})
const slices = computed<ISlice[]>(() => {
const tags = Object.keys(categories.value)
const total = props.transactions.reduce((t, b) => t + parseFloat((b.amount || 0).toString()), 0)
return tags
.map((tag, index) => ({
percent:
categories.value[tag].transactions.reduce((t, b) => t + b.amount, 0) /
(total || 1),
color: colors[index]?.value || '#ffffff',
darkColor: colors[index]?.darkValue || '#ffffff',
key: tag,
label: categories.value[tag].tag.label
}))
.sort((a, b) => (a.percent >= b.percent ? -1 : 1))
})
const legendStyle = (slice: ISlice) => ({ color: slice.color })
</script>
<template> <template>
<div class="tag-list no-margin"> <div class="tag-list">
<div class="columns no-margin is-vcentered is-centered is-multiline"> <div class="grid md:grid-cols-3 gap-4 items-center">
<div class="column is-one-third"> <div>
<chart-progress :slices="slices" /> <ChartProgress :slices="slices" />
</div> </div>
<div class="column legend-container is-12"> <div class="md:col-span-2 flex justify-center">
<table class="table is-fullwidth"> <table class="table max-w-md">
<tbody>
<tr <tr
v-for="(slice, k) in slices" v-for="(slice, k) in slices"
:key="k" :key="k"
:style="legendStyle(slice)" :style="legendStyle(slice)"
class="legend-item"
> >
<td> <td>
<awe-icon :icon="categories[slice.key || ''].tag.icon" /> <vaquant-icon :icon="TransactionTagLabel[slice.key as TransactionTag]?.icon || 'tag'" />
</td> </td>
<td class="legend-label">{{ slice.label }}</td> <td class="text-center">{{ slice.label }}</td>
<td class="percent-label numeric"> <td class="numeric text-right">
{{ Math.round(slice.percent * 100) }}% {{ Math.round(slice.percent * 100) }}%
</td> </td>
</tr> </tr>
</tbody>
</table> </table>
</div> </div>
</div> </div>
<div> <div class="mt-6">
<div class="field has-addons has-addons-centered is-narrow"> <div class="join justify-center mb-4 mx-auto flex">
<div class="control" v-if="filterCategory"> <button
<button type="submit" class="button" @click="filterCategory = ''"> v-if="filterCategory"
<awe-icon icon="times" /> type="submit"
class="btn join-item"
@click="filterCategory = ''"
>
<vaquant-icon icon="x" />
</button> </button>
</div> <select v-model="filterCategory" class="select select-bordered join-item">
<div class="control"> <option value="">Toutes</option>
<div class="select is-fullwidth">
<select v-model="filterCategory">
<option value>Toutes</option>
<option v-for="(category, k) in categories" :key="k" :value="k"> <option v-for="(category, k) in categories" :key="k" :value="k">
{{ category.tag.label }} {{ category.tag.label }}
</option> </option>
</select> </select>
</div> </div>
</div> <div class="grid md:grid-cols-2 gap-6">
</div>
<div class="columns clear-margin is-centered is-multiline">
<div <div
v-for="(category, k) in filteredCategories" v-for="(category, k) in filteredCategories"
:key="k" :key="k"
class="category-list-container column is-half no-padding" class="category-list flex gap-4 border-b border-base-300 pb-4"
> >
<div class="columns clear-margin category-list"> <div class="text-4xl flex items-start sticky top-16">
<div class="column is-2 tag-element account-color"> <vaquant-icon :icon="category.tag.icon" />
<div class="icon-container">
<awe-icon :icon="category.tag.icon" />
</div> </div>
</div> <div v-if="category.transactions.length" class="flex-1">
<div <p class="mb-2 opacity-70">
class="column transaction-list no-padding" {{ t('account.total', { count: category.transactions.length }, category.transactions.length) }}
v-if="category.transactions.length"
>
<p>
{{
$tc('account.total', category.transactions.length, {
count: category.transactions.length
})
}}
</p> </p>
<account-transaction-list <AccountTransactionList
:filter="false" :filter="false"
:head="false" :head="false"
:foot="true" :foot="true"
@@ -76,135 +128,4 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</template> </template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import colors from '@/data/colors'
import IAccount from '@/models/IAccount'
import ITransaction from '@/models/ITransaction'
import ISlice from '@/models/ISlice'
import TransactionTag, {
ITagLabel,
TransactionTagLabel
} from '@/enums/TransactionTag'
interface ICategory {
[key: string]: {
tag: ITagLabel
transactions: ITransaction[]
}
}
@Component({
components: {
'chart-progress': () => import('@/components/Charts/ChartProgress.vue'),
'account-transaction-list': () =>
import('@/components/AccountTransactionList.vue')
}
})
export default class TagList extends Vue {
@Prop({ type: Object, required: true })
public account!: IAccount
@Prop({ type: Array, default: () => [] })
public transactions!: ITransaction[]
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
public filterCategory: string = ''
public legendStyle(slice: ISlice): any {
return {
color: slice.color
}
}
public get categories(): ICategory {
return this.transactions.reduce(
(a: ICategory, transaction: ITransaction) => {
if (!a[transaction.tag]) {
a[transaction.tag] = {
tag: this.transactionTagLabel[transaction.tag]
? this.transactionTagLabel[transaction.tag]
: this.transactionTagLabel[TransactionTag.None],
transactions: []
}
}
a[transaction.tag].transactions.push(transaction)
return a
},
{}
)
}
public get filteredCategories(): ICategory {
if (this.filterCategory && this.filterCategory in this.categories) {
return {
[this.filterCategory]: this.categories[this.filterCategory]
}
}
return this.categories
}
public get slices(): ISlice[] {
const tags = Object.keys(this.categories)
const total = this.transactions.reduce(
(t: number, b: ITransaction) =>
t + parseFloat((b.amount || 0).toString()),
0
)
const slices = tags.map((tag: string, index: number) => ({
percent:
this.categories[tag].transactions.reduce(
(t: number, b: ITransaction) => t + b.amount,
0
) / total,
color: colors[index].value || '#ffffff',
darkColor: colors[index].darkValue || '#ffffff',
key: tag,
label: this.categories[tag].tag.label
}))
return slices.sort((a: ISlice, b) => (a.percent >= b.percent ? -1 : 1))
}
}
</script>
<style lang="scss" scoped>
@import '../styles/variables';
.category-list:not(:last-child) {
border-bottom: 1px solid $main;
}
.tag-element {
display: flex;
text-align: center;
align-items: center;
flex-flow: column;
font-size: 30pt;
}
.icon-container {
position: sticky;
top: calc(3.25rem + 15px);
}
.legend-container {
display: flex;
table {
max-width: 400px;
}
}
.legend {
font-weight: bold;
font-size: 16pt;
}
.legend-item {
background-color: $main;
font-size: 20px;
line-height: 2em;
}
.legend-label {
text-align: center;
}
.percent-label {
text-align: right;
}
</style>

View File

@@ -1,6 +1,184 @@
<script setup lang="ts">
import { ref, reactive, computed, watch, onBeforeMount } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { format } from 'date-fns'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import type IAccount from '@/models/IAccount'
import type ICurrency from '@/models/ICurrency'
import type ITransaction from '@/models/ITransaction'
import type ISplit from '@/models/ISplit'
import type ILocation from '@/models/ILocation'
import TransactionType from '@/enums/TransactionType'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
import exchangeService from '@/services/ExchangeService'
import transactionService from '@/services/TransactionService'
import mapService from '@/services/MapService'
import { money } from '@/utils/format'
import { findContrastColor } from '@/utils'
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 TransactionSplit from '@/components/TransactionSplit.vue'
import TransactionTagUpdate from '@/components/TransactionTagUpdate.vue'
import EarthMap from '@/components/EarthMap.vue'
import CurrencyInput from '@/components/CurrencyInput.vue'
const props = defineProps<{
transaction: ITransaction
account: IAccount
}>()
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
const { locale } = storeToRefs(userStore)
const formatToDate = (d: Date | string) => format(new Date(d), 'yyyy-MM-dd')
const todayDate = new Date()
const name = ref('')
const amount = ref<number | null>(null)
const tag = ref<TransactionTag>(TransactionTag.None)
const today = formatToDate(todayDate)
const date = ref(today)
const currency = ref<ICurrency | null>(null)
const payBy = ref('')
const payFor = reactive<ISplit[]>([])
const maxAmount = MAX_TRANSACTION_AMOUNT
const displayLocationModal = ref(false)
const isSetLocation = ref(false)
const location = ref<ILocation | null>(null)
const noTag = TransactionTag.None
onBeforeMount(() => {
setData(props.transaction)
})
const setData = (t: ITransaction) => {
name.value = t.name
date.value = formatToDate(t.date)
tag.value = t.tag
amount.value = t.amount || null
payBy.value = t.payBy
payFor.splice(0, payFor.length, ...t.payFor)
if (props.account.users) {
props.account.users.forEach((u) => {
if (!t.payFor.find((p) => p.alias === u.alias)) {
payFor.push({ alias: u.alias || '', weight: 0 })
}
})
}
currency.value = t.mainCurrency
}
const payForUsers = computed(() => payFor.filter((p) => p.weight > 0))
const backgroundColor = computed(() => {
if (!props.account || !props.account.color) return undefined
return {
backgroundColor: props.account.color,
color: findContrastColor(props.account.color) || 'black'
}
})
watch(payBy, (newValue, oldValue) => {
if (oldValue && payFor.length !== props.account.users.length) {
const old = payFor.find((p) => p.alias === oldValue)
if (old) old.weight = 0
}
if (newValue) {
const newPayBy = payFor.find((p) => p.alias === newValue)
if (newPayBy) newPayBy.weight = newPayBy.weight || 1
}
})
const selectAll = () => {
payFor.forEach((p) => (p.weight = p.weight || 1))
}
const located = (loc: ILocation) => {
location.value = loc
}
const validLocation = async () => {
try {
isSetLocation.value = true
if (location.value) {
const place = await mapService.getPlace(location.value)
props.transaction.location = { ...location.value, place }
}
} catch {
props.transaction.location = props.transaction.location
} finally {
displayLocationModal.value = false
isSetLocation.value = false
}
}
const validate = (): boolean => {
const el = document.querySelector(':focus') as HTMLInputElement | null
el?.blur()
return validateAndNotify(
transactionFormSchema,
{ name: name.value, amount: amount.value, payBy: payBy.value },
t,
{ amount: { max: money(maxAmount, currency.value) } }
)
}
const submitTransaction = async (finish: () => void) => {
if (!validate() || !currency.value || !props.account) {
finish()
return
}
const currencies = props.account.currencies.map((c) => c.code)
const d = new Date(date.value)
d.setHours(todayDate.getHours(), todayDate.getMinutes())
const hasId = !!props.transaction._id
const numericAmount = amount.value ? parseFloat(amount.value.toString().replace(',', '.')) : 0
const transaction: ITransaction = {
_id: props.transaction._id,
_rev: props.transaction._rev,
doctype: props.transaction.doctype,
accountId: props.account._id || '',
name: name.value,
date: d,
tag: tag.value,
amount: numericAmount,
payBy: payBy.value,
payFor: payForUsers.value,
userIds: props.account.userIds,
mainCurrency: currency.value,
currencies: props.account.currencies,
transactionType: TransactionType.normal,
location: props.transaction.location,
exchange: await exchangeService.get(currency.value.code, d, currencies)
}
const response = hasId
? await transactionService.save(transaction)
: await transactionService.add(props.account._id || '', transaction)
if (response.ok) {
if (hasId) {
router.push({ name: 'transaction', params: { id: props.transaction._id || '' } })
notif.success('Dépense mise à jour.')
} else {
router.push({ name: 'account', params: { id: props.account._id || '' } })
notif.success('Dépense créée.')
}
} else {
console.warn(response)
notif.error(`Une erreur s'est produite à la création d'une transaction.`)
}
finish()
}
</script>
<template> <template>
<div class="transaction-create"> <div class="transaction-create">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="account"> <nav v-if="account" class="breadcrumbs text-sm mb-3" aria-label="breadcrumbs">
<ul> <ul>
<li> <li>
<router-link :to="{ name: 'account', params: { id: account._id } }"> <router-link :to="{ name: 'account', params: { id: account._id } }">
@@ -8,9 +186,9 @@
</router-link> </router-link>
</li> </li>
<li v-if="tag && tag !== noTag"> <li v-if="tag && tag !== noTag">
<a href="#" @click.prevent>{{ transactionTagLabel[tag].label }}</a> <a href="#" @click.prevent>{{ TransactionTagLabel[tag].label }}</a>
</li> </li>
<li class="is-active"> <li>
<a href="#" @click.prevent> <a href="#" @click.prevent>
<span v-if="transaction._id">{{ name }}</span> <span v-if="transaction._id">{{ name }}</span>
<span v-else>Nouvelle dépense</span> <span v-else>Nouvelle dépense</span>
@@ -18,476 +196,136 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="columns"> <div class="grid md:grid-cols-2 gap-6">
<div class="column"> <div class="space-y-4">
<div class="field is-horizontal"> <div class="flex items-center gap-3">
<div class="field-label is-normal"> <label class="label w-24">Nom</label>
<label class="label">Nom</label> <div class="flex-1">
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input <input
type="text"
class="input"
v-model.trim="name" v-model.trim="name"
type="text"
maxlength="30" maxlength="30"
placeholder="Nom de la dépense" placeholder="Nom de la dépense"
class="input input-bordered w-full"
/> />
<p class="help is-info"> <p class="text-xs text-info mt-1">
{{ {{ t('validation.max_char', { max: 30 - name.length }, 30 - name.length) }}
$tc('validation.max_char', 30 - name.length, {
max: 30 - name.length
})
}}
</p> </p>
</div> </div>
</div> </div>
</div>
</div> <div class="flex items-center gap-3">
<div class="field is-horizontal"> <label class="label w-24">Montant</label>
<div class="field-label is-normal"> <div class="flex-1">
<label class="label">Montant</label> <div class="join w-full">
</div> <CurrencyInput
<div class="field-body">
<div class="field">
<div class="field has-addons">
<p class="control is-expanded">
<currency-input
v-model="amount" v-model="amount"
placeholder="Montant"
class="input"
:currency="null"
:locale="locale" :locale="locale"
:precision="2" :precision="2"
:allow-negative="false" :allow-negative="false"
:value-range="{ min: 0, max: 1000000 }" :value-range="{ min: 0, max: 1000000 }"
placeholder="Montant"
class="join-item flex-1"
/> />
</p> <span v-if="account.currencies.length > 1" class="join-item">
<p class="control"> <select v-model="currency" name="currency" class="select select-bordered">
<span v-if="account.currencies.length > 1" class="select"> <option v-for="(c, k) in account.currencies" :key="k" :value="c">
<select name="currency" id="currency" v-model="currency"> {{ c.symbol || c.name }}
<option
v-for="(currency, k) in account.currencies"
:key="k"
:value="currency"
>
{{ currency.symbol || currency.name }}
</option> </option>
</select> </select>
</span> </span>
<a v-else class="button is-static"> <span v-else class="btn btn-disabled join-item">
{{ {{ account.mainCurrency.symbol || account.mainCurrency.name }}
account.mainCurrency.symbol || account.mainCurrency.name </span>
}}
</a>
</p>
</div> </div>
<p class="help is-info"> <p class="text-xs text-info mt-1">
montant max : {{ maxAmount | money(currency) }} montant max : {{ money(maxAmount, currency) }}
</p> </p>
</div> </div>
</div> </div>
</div>
<div class="field is-horizontal"> <div class="flex items-center gap-3">
<div class="field-label is-normal"> <label class="label w-24">Localisation</label>
<label class="label">Localisation</label> <div class="flex-1 flex gap-2">
</div>
<div class="field-body">
<div
class="field"
:class="{ 'has-addons': !!transaction.location }"
>
<div class="control set-location-input">
<input <input
v-if="transaction.location"
:value="transaction.location.place"
type="text" type="text"
readonly readonly
class="input" class="input input-bordered flex-1"
v-if="transaction.location"
v-model="transaction.location.place"
/> />
</div> <button class="btn btn-primary" @click="displayLocationModal = true">définir</button>
<div class="control set-location">
<button
class="button is-primary"
@click="displayLocationModal = true"
>
définir
</button>
</div> </div>
</div> </div>
</div>
</div> <div class="flex items-center gap-3">
<div class="field is-horizontal"> <label class="label w-24">Date</label>
<div class="field-label is-normal">
<label class="label">Date</label>
</div>
<div class="field-body">
<div class="field">
<div class="control">
<input <input
type="date"
class="input"
v-model="date" v-model="date"
type="date"
class="input input-bordered"
placeholder="Date" placeholder="Date"
:max="today" :max="today"
/> />
</div> </div>
<TransactionTagUpdate v-model="tag" />
</div> </div>
</div>
</div> <div v-if="account.users.length > 1" class="space-y-4">
<transaction-tag-update v-model="tag" /> <div class="flex items-center gap-3">
</div> <label class="label w-24">Payé par</label>
<div class="column" v-if="account.users.length > 1"> <select v-model="payBy" name="pay-by" class="select select-bordered flex-1">
<div class="field is-horizontal"> <option v-for="(u, k) in account.users" :key="k" :value="u.alias">
<div class="field-label is-normal"> {{ u.alias }}
<label class="label">Payé par</label>
</div>
<div class="field-body">
<div class="field is-narrow">
<div class="control">
<div class="select is-fullwidth">
<select name="pay-by" id="pay-by" v-model="payBy">
<option
v-for="(user, k) in account.users"
:key="k"
:value="user.alias"
>
{{ user.alias }}
</option> </option>
</select> </select>
</div> </div>
</div> <div>
</div> <div class="flex items-center gap-3 mb-2">
</div> <label class="label w-24">Pour</label>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">Pour</label>
<button <button
class="button is-primary is-small"
:class="{
'is-outlined': payForUsers.length !== account.users.length
}"
type="button" type="button"
class="btn btn-primary btn-sm"
:class="{ 'btn-outline': payForUsers.length !== account.users.length }"
@click="selectAll" @click="selectAll"
> >
tous tous
</button> </button>
</div> </div>
<div class="field-body"> <div class="space-y-2">
<div class="field is-narrow">
<div class="control">
<div v-for="(split, k) in payFor" :key="k" class="user-item"> <div v-for="(split, k) in payFor" :key="k" class="user-item">
<transaction-split :split="payFor[k]" /> <TransactionSplit :split="payFor[k]" />
<hr v-if="k !== payFor.length - 1" /> <hr v-if="k !== payFor.length - 1" />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
</div> <dialog class="modal" :class="{ 'modal-open': displayLocationModal }">
<div class="modal" :class="{ 'is-active': displayLocationModal }"> <div class="modal-box max-w-3xl">
<div class="modal-background"></div> <EarthMap
<div class="modal-card">
<section>
<earth-map
v-if="displayLocationModal" v-if="displayLocationModal"
:locations=" :locations="transaction.location ? [transaction.location] : undefined"
transaction.location ? [transaction.location] : undefined
"
:define-location="true" :define-location="true"
@located="located" @located="located"
/> />
</section> <div class="modal-action">
<footer class="modal-card-foot">
<button <button
class="button is-primary" class="btn btn-primary"
:class="{ 'is-loading': isSetLocation }" :class="{ 'btn-loading': isSetLocation }"
@click="validLocation" @click="validLocation"
> >
valider valider
</button> </button>
<button class="button" @click="displayLocationModal = false"> <button class="btn" @click="displayLocationModal = false">annuler</button>
annuler
</button>
</footer>
</div> </div>
</div> </div>
<fab-button </dialog>
@valid="submitTransaction"
:margin="true" <FabButton :margin="true" :button-style="backgroundColor" @valid="submitTransaction">
:button-style="backgroundColor" <vaquant-icon icon="check" />
> </FabButton>
<awe-icon icon="check" />
</fab-button>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import { CurrencyInput } from 'vue-currency-input'
import IAccount from '@/models/IAccount'
import ICurrency from '@/models/ICurrency'
import ITransaction from '@/models/ITransaction'
import IUser from '@/models/IUser'
import ISplit from '@/models/ISplit'
import TransactionType from '@/enums/TransactionType'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
import accountService from '@/services/AccountService'
import exchangeService from '@/services/ExchangeService'
import transactionService from '@/services/TransactionService'
import queueNotifService from '@/services/QueueNotifService'
import { money } from '@/utils/filters'
import { findContrastColor } from '@/utils'
import ILocation from '@/models/ILocation'
import notif from '@/utils/notif'
import MapService from '../services/MapService'
import { format } from 'date-fns'
const formatToDate = (date: Date) => format(date, 'yyyy-MM-dd')
const today: Date = new Date()
@Component({
components: {
'currency-input': CurrencyInput,
'earth-map': () => import('@/components/EarthMap.vue'),
'fab-button': () => import('@/components/FabButton.vue'),
'transaction-split': () => import('@/components/TransactionSplit.vue'),
'transaction-tag-update': () =>
import('@/components/TransactionTagUpdate.vue')
}
})
export default class TransactionCreate extends Vue {
@Getter
public locale!: string
@Getter
public user!: IUser | null
@Prop({ type: Object, required: true })
public transaction!: ITransaction
@Prop({ type: Object, required: true })
public account!: IAccount
public name: string = ''
public amount: number | null = null
public tag: TransactionTag = TransactionTag.None
public today: string = formatToDate(today)
public date: string = formatToDate(today)
public currency: ICurrency | null = null
public payBy: string = ''
public payFor: ISplit[] = []
public maxAmount: number = 1000000
public noTag: string = TransactionTag.None
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
public displayLocationModal = false
public isSetLocation = false
public location: ILocation | null = null
public async created(): Promise<void> {
this.setData(this.transaction)
}
public setData(transaction: ITransaction): void {
this.name = transaction.name
this.date = formatToDate(transaction.date)
this.tag = transaction.tag
this.amount = transaction.amount || null
this.payBy = transaction.payBy
this.payFor = transaction.payFor
if (this.account.users) {
this.account.users.forEach((user: IUser) => {
if (!transaction.payFor.find((p: ISplit) => p.alias === user.alias)) {
this.payFor.push({
alias: user.alias || '',
weight: 0
})
}
})
}
this.currency = transaction.mainCurrency
}
public async submitTransaction(finish: () => void): Promise<void> {
if (!this.validate()) {
finish()
return
}
if (!this.currency || !this.account) {
finish()
return
}
const currencies: string[] = this.account.currencies.map(
(c: ICurrency) => c.code
)
const date: Date = new Date(this.date)
date.setHours(today.getHours(), today.getMinutes())
const hasId: boolean = !!this.transaction._id
const amount: number = this.amount
? parseFloat(this.amount.toString().replace(',', '.'))
: 0
const transaction: ITransaction = {
_id: this.transaction._id,
_rev: this.transaction._rev,
doctype: this.transaction.doctype,
accountId: this.account._id || '',
name: this.name,
date,
tag: this.tag,
amount,
payBy: this.payBy,
payFor: this.payForUsers,
userIds: this.account.userIds,
mainCurrency: this.currency,
currencies: this.account.currencies,
transactionType: TransactionType.normal,
location: this.transaction.location,
exchange: await exchangeService.get(this.currency.code, date, currencies)
}
const response: PouchDB.Core.Response = hasId
? await transactionService.save(transaction)
: await transactionService.add(this.account._id || '', transaction)
if (response.ok) {
if (hasId) {
this.$router.push({
name: 'transaction',
params: { id: this.transaction._id || '' }
})
notif.success('Dépense mise à jour.')
} else {
this.$router.push({
name: 'account',
params: { id: this.account._id || '' }
})
notif.success('Dépense créée.')
}
} else {
// tslint:disable-next-line:no-console
console.warn(response)
notif.error(`Une erreur s'est produite à la création d'une transaction.`)
}
finish()
}
public selectAll(): void {
if (!this.account) {
return
}
this.payFor.forEach((p: ISplit) => (p.weight = p.weight || 1))
}
public located(location: ILocation) {
this.location = location
}
public async validLocation() {
try {
this.isSetLocation = true
if (this.location) {
const place = await MapService.getPlace(this.location)
const location = {
...this.location,
place
}
this.$set<ILocation>(this.transaction, 'location', location)
}
} catch (error) {
this.$set(this.transaction, 'location', this.transaction.location)
} finally {
this.displayLocationModal = false
this.isSetLocation = false
}
}
public get payForUsers(): ISplit[] {
return this.payFor.filter((p, index) => p.weight > 0)
}
public get backgroundColor(): any | null {
if (!this.account || !this.account.color) {
return null
}
return {
backgroundColor: this.account.color,
color: findContrastColor(this.account.color) || 'black'
}
}
@Watch('payBy')
public onPayByChange(payBy: string, oldPayBy: string) {
if (
oldPayBy &&
this.account &&
this.payFor.length !== this.account.users.length
) {
const old = this.payFor.find((p: ISplit) => p.alias === oldPayBy)
if (old) {
old.weight = 0
}
}
if (payBy) {
const newPayBy = this.payFor.find((p: ISplit) => p.alias === payBy)
if (newPayBy) {
newPayBy.weight = newPayBy.weight || 1
}
}
}
private validate(): boolean {
const activeElement = document.activeElement
if (activeElement) {
const input = activeElement as HTMLInputElement
if (input.blur) {
input.blur()
}
}
const el = document.querySelector(':focus')
if (el) {
const input = el as HTMLInputElement
if (input.blur) {
input.blur()
}
}
if (
!this.name ||
!this.amount ||
!this.payBy ||
(!this.currency && !this.date && this.payFor.length === 0)
) {
if (this.amount === 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
} else {
queueNotifService.error('Tous les champs sont requis.')
}
return false
}
if (isNaN(this.amount) || this.amount > this.maxAmount) {
const maxAmount = money(this.maxAmount, this.currency)
queueNotifService.error(
`Veuillez saisir un montant numérique inférieur à ${maxAmount}.`
)
return false
}
if (this.amount < 0) {
queueNotifService.error('Le montant doit être supérieur à 0.')
return false
}
return true
}
}
</script>
<style scoped>
.help {
text-align: left;
}
.set-location {
text-align: center;
}
.set-location-input {
flex: 1;
}
</style>

View File

@@ -1,81 +1,64 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type ISplit from '@/models/ISplit'
const props = defineProps<{ split: ISplit }>()
const { t } = useI18n()
const checked = ref(props.split.weight > 0)
const plus = () => {
props.split.weight++
}
const minus = () => {
props.split.weight = Math.max(props.split.weight - 1, 0)
}
watch(checked, (value) => {
if (value && props.split.weight === 0) {
props.split.weight = 1
} else if (!value && props.split.weight > 0) {
props.split.weight = 0
}
})
watch(
() => props.split.weight,
(weight) => {
if (weight === 0 && checked.value) {
checked.value = false
} else if (weight > 0 && !checked.value) {
checked.value = true
}
},
{ immediate: true }
)
</script>
<template> <template>
<div class="transaction-split field"> <div class="transaction-split flex items-center gap-2 py-1">
<div class="columns is-mobile">
<div class="column">
<input
v-model="checked"
class="is-checkradio"
:id="`user-${split.alias}`"
type="checkbox"
:name="`user-${split.alias}`"
/>
<label <label
:for="`user-${split.alias}`" :for="`user-${split.alias}`"
>{{ split.alias }}, {{ $tc('transaction.split', split.weight, { count: split.weight }) }}</label> class="flex items-center gap-2 cursor-pointer flex-1"
</div> >
<div class="column is-one-third"> <input
<div class="field has-addons"> :id="`user-${split.alias}`"
<div class="control"> v-model="checked"
<a @click="plus" class="button is-primary"> type="checkbox"
<awe-icon icon="plus" /> :name="`user-${split.alias}`"
</a> class="checkbox checkbox-primary"
</div> />
<div class="control"> <span>{{ split.alias }}, {{ t('transaction.split', { count: split.weight }, split.weight) }}</span>
<a @click="minus" class="button is-warning"> </label>
<awe-icon icon="minus" /> <div class="join">
</a> <button type="button" class="btn btn-primary btn-sm join-item" @click="plus">
</div> <vaquant-icon icon="plus" />
</div> </button>
</div> <button type="button" class="btn btn-warning btn-sm join-item" @click="minus">
<vaquant-icon icon="minus" />
</button>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Prop, Vue, Watch } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import IUser from '@/models/IUser'
import ISplit from '@/models/ISplit'
@Component
export default class TransactionSplit extends Vue {
@Getter
public user!: IUser | null
@Prop({ type: Object, required: true })
public split!: ISplit
public checked: boolean = false
public plus(): void {
this.split.weight++
}
public minus(): void {
this.split.weight = Math.max(this.split.weight - 1, 0)
}
@Watch('checked')
public onCheckedChange(check: boolean): void {
if (check && this.split.weight === 0) {
this.split.weight = 1
} else if (!check && this.split.weight > 0) {
this.split.weight = 0
}
}
@Watch('split.weight', { immediate: true })
public onWeightChange(weight: number): void {
if (weight === 0 && this.checked) {
this.checked = false
} else if (weight > 0 && !this.checked) {
this.checked = true
}
}
}
</script>
<style scoped>
.input {
float: left;
max-width: 160px;
}
</style>

View File

@@ -1,46 +1,47 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
const model = defineModel<TransactionTag>({ required: true })
const tagLocale = ref<TransactionTag>(model.value)
watch(tagLocale, (value) => {
if (value !== model.value) {
model.value = value
}
})
watch(model, (value) => {
if (value !== tagLocale.value) {
tagLocale.value = value
}
})
</script>
<template> <template>
<div class="transaction-tag-update field is-horizontal"> <div class="transaction-tag-update flex flex-row items-center gap-3">
<div class="field-label is-normal">
<label class="label" for="tag">Catégorie</label> <label class="label" for="tag">Catégorie</label>
</div> <div class="relative flex-1">
<div class="field-body"> <select
<div class="field"> id="tag"
<p class="control has-icons-left"> v-model="tagLocale"
<span class="select is-fullwidth"> name="tag"
<select v-model="tagLocale" name="tag" id="tag"> class="select select-bordered w-full pl-10"
>
<option <option
v-for="(transactionTag, k) in transactionTagLabel" v-for="(transactionTag, k) in TransactionTagLabel"
:key="k" :key="k"
:value="k"> :value="k"
>
{{ transactionTag.label }} {{ transactionTag.label }}
</option> </option>
</select> </select>
<span
v-if="TransactionTagLabel[model]"
class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none"
>
<vaquant-icon :icon="TransactionTagLabel[model].icon" />
</span> </span>
<span class="icon is-small is-left" v-if="transactionTagLabel[tag]">
<awe-icon :icon="transactionTagLabel[tag].icon" />
</span>
</p>
</div>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Vue, Model, Watch } from 'vue-property-decorator'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
@Component
export default class TransactionTagUpdate extends Vue {
@Model('input', { type: String })
public tag!: TransactionTag
public tagLocale: TransactionTag = this.tag
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
@Watch('tagLocale')
public onTagChange(tag: TransactionTag) {
if (tag !== this.tag) {
this.$emit('input', tag)
}
}
}
</script>

View File

@@ -1,95 +1,75 @@
<template> <script setup lang="ts">
<div class="user-new box columns"> import { ref, computed, watch } from 'vue'
<div class="column is-5" v-if="user"> import { useI18n } from 'vue-i18n'
<div class="field is-horizontal"> import { storeToRefs } from 'pinia'
<div class="field-label is-normal"> import type IUser from '@/models/IUser'
<label class="label" v-t="'user.pseudo'"></label> import { useUserStore } from '@/stores/user'
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input :readonly="isMainUser" class="input" type="text" v-model="userId" />
</p>
</div>
</div>
</div>
</div>
<div class="column is-6" v-if="newUser">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" v-t="'user.alias'"></label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
required
class="input"
type="text"
maxlength="20"
v-model="newUser.alias"
:placeholder="newUser.userId"
/>
</p>
<p class="help is-primary">Nom utilisé sur ce compte</p>
<p
class="help is-primary"
>{{ $tc('validation.max_char', maxChar(newUser), { max: maxChar(newUser) }) }}</p>
</div>
</div>
</div>
</div>
<div class="column is-1" v-if="!isMainUser">
<button class="button is-danger" @click="$emit('remove')">
<awe-icon icon="trash" />
</button>
</div>
</div>
</template>
<script lang="ts"> const newUser = defineModel<IUser>({ required: true })
import { Component, Model, Vue, Watch } from 'vue-property-decorator' defineEmits<{ remove: [] }>()
import { Getter } from 'vuex-class' const { t } = useI18n()
import IUser from '@/models/IUser'
import { slug } from '@/utils'
@Component const userStore = useUserStore()
export default class UserNew extends Vue { const { user } = storeToRefs(userStore)
@Getter
public user!: IUser | null
@Model('input', { type: Object, required: true })
public newUser!: IUser
private localeUserId = (this.newUser.userId || '').toLowerCase()
public maxChar(user: IUser): number { const localeUserId = ref<string>((newUser.value.userId || '').toLowerCase())
return 20 - (user.alias ? user.alias.length : 0)
}
public get isMainUser(): boolean { const userId = computed({
if (!this.newUser) { get: () => localeUserId.value,
return false set: (val: string) => {
} localeUserId.value = (val || '').toLowerCase()
return !!this.user && this.user.userId === this.newUser.userId
} }
})
public set userId(newUserId: string) { watch(
this.localeUserId = (newUserId || '').toLowerCase() localeUserId,
} (value) => {
public get userId(): string { if (newUser.value.userId !== value) {
return this.localeUserId newUser.value.userId = value
} }
},
{ immediate: true }
)
@Watch('localeUserId', { immediate: true }) const isMainUser = computed(
public onLocaleUserIdChange(userId: string) { () => !!user.value && !!newUser.value && user.value.userId === newUser.value.userId
if (this.newUser.userId !== userId) { )
this.newUser.userId = userId
} const maxChar = (u: IUser): number => 20 - (u.alias ? u.alias.length : 0)
}
}
</script> </script>
<style> <template>
.user-new { <div class="user-new card bg-base-100 shadow p-4 my-2 flex flex-col md:flex-row gap-3 items-end">
margin: 5px 0; <div v-if="user" class="flex flex-col gap-1 flex-1">
} <label class="label">{{ t('user.pseudo') }}</label>
</style> <input
v-model="userId"
:readonly="isMainUser"
type="text"
class="input input-bordered w-full"
/>
</div>
<div v-if="newUser" class="flex flex-col gap-1 flex-1">
<label class="label">{{ t('user.alias') }}</label>
<input
v-model="newUser.alias"
required
type="text"
maxlength="20"
:placeholder="newUser.userId"
class="input input-bordered w-full"
/>
<p class="text-xs text-primary">Nom utilisé sur ce compte</p>
<p class="text-xs text-primary">
{{ t('validation.max_char', { max: maxChar(newUser) }, maxChar(newUser)) }}
</p>
</div>
<button
v-if="!isMainUser"
class="btn btn-error btn-square"
@click="$emit('remove')"
>
<vaquant-icon icon="trash" />
</button>
</div>
</template>

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
defineProps<{ icon: string }>()
</script>
<template>
<i :class="`ti ti-${icon}`" />
</template>

View File

@@ -0,0 +1,37 @@
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import bus, { SYNC } from '@/utils/bus-event'
import type IAccount from '@/models/IAccount'
import { findContrastColor } from '@/utils'
export const useBaseAccount = (getData: (docIds?: string[]) => Promise<void>) => {
const account = ref<IAccount | null>(null)
const removing = ref(false)
const route = useRoute()
const colorStyle = (display = true): Record<string, string> => {
if (!account.value || !display) return {}
return {
backgroundColor: account.value.color ?? '',
borderColor: account.value.color ?? '',
color: findContrastColor(account.value.color ?? null) ?? ''
}
}
onMounted(async () => {
bus.on(SYNC, getData)
await getData()
const anchor = route.hash
if (anchor && document.querySelector(anchor)) {
await nextTick()
location.href = anchor
window.scrollBy(0, -60)
}
})
onBeforeUnmount(() => {
bus.off(SYNC, getData)
})
return { account, removing, colorStyle }
}

View File

@@ -72,16 +72,17 @@ export default [
} }
].map( ].map(
(color: IColor): IColor => { (color: IColor): IColor => {
const darkValue = lightness(color.value, dark).toLowerCase() const value = color.value ?? ''
const lightValue = lightness(color.value, light).toLowerCase() const darkValue = lightness(value, dark).toLowerCase()
const slightLightValue = lightness(color.value, 10).toLowerCase() const lightValue = lightness(value, light).toLowerCase()
const slightLightValue = lightness(value, 10).toLowerCase()
return { return {
...color, ...color,
darkValue, darkValue,
lightValue, lightValue,
slightLightValue, slightLightValue,
constrastColor: constrastColor:
fontColorContrast(color.value) === '#000000' ? darkValue : lightValue fontColorContrast(value) === '#000000' ? darkValue : lightValue
} }
} }
) )

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

@@ -35,11 +35,11 @@ export const TransactionTagLabel: ITagAllLabel = {
}, },
[TransactionTag.Food]: { [TransactionTag.Food]: {
label: 'Restaurant', label: 'Restaurant',
icon: 'utensils' icon: 'tools-kitchen-2'
}, },
[TransactionTag.Transport]: { [TransactionTag.Transport]: {
label: 'Transport', label: 'Transport',
icon: 'subway' icon: 'train'
}, },
[TransactionTag.Housing]: { [TransactionTag.Housing]: {
label: 'Logement', label: 'Logement',
@@ -47,7 +47,7 @@ export const TransactionTagLabel: ITagAllLabel = {
}, },
[TransactionTag.Shopping]: { [TransactionTag.Shopping]: {
label: 'Courses', label: 'Courses',
icon: 'shopping-basket' icon: 'shopping-cart'
}, },
[TransactionTag.Gift]: { [TransactionTag.Gift]: {
label: 'Cadeau', label: 'Cadeau',
@@ -55,10 +55,10 @@ export const TransactionTagLabel: ITagAllLabel = {
}, },
[TransactionTag.Entertainment]: { [TransactionTag.Entertainment]: {
label: 'Divertissement', label: 'Divertissement',
icon: 'theater-masks' icon: 'masks-theater'
}, },
[TransactionTag.Tax]: { [TransactionTag.Tax]: {
label: 'Impôt', label: 'Impôt',
icon: 'hand-holding-usd' icon: 'pig-money'
} }
} }

11
src/i18n/index.ts Normal file
View File

@@ -0,0 +1,11 @@
import { createI18n } from 'vue-i18n'
import messages from '@/messages'
const i18n = createI18n({
legacy: false,
locale: 'fr',
fallbackLocale: 'en',
messages
})
export default i18n

View File

@@ -1,37 +1,30 @@
import Vue from 'vue' import { createApp } from 'vue'
import VueI18n from 'vue-i18n' import { createPinia } from 'pinia'
import piniaPluginPersistedstate from 'pinia-plugin-persistedstate'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import store from './store' import i18n from './i18n'
import './styles/index.scss' import { useUserStore } from './stores/user'
import 'bulma-checkradio' import { registerIcons } from './utils/icons'
import './registerServiceWorker' import couchService from './services/CouchService'
import filters from './utils/filters' import './styles/index.css'
import './utils/icons'
import messages from './messages'
Vue.use(VueI18n) const bootstrap = async () => {
const app = createApp(App)
Vue.config.productionTip = false const pinia = createPinia()
pinia.use(piniaPluginPersistedstate)
app.use(pinia)
for (const filter of Object.keys(filters)) { app.use(router)
Vue.filter(filter, filters[filter]) app.use(i18n)
registerIcons(app)
const userStore = useUserStore()
couchService.start(() => userStore.publicAccountIds)
await userStore.retrieveUser()
app.mount('#app')
} }
const i18n = new VueI18n({ bootstrap()
locale: 'fr',
fallbackLocale: 'en',
messages
})
const app = async () => {
await store.dispatch('retrieveUser')
new Vue({
router,
store,
i18n,
render: (h) => h(App)
}).$mount('#app')
}
app()

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
} }

View File

@@ -1,29 +0,0 @@
/* tslint:disable:no-console */
import { register } from 'register-service-worker'
import queueNotifService from '@/services/QueueNotifService'
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
registrationOptions: {},
ready() {
console.log('App is being served from cache by a service worker.')
},
cached() {
console.log('Content has been cached for offline use.')
queueNotifService.success('Application mise à jour !')
},
updated() {
console.log('New content is available; please refresh.')
queueNotifService.hasNewVersion()
},
offline() {
console.log(
'No internet connection found. App is running in offline mode.'
)
},
error(error) {
console.error('Error during service worker registration:', error)
}
})
}

View File

@@ -1,112 +0,0 @@
import Vue from 'vue'
import Router from 'vue-router'
import Home from './views/Home.vue'
import store from './store'
Vue.use(Router)
const router: Router = new Router({
mode: 'history',
routes: [
{
path: '/',
name: 'home',
component: Home,
},
{
path: '/about',
name: 'about',
component: () =>
import(/* webpackChunkName: "about" */ './views/About.vue'),
},
{
path: '/pricing',
name: 'pricing',
component: () =>
import(/* webpackChunkName: "pricing" */ './views/Pricing.vue'),
},
{
path: '/security',
name: 'security',
component: () =>
import(/* webpackChunkName: "pricing" */ './views/Security.vue'),
},
{
path: '/account/new',
name: 'account-new',
component: () =>
import(
/* webpackChunkName: "account-new" */ './views/accounts/AccountNew.vue'
),
},
{
path: '/account/:id',
name: 'account',
props: true,
component: () =>
import(
/* webpackChunkName: "account" */ './views/accounts/AccountItem.vue'
),
},
{
path: '/account/:id/public',
name: 'account-public',
props: true,
component: () =>
import(
/* webpackChunkName: "account-public" */ './views/accounts/AccountPublic.vue'
),
},
{
path: '/account/:id/transaction/new',
name: 'transaction-new',
props: true,
component: () =>
import(
/* webpackChunkName: "transaction-new" */ './views/transactions/TransactionNew.vue'
),
},
{
path: '/transaction/:id/update',
name: 'transaction-update',
props: true,
component: () =>
import(
/* webpackChunkName: "transaction-update" */ './views/transactions/TransactionUpdate.vue'
),
},
{
path: '/transaction/:id',
name: 'transaction',
props: true,
component: () =>
import(
/* webpackChunkName: "transaction" */ './views/transactions/TransactionItem.vue'
),
},
{
path: '/user',
name: 'user',
component: () =>
import(/* webpackChunkName: "user" */ './views/User.vue'),
},
{
path: '/user/:premail',
name: 'user-premail',
props: true,
component: () =>
import(/* webpackChunkName: "user" */ './views/User.vue'),
},
],
})
// tslint:disable-next-line:variable-name
router.beforeEach((to, _from, next) => {
const accountRoutes: string[] = ['account', 'transaction']
if (!accountRoutes.includes(to.name || '')) {
store.dispatch('setTitle', null)
}
next()
})
export default router

75
src/router/index.ts Normal file
View File

@@ -0,0 +1,75 @@
import { createRouter, createWebHistory } from 'vue-router'
import Home from '@/views/Home.vue'
import { useUserStore } from '@/stores/user'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: Home },
{
path: '/about',
name: 'about',
component: () => import('@/views/About.vue')
},
{
path: '/security',
name: 'security',
component: () => import('@/views/Security.vue')
},
{
path: '/account/new',
name: 'account-new',
component: () => import('@/views/accounts/AccountNew.vue')
},
{
path: '/account/:id',
name: 'account',
props: true,
component: () => import('@/views/accounts/AccountItem.vue')
},
{
path: '/account/:id/public',
name: 'account-public',
props: true,
component: () => import('@/views/accounts/AccountPublic.vue')
},
{
path: '/account/:id/transaction/new',
name: 'transaction-new',
props: true,
component: () => import('@/views/transactions/TransactionNew.vue')
},
{
path: '/transaction/:id/update',
name: 'transaction-update',
props: true,
component: () => import('@/views/transactions/TransactionUpdate.vue')
},
{
path: '/transaction/:id',
name: 'transaction',
props: true,
component: () => import('@/views/transactions/TransactionItem.vue')
},
{
path: '/user',
name: 'user',
component: () => import('@/views/User.vue')
},
{
path: '/user/:premail',
name: 'user-premail',
props: true,
component: () => import('@/views/User.vue')
}
]
})
router.beforeEach((to) => {
const accountRoutes = ['account', 'transaction']
if (!accountRoutes.includes(String(to.name || ''))) {
useUserStore().setTitle(null)
}
})
export default router

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

@@ -1,31 +0,0 @@
import IAccount from '@/models/IAccount'
import ITransaction from '@/models/ITransaction'
import IExchange from '@/models/IExchange'
type Doc = IAccount | ITransaction | IExchange
interface IUserContext {
db: string
name: string | null
roles: string[]
}
// tslint:disable-next-line: variable-name only-arrow-functions
const validate_doc_update = function(
newDoc: Doc,
oldDoc: Doc,
userCtx: IUserContext
) {
if (!userCtx.name) {
throw { forbidden: 'You need to be logged in to update accounts' }
}
if (newDoc.doctype === 'account' && oldDoc.doctype === 'account') {
if (true) {
throw { forbidden: 'You do not have rights to update this account' }
}
}
}
// tslint:disable-next-line:no-console
console.info(validate_doc_update.toString().replace(/\n/g, ''))

View File

@@ -6,7 +6,7 @@ import { slug } from '@/utils'
class AccountService { class AccountService {
public async get(id: string): Promise<IAccount | null> { public async get(id: string): Promise<IAccount | null> {
const account: IAccount = await couchService.get(id) const account = await couchService.get<IAccount>(id)
if (!account) { if (!account) {
return null return null
} }
@@ -14,7 +14,7 @@ class AccountService {
} }
public async getRemote(id: string): Promise<IAccount | null> { public async getRemote(id: string): Promise<IAccount | null> {
const account: IAccount = await couchService.getRemote(id) const account = await couchService.getRemote<IAccount>(id)
if (!account) { if (!account) {
return null return null
} }
@@ -43,11 +43,12 @@ class AccountService {
public async active(id: string): Promise<boolean> { public async active(id: string): Promise<boolean> {
try { try {
const account = await this.get(id) const account = await this.get(id)
if (account) { if (!account) {
account.archive = false return false
} }
account.archive = false
const { ok } = await couchService.save(account) const { ok } = await couchService.save(account)
return ok return ok ?? false
} catch (error) { } catch (error) {
// tslint:disable-next-line:no-console // tslint:disable-next-line:no-console
console.info('erreur dans la cloture du compte', { console.info('erreur dans la cloture du compte', {
@@ -60,11 +61,12 @@ class AccountService {
public async archive(id: string): Promise<boolean> { public async archive(id: string): Promise<boolean> {
try { try {
const account = await this.get(id) const account = await this.get(id)
if (account) { if (!account) {
account.archive = true return false
} }
account.archive = true
const { ok } = await couchService.save(account) const { ok } = await couchService.save(account)
return ok return ok ?? false
} catch (error) { } catch (error) {
// tslint:disable-next-line:no-console // tslint:disable-next-line:no-console
console.info('erreur dans la cloture du compte', { console.info('erreur dans la cloture du compte', {
@@ -96,7 +98,7 @@ class AccountService {
try { try {
const response = await couchService.getByPrefix('acc') const response = await couchService.getByPrefix('acc')
const accounts = response.rows.map((row: any) => row.doc) as IAccount[] const accounts = response.rows.map((row: any) => row.doc) as IAccount[]
const result = [] const result: IAccount[] = []
for (const account of accounts) { for (const account of accounts) {
if ((archived && account.archive) || (!archived && !account.archive)) { if ((archived && account.archive) || (!archived && !account.archive)) {
result.push(account) result.push(account)

View File

@@ -1,5 +1,5 @@
import IPoint from '@/models/IPoint' import IPoint from '@/models/IPoint'
import { money } from '@/utils/filters' import { money } from '@/utils/format'
import ICurrency from '@/models/ICurrency' import ICurrency from '@/models/ICurrency'
class ChartService { class ChartService {

View File

@@ -1,44 +1,50 @@
import { v4 as uuid } from 'uuid' import { v4 as uuid } from 'uuid'
import PouchDb from 'pouchdb-browser' import PouchDb from 'pouchdb-browser'
import PouchDbAuthentication from 'pouchdb-authentication' import PouchDbAuthentication from 'pouchdb-authentication'
import bus, { SYNC } from '@/utils/bus-event'
import IUser from '@/models/IUser'
import { debounce } from 'lodash-es' import { debounce } from 'lodash-es'
import bus, { SYNC } from '@/utils/bus-event'
import type IUser from '@/models/IUser'
import { confirmation } from '@/utils' import { confirmation } from '@/utils'
import store from '@/store'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
PouchDb.plugin(PouchDbAuthentication) PouchDb.plugin(PouchDbAuthentication)
const emit = debounce((ev: string, 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: string = 'VAQUANT_LOCALE_DB' const LOCALE_DB = 'VAQUANT_LOCALE_DB'
const REMOTE: string = process.env.VUE_APP_COUCHDB || '' const REMOTE: string = import.meta.env.VITE_COUCHDB || ''
type PublicAccountIdsProvider = () => string[]
class CouchService { class CouchService {
public db: PouchDB.Database | null = null public db: PouchDB.Database | null = null
private innerRemote: PouchDB.Database | null = null private innerRemote: PouchDB.Database | null = null
private user: IUser | null = null private user: IUser | null = null
private sync: PouchDB.Replication.Sync<{}> | null = null private sync: PouchDB.Replication.Sync<object> | null = null
private userSync: PouchDB.Replication.Sync<{}> | null = null private userSync: PouchDB.Replication.Sync<object> | null = null
private eventListened: boolean = false private eventListened = false
private publicAccountIds: PublicAccountIdsProvider = () => []
public get remote(): PouchDB.Database { public get remote(): PouchDB.Database {
if (!this.innerRemote) { if (!this.innerRemote) {
this.innerRemote = new PouchDb(`${REMOTE}/vaquant`, remoteConfig) this.innerRemote = new PouchDb(`${REMOTE}/vaquant`, remoteConfig)
} }
return this.innerRemote return this.innerRemote as PouchDB.Database
} }
constructor() { constructor() {
this.initDb() this.initDb()
}
public start(publicAccountIds: PublicAccountIdsProvider): void {
this.publicAccountIds = publicAccountIds
if (!this.eventListened) { if (!this.eventListened) {
this.eventListened = true this.eventListened = true
window.addEventListener('online', () => { window.addEventListener('online', () => {
@@ -50,7 +56,6 @@ class CouchService {
this.cancelSync() this.cancelSync()
}) })
} }
if (navigator.onLine) { if (navigator.onLine) {
this.initLive() this.initLive()
} }
@@ -60,7 +65,7 @@ class CouchService {
if (!this.db) { if (!this.db) {
return false return false
} }
await this.cancelSync() this.cancelSync()
this.sync = this.db this.sync = this.db
.sync(this.remote, { .sync(this.remote, {
@@ -69,13 +74,13 @@ class CouchService {
filter: 'account/by_user', filter: 'account/by_user',
query_params: { query_params: {
userId: (this.user && this.user.userId) || '', userId: (this.user && this.user.userId) || '',
accountIds: [...store.getters.publicAccountIds] accountIds: [...this.publicAccountIds()]
} }
}) })
.on('change', (result: PouchDB.Replication.SyncResult<any>) => { .on('change', (result: PouchDB.Replication.SyncResult<{ doctype?: string }>) => {
if (result.direction === 'pull') { if (result.direction === 'pull') {
const hasDocOtherThanExchange = result.change.docs.some( const hasDocOtherThanExchange = result.change.docs.some(
(doc: any) => doc.doctype !== 'exchange' (doc) => (doc as { doctype?: string }).doctype !== 'exchange'
) )
if (hasDocOtherThanExchange) { if (hasDocOtherThanExchange) {
emit(SYNC) emit(SYNC)
@@ -85,10 +90,9 @@ class CouchService {
emit(SYNC) emit(SYNC)
} }
}) })
.on('error', (error: any) => { .on('error', (error: unknown) => {
// tslint:disable-next-line:no-console
console.warn('on error', { error }) console.warn('on error', { error })
if (error.name !== 'unauthorized') { if ((error as { name?: string })?.name !== 'unauthorized') {
queueNotifService.error(`une erreur s'est produite`) queueNotifService.error(`une erreur s'est produite`)
} }
}) })
@@ -131,21 +135,25 @@ class CouchService {
return navigator.onLine return navigator.onLine
} }
public async get(id: string): Promise<any | null> { public async get<T extends object = Record<string, unknown>>(
id: string
): Promise<T | null> {
if (!this.db) { if (!this.db) {
return null return null
} }
return await this.db.get(id) return (await this.db.get(id)) as T
} }
public async getRemote(id: string): Promise<any | null> { public async getRemote<T extends object = Record<string, unknown>>(
id: string
): Promise<T | null> {
if (!this.remote) { if (!this.remote) {
return null return null
} }
return await this.remote.get(id) return (await this.remote.get(id)) as T
} }
public async getByPrefix<T>( public async getByPrefix<T extends object>(
prefix: string prefix: string
): Promise<PouchDB.Core.AllDocsResponse<T>> { ): Promise<PouchDB.Core.AllDocsResponse<T>> {
if (!this.db) { if (!this.db) {
@@ -158,40 +166,34 @@ class CouchService {
return (await this.db.allDocs({ return (await this.db.allDocs({
include_docs: true, include_docs: true,
startkey: prefix, startkey: prefix,
endkey: `${prefix}\ufff0` endkey: `${prefix}`
})) as PouchDB.Core.AllDocsResponse<T> })) as PouchDB.Core.AllDocsResponse<T>
} }
public async query(query: string): Promise<any> { public async query(query: string): Promise<unknown[]> {
if (!this.db) { if (!this.db) {
return [] return []
} }
const response = await this.db.query(query, { const response = await this.db.query(query, {
include_docs: true include_docs: true
}) })
return response.rows.map((row: any) => row.doc) return response.rows.map((row) => row.doc)
} }
public async save(doc: any): Promise<PouchDB.Core.Response> { public async save(doc: object): Promise<PouchDB.Core.Response> {
if (!this.db) { if (!this.db) {
return { return { id: '', rev: '', ok: false }
id: '',
rev: '',
ok: false
} }
} return await this.db.put(doc as PouchDB.Core.PutDocument<object>)
const response = await this.db.put(doc)
return response
} }
public async multipleSave( public async multipleSave(
docs: any[] docs: object[]
): Promise<Array<PouchDB.Core.Response | PouchDB.Core.Error>> { ): Promise<Array<PouchDB.Core.Response | PouchDB.Core.Error>> {
if (!this.db) { if (!this.db) {
return [] return []
} }
const response = await this.db.bulkDocs(docs) return await this.db.bulkDocs(docs as PouchDB.Core.PutDocument<object>[])
return response
} }
public async remove(id: string): Promise<boolean> { public async remove(id: string): Promise<boolean> {
@@ -200,17 +202,13 @@ class CouchService {
} }
const doc = await this.db.get(id) const doc = await this.db.get(id)
if (doc) { if (doc) {
const response = await this.save({ const response = await this.save({ ...doc, _deleted: true })
...doc, return response.ok ?? false
_deleted: true
})
return response.ok
} }
return false return false
} }
public newId(prefix: string, withUuid: boolean = false): string { public newId(prefix: string, withUuid = false): string {
return `${prefix}${Date.now().toString()}${withUuid ? uuid() : ''}` return `${prefix}${Date.now().toString()}${withUuid ? uuid() : ''}`
} }
@@ -220,12 +218,11 @@ class CouchService {
await this.db.destroy() await this.db.destroy()
this.db = null this.db = null
} }
this.setUser(this.user, false) this.setUser(this.user, false)
} }
} }
private cancelSync(): any { private cancelSync(): void {
if (this.sync) { if (this.sync) {
this.sync.cancel() this.sync.cancel()
this.sync = null this.sync = null

View File

@@ -1,5 +1,5 @@
declare const Email: any declare const Email: { send: (...args: unknown[]) => unknown }
import '@/utils/email' import '@/utils/smtp'
import IUser from '@/models/IUser' import IUser from '@/models/IUser'
class EmailService { class EmailService {

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 = process.env.VUE_APP_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,28 +28,20 @@ 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<Position | null> { private getCurrentPosition(): Promise<GeolocationPosition | null> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!('geolocation' in navigator)) { if (!('geolocation' in navigator)) {
resolve(null) resolve(null)
return
} }
return navigator.geolocation.getCurrentPosition(resolve, reject) navigator.geolocation.getCurrentPosition(resolve, reject)
}) })
} }
} }

View File

@@ -45,8 +45,7 @@ class TransactionService {
} }
public async get(id: string): Promise<ITransaction | null> { public async get(id: string): Promise<ITransaction | null> {
const transaction: ITransaction = await couchService.get(id) return await couchService.get<ITransaction>(id)
return transaction
} }
public async add( public async add(

13
src/shims-tsx.d.ts vendored
View File

@@ -1,13 +0,0 @@
import Vue, { VNode } from 'vue'
declare global {
namespace JSX {
// tslint:disable no-empty-interface
interface Element extends VNode {}
// tslint:disable no-empty-interface
interface ElementClass extends Vue {}
interface IntrinsicElements {
[elem: string]: any
}
}
}

13
src/shims-vue.d.ts vendored
View File

@@ -1,13 +0,0 @@
declare module '*.vue' {
import Vue from 'vue'
export default Vue
}
declare module '@fortawesome/vue-fontawesome'
declare module 'vue-click-outside'
declare module 'lightness'
declare module 'crypto-js/aes'
declare module 'crypto-js/enc-utf8'
declare module '@xkeshi/vue-qrcode'
declare module 'font-color-contrast'
declare module 'vue-stripe-checkout'
declare module 'mapbox-gl/dist/mapbox-gl'

13
src/shims.d.ts vendored Normal file
View File

@@ -0,0 +1,13 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
/// <reference types="pouchdb" />
/// <reference types="pouchdb-browser" />
/// <reference types="pouchdb-replication" />
/// <reference types="pouchdb-mapreduce" />
declare module 'lightness' {
const lightness: (color: string, value: number) => string
export default lightness
}
import 'pinia-plugin-persistedstate'

View File

@@ -1,154 +0,0 @@
import Vue from 'vue'
import Vuex from 'vuex'
import VuexPersistence from 'vuex-persist'
import IUser from '@/models/IUser'
import userService from '@/services/UserService'
import couchService from '@/services/CouchService'
import IResponse from '@/models/IResponse'
import IUserPassword from '@/models/IUserPassword'
import IIdPassword from '@/models/IEmailPassword'
import { hasGoodNetwork } from '@/utils/network'
import { toHex } from '@/utils'
import queueNotifService from '@/services/QueueNotifService'
Vue.use(Vuex)
const SET_LOCALE: string = 'SET_LOCALE'
const SET_TITLE: string = 'SET_TITLE'
const SET_USER: string = 'SET_USER'
const ADD_PUBLIC_ACCOUNT: string = 'ADD_PUBLIC_ACCOUNT'
const REMOVE_PUBLIC_ACCOUNT: string = 'REMOVE_PUBLIC_ACCOUNT'
interface IState {
locale: string
user: IUser | null
hexUser: string | null
title: string | null
publicAccountIds: string[]
}
export default new Vuex.Store<IState>({
state: {
locale: '',
user: null,
hexUser: null,
title: null,
publicAccountIds: []
},
getters: {
locale: ({ locale }) => locale,
user: ({ user }) => user,
title: ({ title }) => title,
username: (state: IState) =>
state.user
? state.user.firstname && state.user.lastname
? `${state.user.firstname} ${state.user.lastname}`.trim()
: state.user.email
: '',
isAppAdmin: (state: IState) =>
state.user &&
state.user.roles &&
state.user.roles.find((role: string) => role === 'vaquant-admin'),
publicAccountIds: ({ publicAccountIds }) => publicAccountIds
},
mutations: {
[SET_LOCALE](store, locale: string) {
store.locale = locale
},
[SET_TITLE](store, title: string | null) {
store.title = title
},
[SET_USER](
store,
{ user, replicate }: { user: IUser; replicate?: boolean }
) {
store.user = user
store.hexUser = user ? toHex(user.userId) : null
couchService.setUser(user, replicate || false)
},
[ADD_PUBLIC_ACCOUNT](store, { accountId }: { accountId: string }) {
if (!accountId) {
return
}
store.publicAccountIds = [
...new Set([...store.publicAccountIds, accountId])
]
},
[REMOVE_PUBLIC_ACCOUNT](store, { accountId }: { accountId: string }) {
if (!accountId) {
return
}
store.publicAccountIds = store.publicAccountIds.filter(
(id: string) => id !== accountId && id !== null
)
}
},
actions: {
setLocale({ commit }, locale: string) {
commit(SET_LOCALE, locale)
},
setTitle({ commit }, title: string) {
commit(SET_TITLE, title)
},
async retrieveUser({ commit, state }) {
let user = state.user
if (user) {
commit(SET_USER, { user, replicate: false })
}
// Update user if present and available online.
if (navigator.onLine && hasGoodNetwork()) {
user = await userService.getUser(user)
}
if (user) {
commit(SET_USER, { user, replicate: false })
}
},
async signup({ commit }, { user, password, replicate }: IUserPassword) {
const response: IResponse = await userService.signup(user, password)
if (response.ok) {
commit(SET_USER, { user, replicate })
} else {
queueNotifService.error(response.message || '')
}
},
async login({ commit, state }, { userId, password }: IIdPassword) {
const response: IResponse = await userService.login(userId, password)
if (response.ok) {
const user: IUser | null = await userService.getUser(state.user)
commit(SET_USER, { user })
} else {
queueNotifService.error(response.message || '')
}
},
async logout({ commit }) {
const response = await userService.logout()
if (response.ok) {
commit(SET_USER, { user: null })
} else {
queueNotifService.error(response.message || '')
}
},
async remove({ commit, state }) {
if (state.user) {
const response = await userService.deleteUser(state.user.userId)
if (response.ok) {
commit(SET_USER, { user: null })
} else {
queueNotifService.error(response.message || '')
}
}
},
addAccountId({ commit }, { accountId }: { accountId: string }) {
commit(ADD_PUBLIC_ACCOUNT, { accountId })
},
removeAccountId({ commit }, { accountId }: { accountId: string }) {
commit(REMOVE_PUBLIC_ACCOUNT, { accountId })
}
},
plugins: [
new VuexPersistence<any>({
key: 'vaquant',
storage: window.localStorage
}).plugin
]
})

133
src/stores/user.ts Normal file
View File

@@ -0,0 +1,133 @@
import { defineStore } from 'pinia'
import type IUser from '@/models/IUser'
import type IUserPassword from '@/models/IUserPassword'
import type IEmailPassword from '@/models/IEmailPassword'
import type IResponse from '@/models/IResponse'
import userService from '@/services/UserService'
import couchService from '@/services/CouchService'
import queueNotifService from '@/services/QueueNotifService'
import { hasGoodNetwork } from '@/utils/network'
import { toHex } from '@/utils'
const DEFAULT_LOCALE = 'fr'
interface UserState {
locale: string
user: IUser | null
hexUser: string | null
title: string | null
publicAccountIds: string[]
}
export const useUserStore = defineStore('user', {
state: (): UserState => ({
locale: DEFAULT_LOCALE,
user: null,
hexUser: null,
title: null,
publicAccountIds: []
}),
getters: {
currentLocale: (state) => state.locale || DEFAULT_LOCALE,
username: (state): string =>
state.user
? state.user.firstname && state.user.lastname
? `${state.user.firstname} ${state.user.lastname}`.trim()
: state.user.email
: '',
isAppAdmin: (state): boolean =>
!!state.user?.roles?.some((role: string) => role === 'vaquant-admin')
},
actions: {
setLocale(locale: string) {
this.locale = locale
},
setTitle(title: string | null) {
this.title = title
},
setUser({ user, replicate }: { user: IUser | null; replicate?: boolean }) {
this.user = user
this.hexUser = user ? toHex(user.userId) : null
couchService.setUser(user, replicate || false)
},
addAccountId(accountId: string) {
if (!accountId) {
return
}
this.publicAccountIds = Array.from(
new Set([...this.publicAccountIds, accountId])
)
},
removeAccountId(accountId: string) {
if (!accountId) {
return
}
this.publicAccountIds = this.publicAccountIds.filter(
(id) => id !== accountId && id !== null
)
},
async retrieveUser() {
let user = this.user
if (user) {
this.setUser({ user, replicate: false })
}
if (navigator.onLine && hasGoodNetwork()) {
user = await userService.getUser(user)
}
if (user) {
this.setUser({ user, replicate: false })
}
},
async signup({ user, password, replicate }: IUserPassword) {
const response: IResponse = await userService.signup(user, password)
if (response.ok) {
this.setUser({ user, replicate })
} else {
queueNotifService.error(response.message || '')
}
},
async login({ userId, password }: IEmailPassword) {
const response: IResponse = await userService.login(userId, password)
if (response.ok) {
const user: IUser | null = await userService.getUser(this.user)
this.setUser({ user })
} else {
queueNotifService.error(response.message || '')
}
},
async logout() {
const response = await userService.logout()
if (response.ok) {
this.setUser({ user: null })
} else {
queueNotifService.error(response.message || '')
}
},
async remove() {
if (this.user) {
const response = await userService.deleteUser(this.user.userId)
if (response.ok) {
this.setUser({ user: null })
} else {
queueNotifService.error(response.message || '')
}
}
}
},
persist: {
key: 'vaquant',
pick: ['locale', 'user', 'hexUser', 'publicAccountIds']
}
})

View File

@@ -1,688 +0,0 @@
.mapboxgl-map {
font: 12px/20px Helvetica Neue, Arial, Helvetica, sans-serif;
overflow: hidden;
position: relative;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
text-align: left;
}
.mapboxgl-map:-webkit-full-screen {
width: 100%;
height: 100%;
}
.mapboxgl-canary {
background-color: salmon;
}
.mapboxgl-canvas-container.mapboxgl-interactive,
.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
-moz-user-select: none;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
}
.mapboxgl-canvas-container.mapboxgl-interactive.mapboxgl-track-pointer {
cursor: pointer;
}
.mapboxgl-canvas-container.mapboxgl-interactive:active,
.mapboxgl-ctrl-group button.mapboxgl-ctrl-compass:active {
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate,
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate .mapboxgl-canvas {
touch-action: pan-x pan-y;
}
.mapboxgl-canvas-container.mapboxgl-touch-drag-pan,
.mapboxgl-canvas-container.mapboxgl-touch-drag-pan .mapboxgl-canvas {
touch-action: pinch-zoom;
}
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan,
.mapboxgl-canvas-container.mapboxgl-touch-zoom-rotate.mapboxgl-touch-drag-pan
.mapboxgl-canvas {
touch-action: none;
}
.mapboxgl-ctrl-bottom-left,
.mapboxgl-ctrl-bottom-right,
.mapboxgl-ctrl-top-left,
.mapboxgl-ctrl-top-right {
position: absolute;
pointer-events: none;
z-index: 2;
}
.mapboxgl-ctrl-top-left {
top: 0;
left: 0;
}
.mapboxgl-ctrl-top-right {
top: 0;
right: 0;
}
.mapboxgl-ctrl-bottom-left {
bottom: 0;
left: 0;
}
.mapboxgl-ctrl-bottom-right {
right: 0;
bottom: 0;
}
.mapboxgl-ctrl {
clear: both;
pointer-events: auto;
transform: translate(0);
}
.mapboxgl-ctrl-top-left .mapboxgl-ctrl {
margin: 10px 0 0 10px;
float: left;
}
.mapboxgl-ctrl-top-right .mapboxgl-ctrl {
margin: 10px 10px 0 0;
float: right;
}
.mapboxgl-ctrl-bottom-left .mapboxgl-ctrl {
margin: 0 0 10px 10px;
float: left;
}
.mapboxgl-ctrl-bottom-right .mapboxgl-ctrl {
margin: 0 10px 10px 0;
float: right;
}
.mapboxgl-ctrl-group {
border-radius: 4px;
background: #fff;
}
.mapboxgl-ctrl-group:not(:empty) {
-moz-box-shadow: 0 0 2px rgba(0, 0, 0, 0.1);
-webkit-box-shadow: 0 0 2px rgba(0, 0, 0, 0.1);
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1);
}
@media (-ms-high-contrast: active) {
.mapboxgl-ctrl-group:not(:empty) {
box-shadow: 0 0 0 2px ButtonText;
}
}
.mapboxgl-ctrl-group button {
width: 29px;
height: 29px;
display: block;
padding: 0;
outline: none;
border: 0;
box-sizing: border-box;
background-color: transparent;
cursor: pointer;
}
.mapboxgl-ctrl-group button + button {
border-top: 1px solid #ddd;
}
.mapboxgl-ctrl button .mapboxgl-ctrl-icon {
display: block;
width: 100%;
height: 100%;
background-repeat: no-repeat;
background-position: 50%;
}
@media (-ms-high-contrast: active) {
.mapboxgl-ctrl-icon {
background-color: transparent;
}
.mapboxgl-ctrl-group button + button {
border-top: 1px solid ButtonText;
}
}
.mapboxgl-ctrl button::-moz-focus-inner {
border: 0;
padding: 0;
}
.mapboxgl-ctrl-group button:focus {
box-shadow: 0 0 2px 2px #0096ff;
}
.mapboxgl-ctrl button:disabled {
cursor: not-allowed;
}
.mapboxgl-ctrl button:disabled .mapboxgl-ctrl-icon {
opacity: 0.25;
}
.mapboxgl-ctrl button:not(:disabled):hover {
background-color: rgba(0, 0, 0, 0.05);
}
.mapboxgl-ctrl-group button:focus:focus-visible {
box-shadow: 0 0 2px 2px #0096ff;
}
.mapboxgl-ctrl-group button:focus:not(:focus-visible) {
box-shadow: none;
}
.mapboxgl-ctrl-group button:focus:first-child {
border-radius: 4px 4px 0 0;
}
.mapboxgl-ctrl-group button:focus:last-child {
border-radius: 0 0 4px 4px;
}
.mapboxgl-ctrl-group button:focus:only-child {
border-radius: inherit;
}
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E");
}
@media (-ms-high-contrast: active) {
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E");
}
}
@media (-ms-high-contrast: black-on-white) {
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-out .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 13c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h9c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-9z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-zoom-in .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M14.5 8.5c-.75 0-1.5.75-1.5 1.5v3h-3c-.75 0-1.5.75-1.5 1.5S9.25 16 10 16h3v3c0 .75.75 1.5 1.5 1.5S16 19.75 16 19v-3h3c.75 0 1.5-.75 1.5-1.5S19.75 13 19 13h-3v-3c0-.75-.75-1.5-1.5-1.5z'/%3E%3C/svg%3E");
}
}
.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E");
}
@media (-ms-high-contrast: active) {
.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E");
}
}
@media (-ms-high-contrast: black-on-white) {
.mapboxgl-ctrl button.mapboxgl-ctrl-fullscreen .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M24 16v5.5c0 1.75-.75 2.5-2.5 2.5H16v-1l3-1.5-4-5.5 1-1 5.5 4 1.5-3h1zM6 16l1.5 3 5.5-4 1 1-4 5.5 3 1.5v1H7.5C5.75 24 5 23.25 5 21.5V16h1zm7-11v1l-3 1.5 4 5.5-1 1-5.5-4L6 13H5V7.5C5 5.75 5.75 5 7.5 5H13zm11 2.5c0-1.75-.75-2.5-2.5-2.5H16v1l3 1.5-4 5.5 1 1 5.5-4 1.5 3h1V7.5z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-shrink .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M18.5 16c-1.75 0-2.5.75-2.5 2.5V24h1l1.5-3 5.5 4 1-1-4-5.5 3-1.5v-1h-5.5zM13 18.5c0-1.75-.75-2.5-2.5-2.5H5v1l3 1.5L4 24l1 1 5.5-4 1.5 3h1v-5.5zm3-8c0 1.75.75 2.5 2.5 2.5H24v-1l-3-1.5L25 5l-1-1-5.5 4L17 5h-1v5.5zM10.5 13c1.75 0 2.5-.75 2.5-2.5V5h-1l-1.5 3L5 4 4 5l4 5.5L5 12v1h5.5z'/%3E%3C/svg%3E");
}
}
.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E");
}
@media (-ms-high-contrast: active) {
.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23999'/%3E%3C/svg%3E");
}
}
@media (-ms-high-contrast: black-on-white) {
.mapboxgl-ctrl button.mapboxgl-ctrl-compass .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 29 29' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10.5 14l4-8 4 8h-8z'/%3E%3Cpath d='M10.5 16l4 8 4-8h-8z' fill='%23ccc'/%3E%3C/svg%3E");
}
}
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23333'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23aaa'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-waiting
.mapboxgl-ctrl-icon {
-webkit-animation: mapboxgl-spin 2s linear infinite;
-moz-animation: mapboxgl-spin 2s infinite linear;
-o-animation: mapboxgl-spin 2s infinite linear;
-ms-animation: mapboxgl-spin 2s infinite linear;
animation: mapboxgl-spin 2s linear infinite;
}
@media (-ms-high-contrast: active) {
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23fff'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23999'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-active-error
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e58978'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%2333b5e5'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl
button.mapboxgl-ctrl-geolocate.mapboxgl-ctrl-geolocate-background-error
.mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23e54e33'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3C/svg%3E");
}
}
@media (-ms-high-contrast: black-on-white) {
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3C/svg%3E");
}
.mapboxgl-ctrl button.mapboxgl-ctrl-geolocate:disabled .mapboxgl-ctrl-icon {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='29' height='29' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill='%23666'%3E%3Cpath d='M10 4C9 4 9 5 9 5v.1A5 5 0 005.1 9H5s-1 0-1 1 1 1 1 1h.1A5 5 0 009 14.9v.1s0 1 1 1 1-1 1-1v-.1a5 5 0 003.9-3.9h.1s1 0 1-1-1-1-1-1h-.1A5 5 0 0011 5.1V5s0-1-1-1zm0 2.5a3.5 3.5 0 110 7 3.5 3.5 0 110-7z'/%3E%3Ccircle cx='10' cy='10' r='2'/%3E%3Cpath d='M14 5l1 1-9 9-1-1 9-9z' fill='red'/%3E%3C/svg%3E");
}
}
@-webkit-keyframes mapboxgl-spin {
0% {
-webkit-transform: rotate(0deg);
}
to {
-webkit-transform: rotate(1turn);
}
}
@-moz-keyframes mapboxgl-spin {
0% {
-moz-transform: rotate(0deg);
}
to {
-moz-transform: rotate(1turn);
}
}
@-o-keyframes mapboxgl-spin {
0% {
-o-transform: rotate(0deg);
}
to {
-o-transform: rotate(1turn);
}
}
@-ms-keyframes mapboxgl-spin {
0% {
-ms-transform: rotate(0deg);
}
to {
-ms-transform: rotate(1turn);
}
}
@keyframes mapboxgl-spin {
0% {
transform: rotate(0deg);
}
to {
transform: rotate(1turn);
}
}
a.mapboxgl-ctrl-logo {
width: 88px;
height: 23px;
margin: 0 0 -4px -4px;
display: block;
background-repeat: no-repeat;
cursor: pointer;
overflow: hidden;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg opacity='.3' stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cg opacity='.9' fill='%23fff'%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/g%3E%3C/svg%3E");
}
a.mapboxgl-ctrl-logo.mapboxgl-compact {
width: 23px;
}
@media (-ms-high-contrast: active) {
a.mapboxgl-ctrl-logo {
background-color: transparent;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg stroke='%23000' stroke-width='3'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cg fill='%23fff'%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/g%3E%3C/svg%3E");
}
}
@media (-ms-high-contrast: black-on-white) {
a.mapboxgl-ctrl-logo {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='88' height='23' viewBox='0 0 88 23' xmlns='http://www.w3.org/2000/svg' xmlns:xlink='http://www.w3.org/1999/xlink' fill-rule='evenodd'%3E%3Cdefs%3E%3Cpath id='a' d='M11.5 2.25c5.105 0 9.25 4.145 9.25 9.25s-4.145 9.25-9.25 9.25-9.25-4.145-9.25-9.25 4.145-9.25 9.25-9.25zM6.997 15.983c-.051-.338-.828-5.802 2.233-8.873a4.395 4.395 0 013.13-1.28c1.27 0 2.49.51 3.39 1.42.91.9 1.42 2.12 1.42 3.39 0 1.18-.449 2.301-1.28 3.13C12.72 16.93 7 16 7 16l-.003-.017zM15.3 10.5l-2 .8-.8 2-.8-2-2-.8 2-.8.8-2 .8 2 2 .8z'/%3E%3Cpath id='b' d='M50.63 8c.13 0 .23.1.23.23V9c.7-.76 1.7-1.18 2.73-1.18 2.17 0 3.95 1.85 3.95 4.17s-1.77 4.19-3.94 4.19c-1.04 0-2.03-.43-2.74-1.18v3.77c0 .13-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V8.23c0-.12.1-.23.23-.23h1.4zm-3.86.01c.01 0 .01 0 .01-.01.13 0 .22.1.22.22v7.55c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V15c-.7.76-1.69 1.19-2.73 1.19-2.17 0-3.94-1.87-3.94-4.19 0-2.32 1.77-4.19 3.94-4.19 1.03 0 2.02.43 2.73 1.18v-.75c0-.12.1-.23.23-.23h1.4zm26.375-.19a4.24 4.24 0 00-4.16 3.29c-.13.59-.13 1.19 0 1.77a4.233 4.233 0 004.17 3.3c2.35 0 4.26-1.87 4.26-4.19 0-2.32-1.9-4.17-4.27-4.17zM60.63 5c.13 0 .23.1.23.23v3.76c.7-.76 1.7-1.18 2.73-1.18 1.88 0 3.45 1.4 3.84 3.28.13.59.13 1.2 0 1.8-.39 1.88-1.96 3.29-3.84 3.29-1.03 0-2.02-.43-2.73-1.18v.77c0 .12-.1.23-.23.23h-1.4c-.13 0-.23-.1-.23-.23V5.23c0-.12.1-.23.23-.23h1.4zm-34 11h-1.4c-.13 0-.23-.11-.23-.23V8.22c.01-.13.1-.22.23-.22h1.4c.13 0 .22.11.23.22v.68c.5-.68 1.3-1.09 2.16-1.1h.03c1.09 0 2.09.6 2.6 1.55.45-.95 1.4-1.55 2.44-1.56 1.62 0 2.93 1.25 2.9 2.78l.03 5.2c0 .13-.1.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.8 0-1.46.7-1.59 1.62l.01 4.68c0 .13-.11.23-.23.23h-1.41c-.13 0-.23-.11-.23-.23v-4.59c0-.98-.74-1.71-1.62-1.71-.85 0-1.54.79-1.6 1.8v4.5c0 .13-.1.23-.23.23zm53.615 0h-1.61c-.04 0-.08-.01-.12-.03-.09-.06-.13-.19-.06-.28l2.43-3.71-2.39-3.65a.213.213 0 01-.03-.12c0-.12.09-.21.21-.21h1.61c.13 0 .24.06.3.17l1.41 2.37 1.4-2.37a.34.34 0 01.3-.17h1.6c.04 0 .08.01.12.03.09.06.13.19.06.28l-2.37 3.65 2.43 3.7c0 .05.01.09.01.13 0 .12-.09.21-.21.21h-1.61c-.13 0-.24-.06-.3-.17l-1.44-2.42-1.44 2.42a.34.34 0 01-.3.17zm-7.12-1.49c-1.33 0-2.42-1.12-2.42-2.51 0-1.39 1.08-2.52 2.42-2.52 1.33 0 2.42 1.12 2.42 2.51 0 1.39-1.08 2.51-2.42 2.52zm-19.865 0c-1.32 0-2.39-1.11-2.42-2.48v-.07c.02-1.38 1.09-2.49 2.4-2.49 1.32 0 2.41 1.12 2.41 2.51 0 1.39-1.07 2.52-2.39 2.53zm-8.11-2.48c-.01 1.37-1.09 2.47-2.41 2.47s-2.42-1.12-2.42-2.51c0-1.39 1.08-2.52 2.4-2.52 1.33 0 2.39 1.11 2.41 2.48l.02.08zm18.12 2.47c-1.32 0-2.39-1.11-2.41-2.48v-.06c.02-1.38 1.09-2.48 2.41-2.48s2.42 1.12 2.42 2.51c0 1.39-1.09 2.51-2.42 2.51z'/%3E%3C/defs%3E%3Cmask id='c'%3E%3Crect width='100%25' height='100%25' fill='%23fff'/%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/mask%3E%3Cg stroke='%23fff' stroke-width='3' fill='%23fff'%3E%3Ccircle mask='url(%23c)' cx='11.5' cy='11.5' r='9.25'/%3E%3Cuse xlink:href='%23b' mask='url(%23c)'/%3E%3C/g%3E%3Cuse xlink:href='%23a'/%3E%3Cuse xlink:href='%23b'/%3E%3C/svg%3E");
}
}
.mapboxgl-ctrl.mapboxgl-ctrl-attrib {
padding: 0 5px;
background-color: hsla(0, 0%, 100%, 0.5);
margin: 0;
}
@media screen {
.mapboxgl-ctrl-attrib.mapboxgl-compact {
min-height: 20px;
padding: 0;
margin: 10px;
position: relative;
background-color: #fff;
border-radius: 3px 12px 12px 3px;
}
.mapboxgl-ctrl-attrib.mapboxgl-compact:hover {
padding: 2px 24px 2px 4px;
visibility: visible;
margin-top: 6px;
}
.mapboxgl-ctrl-bottom-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:hover,
.mapboxgl-ctrl-top-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:hover {
padding: 2px 4px 2px 24px;
border-radius: 12px 3px 3px 12px;
}
.mapboxgl-ctrl-attrib.mapboxgl-compact .mapboxgl-ctrl-attrib-inner {
display: none;
}
.mapboxgl-ctrl-attrib.mapboxgl-compact:hover .mapboxgl-ctrl-attrib-inner {
display: block;
}
.mapboxgl-ctrl-attrib.mapboxgl-compact:after {
content: '';
cursor: pointer;
position: absolute;
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E");
background-color: hsla(0, 0%, 100%, 0.5);
width: 24px;
height: 24px;
box-sizing: border-box;
border-radius: 12px;
}
.mapboxgl-ctrl-bottom-right > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
bottom: 0;
right: 0;
}
.mapboxgl-ctrl-top-right > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
top: 0;
right: 0;
}
.mapboxgl-ctrl-top-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
top: 0;
left: 0;
}
.mapboxgl-ctrl-bottom-left > .mapboxgl-ctrl-attrib.mapboxgl-compact:after {
bottom: 0;
left: 0;
}
}
@media screen and (-ms-high-contrast: active) {
.mapboxgl-ctrl-attrib.mapboxgl-compact:after {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd' fill='%23fff'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E");
}
}
@media screen and (-ms-high-contrast: black-on-white) {
.mapboxgl-ctrl-attrib.mapboxgl-compact:after {
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg width='24' height='24' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg' fill-rule='evenodd'%3E%3Cpath d='M4 10a6 6 0 1012 0 6 6 0 10-12 0m5-3a1 1 0 102 0 1 1 0 10-2 0m0 3a1 1 0 112 0v3a1 1 0 11-2 0'/%3E%3C/svg%3E");
}
}
.mapboxgl-ctrl-attrib a {
color: rgba(0, 0, 0, 0.75);
text-decoration: none;
}
.mapboxgl-ctrl-attrib a:hover {
color: inherit;
text-decoration: underline;
}
.mapboxgl-ctrl-attrib .mapbox-improve-map {
font-weight: 700;
margin-left: 2px;
}
.mapboxgl-attrib-empty {
display: none;
}
.mapboxgl-ctrl-scale {
background-color: hsla(0, 0%, 100%, 0.75);
font-size: 10px;
border: 2px solid #333;
border-top: #333;
padding: 0 5px;
color: #333;
box-sizing: border-box;
}
.mapboxgl-popup {
position: absolute;
top: 0;
left: 0;
display: -webkit-flex;
display: flex;
will-change: transform;
pointer-events: none;
}
.mapboxgl-popup-anchor-top,
.mapboxgl-popup-anchor-top-left,
.mapboxgl-popup-anchor-top-right {
-webkit-flex-direction: column;
flex-direction: column;
}
.mapboxgl-popup-anchor-bottom,
.mapboxgl-popup-anchor-bottom-left,
.mapboxgl-popup-anchor-bottom-right {
-webkit-flex-direction: column-reverse;
flex-direction: column-reverse;
}
.mapboxgl-popup-anchor-left {
-webkit-flex-direction: row;
flex-direction: row;
}
.mapboxgl-popup-anchor-right {
-webkit-flex-direction: row-reverse;
flex-direction: row-reverse;
}
.mapboxgl-popup-tip {
width: 0;
height: 0;
border: 10px solid transparent;
z-index: 1;
}
.mapboxgl-popup-anchor-top .mapboxgl-popup-tip {
-webkit-align-self: center;
align-self: center;
border-top: none;
border-bottom-color: #fff;
}
.mapboxgl-popup-anchor-top-left .mapboxgl-popup-tip {
-webkit-align-self: flex-start;
align-self: flex-start;
border-top: none;
border-left: none;
border-bottom-color: #fff;
}
.mapboxgl-popup-anchor-top-right .mapboxgl-popup-tip {
-webkit-align-self: flex-end;
align-self: flex-end;
border-top: none;
border-right: none;
border-bottom-color: #fff;
}
.mapboxgl-popup-anchor-bottom .mapboxgl-popup-tip {
-webkit-align-self: center;
align-self: center;
border-bottom: none;
border-top-color: #fff;
}
.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-tip {
-webkit-align-self: flex-start;
align-self: flex-start;
border-bottom: none;
border-left: none;
border-top-color: #fff;
}
.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-tip {
-webkit-align-self: flex-end;
align-self: flex-end;
border-bottom: none;
border-right: none;
border-top-color: #fff;
}
.mapboxgl-popup-anchor-left .mapboxgl-popup-tip {
-webkit-align-self: center;
align-self: center;
border-left: none;
border-right-color: #fff;
}
.mapboxgl-popup-anchor-right .mapboxgl-popup-tip {
-webkit-align-self: center;
align-self: center;
border-right: none;
border-left-color: #fff;
}
.mapboxgl-popup-close-button {
position: absolute;
right: 0;
top: 0;
border: 0;
border-radius: 0 3px 0 0;
cursor: pointer;
background-color: transparent;
}
.mapboxgl-popup-close-button:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.mapboxgl-popup-content {
position: relative;
background: #fff;
border-radius: 3px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
padding: 10px 10px 15px;
pointer-events: auto;
}
.mapboxgl-popup-anchor-top-left .mapboxgl-popup-content {
border-top-left-radius: 0;
}
.mapboxgl-popup-anchor-top-right .mapboxgl-popup-content {
border-top-right-radius: 0;
}
.mapboxgl-popup-anchor-bottom-left .mapboxgl-popup-content {
border-bottom-left-radius: 0;
}
.mapboxgl-popup-anchor-bottom-right .mapboxgl-popup-content {
border-bottom-right-radius: 0;
}
.mapboxgl-popup-track-pointer {
display: none;
}
.mapboxgl-popup-track-pointer * {
pointer-events: none;
user-select: none;
}
.mapboxgl-map:hover .mapboxgl-popup-track-pointer {
display: flex;
}
.mapboxgl-map:active .mapboxgl-popup-track-pointer {
display: none;
}
.mapboxgl-marker {
position: absolute;
top: 0;
left: 0;
will-change: transform;
}
.mapboxgl-user-location-dot,
.mapboxgl-user-location-dot:before {
background-color: #1da1f2;
width: 15px;
height: 15px;
border-radius: 50%;
}
.mapboxgl-user-location-dot:before {
content: '';
position: absolute;
-webkit-animation: mapboxgl-user-location-dot-pulse 2s infinite;
-moz-animation: mapboxgl-user-location-dot-pulse 2s infinite;
-ms-animation: mapboxgl-user-location-dot-pulse 2s infinite;
animation: mapboxgl-user-location-dot-pulse 2s infinite;
}
.mapboxgl-user-location-dot:after {
border-radius: 50%;
border: 2px solid #fff;
content: '';
height: 19px;
left: -2px;
position: absolute;
top: -2px;
width: 19px;
box-sizing: border-box;
box-shadow: 0 0 3px rgba(0, 0, 0, 0.35);
}
@-webkit-keyframes mapboxgl-user-location-dot-pulse {
0% {
-webkit-transform: scale(1);
opacity: 1;
}
70% {
-webkit-transform: scale(3);
opacity: 0;
}
to {
-webkit-transform: scale(1);
opacity: 0;
}
}
@-ms-keyframes mapboxgl-user-location-dot-pulse {
0% {
-ms-transform: scale(1);
opacity: 1;
}
70% {
-ms-transform: scale(3);
opacity: 0;
}
to {
-ms-transform: scale(1);
opacity: 0;
}
}
@keyframes mapboxgl-user-location-dot-pulse {
0% {
transform: scale(1);
opacity: 1;
}
70% {
transform: scale(3);
opacity: 0;
}
to {
transform: scale(1);
opacity: 0;
}
}
.mapboxgl-user-location-dot-stale {
background-color: #aaa;
}
.mapboxgl-user-location-dot-stale:after {
display: none;
}
.mapboxgl-user-location-accuracy-circle {
background-color: rgba(29, 161, 242, 0.2);
width: 1px;
height: 1px;
border-radius: 100%;
}
.mapboxgl-crosshair,
.mapboxgl-crosshair .mapboxgl-interactive,
.mapboxgl-crosshair .mapboxgl-interactive:active {
cursor: crosshair;
}
.mapboxgl-boxzoom {
position: absolute;
top: 0;
left: 0;
width: 0;
height: 0;
background: #fff;
border: 2px dotted #202020;
opacity: 0.5;
}
@media print {
.mapbox-improve-map {
display: none;
}
}

View File

@@ -1,18 +0,0 @@
@import './variables';
@import '~bulma/sass/base/_all.sass';
@import '~bulma/sass/components/navbar.sass';
@import '~bulma/sass/components/breadcrumb.sass';
@import '~bulma/sass/components/card.sass';
@import '~bulma/sass/components/message.sass';
@import '~bulma/sass/components/tabs.sass';
@import '~bulma/sass/components/modal.sass';
@import '~bulma/sass/elements/button.sass';
@import '~bulma/sass/elements/box.sass';
@import '~bulma/sass/elements/container.sass';
@import '~bulma/sass/elements/table.sass';
@import '~bulma/sass/elements/title.sass';
@import '~bulma/sass/elements/progress.sass';
@import '~bulma/sass/elements/other.sass';
@import '~bulma/sass/form/_all.sass';
@import '~bulma/sass/layout/_all.sass';
@import '~bulma/sass/grid/columns.sass';

23
src/styles/index.css Normal file
View File

@@ -0,0 +1,23 @@
@import 'tailwindcss';
@plugin "daisyui" {
themes: vaquant --default, light, dark;
}
@plugin "daisyui/theme" {
name: 'vaquant';
default: true;
color-scheme: light;
--color-primary: #3f4fa6;
--color-primary-content: #ffffff;
--color-secondary: #2c3e50;
--color-secondary-content: #ffffff;
--color-success: oklch(67% 0.17 145);
--color-error: oklch(60% 0.21 25);
--color-base-100: #ffffff;
--color-base-200: #f5f5f7;
--color-base-300: #e5e5ea;
--color-base-content: #1c1c1e;
}
@import '@tabler/icons-webfont/dist/tabler-icons.min.css';

View File

@@ -1,155 +0,0 @@
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
@charset 'utf-8';
@import url('https://fonts.googleapis.com/css?family=Nunito|Raleway|Open+Sans&display=swap');
@import url('https://css.gg/css?=|pin-alt');
@import './framework';
@import './transitions';
:root {
--primary-color: #{$primary};
--primary-font-color: #{$main};
}
html {
overflow-y: auto;
min-height: 100vh;
user-select: none;
}
body {
background-color: white;
}
.app {
color: $main;
text-align: center;
.field-body {
flex-grow: 3;
}
}
nav {
&.nav {
a {
color: $main;
font-weight: bold;
&.router-link-exact-active {
color: $blue;
}
}
}
}
.app,
a,
button {
font-family: 'Nunito', sans-serif;
}
.no-margin {
margin: 0 0.75rem;
}
.clear-margin {
margin: 0;
}
.no-padding {
padding: 0.75rem 0;
}
.numeric {
font-family: 'Cutive Mono', monospace;
}
$navbar-color: #eaeaea4d;
.navbar-item.nav-button {
border: 2px solid $navbar-color;
border-radius: 10px;
margin: 5px;
}
.title,
.subtitle,
tr {
color: $main;
font-family: 'Raleway', sans-serif;
}
img {
&.logo {
height: auto;
margin: 10px 0;
width: 180pt;
}
}
.icon {
height: auto;
width: 50px;
}
.icon.is-left {
display: flex;
justify-content: center;
align-items: center;
}
.back-icon {
vertical-align: middle;
}
.breadcrumb {
font-size: 16pt;
text-align: center;
ul {
justify-content: center;
}
}
// Service Worker update
.notify-worker {
background-color: $main;
border-radius: 2px;
bottom: 30px;
color: $white;
margin: auto;
max-width: 450px;
min-width: 250px;
padding: 16px;
position: fixed;
text-align: center;
visibility: hidden;
width: 100%;
z-index: 1;
&.show {
animation: fadein 0.5s;
visibility: visible;
}
button {
margin-top: 10px;
}
}
table {
.total {
font-variant: small-caps;
}
}
@keyframes fadein {
from {
bottom: 0;
opacity: 0;
}
to {
bottom: 30px;
opacity: 1;
}
}

View File

@@ -1,12 +0,0 @@
// sass-lint:disable no-transition-all final-newline
.fade-enter-active,
.fade-leave-active {
transition-duration: .1s;
transition-property: opacity;
transition-timing-function: ease;
}
.fade-enter,
.fade-leave-active {
opacity: 0;
}

View File

@@ -1,9 +0,0 @@
// sass-lint:disable final-newline empty-line-between-blocks class-name-format no-url-protocols no-url-domains nesting-depth
$primary: #3f4fa6;
$main: #2c3e50;
$green: hsl(141, 63%, 44%);
$red: hsl(348, 54%, 52%);
$white: #fff;
$progress-border-radius: 0;
@import '~bulma/sass/utilities/_all.sass';

View File

@@ -1,7 +1,14 @@
import Vue from 'vue' import mitt from 'mitt'
export default new Vue() export const ONLINE = 'ONLINE'
export const OFFLINE = 'OFFLINE'
export const SYNC = 'SYNC'
export const ONLINE: string = 'ONLINE' type Events = {
export const OFFLINE: string = 'OFFLINE' [ONLINE]: undefined
export const SYNC: string = 'SYNC' [OFFLINE]: undefined
[SYNC]: string[] | undefined
}
export const bus = mitt<Events>()
export default bus

View File

@@ -1,14 +1,13 @@
import ICurrency from '@/models/ICurrency' import type ICurrency from '@/models/ICurrency'
export const money = ( export const money = (
value: number, value: number,
currency: ICurrency | null | undefined, currency: ICurrency | null | undefined,
country: string = 'fr-FR' country = 'fr-FR'
): string => { ): string => {
if (!currency) { if (!currency) {
return value.toString() return value.toString()
} }
return new Intl.NumberFormat(country, { return new Intl.NumberFormat(country, {
style: 'currency', style: 'currency',
currency: currency.code, currency: currency.code,
@@ -19,36 +18,24 @@ export const money = (
export const moneypad = ( export const moneypad = (
value: number, value: number,
currency?: ICurrency | null, currency?: ICurrency | null,
withPadEnd: boolean = true withPadEnd = true
): string => { ): string => {
const m = money(value, currency) const m = money(value, currency)
const s: string[] = m.split('\xa0') const s: string[] = m.split('\xa0')
let cur: string | undefined = s.pop() let cur: string | undefined = s.pop()
if (withPadEnd) { if (withPadEnd) {
cur = (cur || '').padEnd(3, '\xa0') cur = (cur || '').padEnd(3, '\xa0')
} }
return `${s.join('\xa0')}\xa0${cur || ''}`
const result = `${s.join('\xa0')}\xa0${cur || ''}`
return result
} }
export const date = (value: string, country: string = 'fr-FR') => { export const date = (value: string, country = 'fr-FR') =>
return new Intl.DateTimeFormat(country).format(new Date(value)) new Intl.DateTimeFormat(country).format(new Date(value))
}
export const fulldate = (value: string, country: string = 'fr-FR') => { export const fulldate = (value: string, country = 'fr-FR') =>
return new Intl.DateTimeFormat(country, { new Intl.DateTimeFormat(country, {
weekday: 'long', weekday: 'long',
year: 'numeric', year: 'numeric',
month: 'long', month: 'long',
day: 'numeric' day: 'numeric'
}).format(new Date(value)) }).format(new Date(value))
}
export default {
money,
moneypad,
date,
fulldate
} as { [key: string]: (...args: any[]) => any }

View File

@@ -1,3 +0,0 @@
import { Component } from 'vue-property-decorator'
Component.registerHooks(['beforeRouteEnter', 'beforeRouteUpdate'])

View File

@@ -1,82 +1,6 @@
import Vue from 'vue' import type { App } from 'vue'
import { library } from '@fortawesome/fontawesome-svg-core' import VaquantIcon from '@/components/VaquantIcon.vue'
import {
faBalanceScale,
faBeer,
faCheck,
faChevronDown,
faChevronRight,
faCog,
faCircle,
faClock,
faToriiGate,
faUtensils,
faSubway,
faHome,
faGift,
faDollarSign,
faEuroSign,
faInbox,
faExchangeAlt,
faEquals,
faGlobeEurope,
faMinus,
faInfinity,
faHandHoldingUsd,
faShoppingBasket,
faMobile,
faMoneyBillWave,
faPenSquare,
faPlus,
faTag,
faSignal,
faSyncAlt,
faUser,
faUsers,
faUserLock,
faTimes,
faTheaterMasks,
faTrash
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
library.add( export const registerIcons = (app: App): void => {
faBalanceScale, app.component('VaquantIcon', VaquantIcon)
faBeer, }
faCheck,
faChevronDown,
faChevronRight,
faCog,
faCircle,
faClock,
faToriiGate,
faUtensils,
faSubway,
faHome,
faGift,
faDollarSign,
faEuroSign,
faExchangeAlt,
faEquals,
faGlobeEurope,
faInbox,
faMinus,
faInfinity,
faHandHoldingUsd,
faShoppingBasket,
faMobile,
faMoneyBillWave,
faPenSquare,
faPlus,
faTag,
faTrash,
faSignal,
faSyncAlt,
faUser,
faUsers,
faUserLock,
faTimes,
faTheaterMasks
)
Vue.component('awe-icon', FontAwesomeIcon)

View File

@@ -1,20 +1,26 @@
declare var navigator: any interface NetworkConnection {
effectiveType?: string
metered?: boolean
bandwidth?: number
}
interface NavigatorWithConnection {
connection?: NetworkConnection
mozConnection?: NetworkConnection
webkitConnection?: NetworkConnection
}
export const hasGoodNetwork = (): boolean => { export const hasGoodNetwork = (): boolean => {
const nav = navigator as Navigator & NavigatorWithConnection
const connection = const connection =
navigator.connection || nav.connection || nav.mozConnection || nav.webkitConnection
navigator.mozConnection ||
navigator.webkitConnection
if (connection) { if (connection) {
if (connection.effectiveType) { if (connection.effectiveType) {
const goodNetworks: string[] = ['3g', '4g'] const goodNetworks = ['3g', '4g']
return goodNetworks.includes(connection.effectiveType) return goodNetworks.includes(connection.effectiveType)
} else {
const highBandwidth: boolean =
connection.metered && (connection.bandwidth || 0) > 2
return highBandwidth
} }
return !!(connection.metered && (connection.bandwidth || 0) > 2)
} }
return true return true

View File

@@ -1,5 +1,5 @@
const ROOT: string = const ROOT: string =
process.env.NODE_ENV === 'production' import.meta.env.MODE === 'production'
? 'https://vaquant.azurewebsites.net/api' ? 'https://vaquant.azurewebsites.net/api'
: 'http://localhost:7071/api' : 'http://localhost:7071/api'

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

@@ -1,29 +1,18 @@
<template> <script setup lang="ts">
<div class="about no-margin"> import { useI18n } from 'vue-i18n'
<img class="logo" src="../assets/logo.svg" title="logo Vaquant" /> import AppResume from '@/components/AppResume.vue'
<p class="simple-description" v-t="'about.description'"></p> import logoUrl from '@/assets/logo.svg'
<hr />
<app-resume />
</div>
</template>
<script lang="ts"> const { t } = useI18n()
import { Component, Vue } from 'vue-property-decorator'
@Component({
components: {
'app-resume': () => import('@/components/AppResume.vue')
}
})
export default class About extends Vue {}
</script> </script>
<style> <template>
.simple-description { <div class="about p-6 text-center">
max-width: 450pt; <img :src="logoUrl" class="mx-auto max-w-xs" alt="logo Vaquant" />
margin: auto; <p class="simple-description mx-auto mt-6 text-justify text-lg max-w-2xl">
font-size: 16pt; {{ t('about.description') }}
text-align: justify; </p>
text-align-last: center; <hr class="my-8" />
} <AppResume />
</style> </div>
</template>

View File

@@ -1,70 +1,48 @@
<script setup lang="ts">
import { ref, onBeforeMount } from 'vue'
import { storeToRefs } from 'pinia'
import { useI18n } from 'vue-i18n'
import { useUserStore } from '@/stores/user'
import accountService from '@/services/AccountService'
import AccountList from '@/components/AccountList.vue'
import AppResume from '@/components/AppResume.vue'
import FabButton from '@/components/FabButton.vue'
import logoUrl from '@/assets/logo.svg'
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const { t } = useI18n()
const showAccounts = ref(false)
onBeforeMount(async () => {
const accounts = await accountService.getAll()
showAccounts.value = !!accounts.length
})
</script>
<template> <template>
<div class="home"> <div class="home">
<div v-if="user"> <div v-if="user" class="mt-4">
<div class="account-list-container"> <AccountList />
<account-list />
</div> </div>
</div> <div v-else class="text-center py-6">
<div v-else> <img :src="logoUrl" class="mx-auto max-w-xs" alt="logo Vaquant" />
<div> <h2 class="text-3xl font-bold mt-4 max-w-2xl mx-auto">
<img class="logo" src="../assets/logo.svg" title="logo Vaquant" />
<h2 class="title is-2">
Gérer vos budgets de groupe en toute simplicité&nbsp;! Gérer vos budgets de groupe en toute simplicité&nbsp;!
</h2> </h2>
<router-link class="btn btn-primary mt-6" :to="{ name: 'user' }">
{{ t('user.loginsignup') }}
</router-link>
<div v-if="showAccounts" class="mt-4">
<AccountList />
</div> </div>
<br /> <FabButton v-else :to="{ name: 'account-new' }" :margin="true">
<router-link <template #fulltext>créer un compte</template>
class="button is-primary" </FabButton>
:to="{ name: 'user' }" <section class="mt-12 px-4">
v-t="'user.loginsignup'" <h3 class="text-2xl font-bold mb-4">Fonctionnalités</h3>
></router-link> <AppResume />
<div class="account-list-container" v-if="showAccounts">
<account-list />
</div>
<fab-button v-else :to="{ name: 'account-new' }" :margin="true">
<span slot="fulltext">créer un compte</span>
</fab-button>
<section class="hero">
<div class="hero-body">
<div class="container">
<h3 class="title is-3">Fonctionnalités</h3>
<app-resume />
</div>
</div>
</section> </section>
</div> </div>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Vue } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import IUser from '@/models/IUser'
import AccountList from '@/components/AccountList.vue'
import accountService from '@/services/AccountService'
@Component({
components: {
'account-list': AccountList,
'app-resume': () => import('@/components/AppResume.vue'),
'fab-button': () => import('@/components/FabButton.vue')
}
})
export default class Home extends Vue {
@Getter
public user!: IUser | null
public showAccounts: boolean = false
public async created() {
const accounts = await accountService.getAll()
this.showAccounts = !!accounts.length
}
}
</script>
<style scoped>
.account-list-container {
margin-top: 15px;
}
</style>

View File

@@ -1,23 +0,0 @@
<template>
<div class="columns is-centered">
<div class="column is-half">
<h1 class="is-1 title">Offres disponibles</h1>
<h2 class="is-2 subtitle">
Vaquant simplifie vos budgets.
</h2>
<pricing-table />
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
@Component({
components: {
'pricing-table': () => import('@/components/PricingTable.vue')
}
})
export default class Princing extends Vue {
}
</script>

View File

@@ -1,18 +1,6 @@
<template> <template>
<div class="security is-centered" v-once> <div v-once class="security text-center p-6">
<h2 class="title is-2"> <h2 class="text-3xl font-bold mb-4">Comment est fait le chiffrement des données ?</h2>
Comment est fait le chiffrement des données ? <p>Le chiffrement de vos données</p>
</h2>
<p>
Le chiffrement de vos données
</p>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
@Component
export default class Security extends Vue {
}
</script>

View File

@@ -1,380 +1,264 @@
<template> <script setup lang="ts">
<div class="user no-margin"> import { ref, computed, onMounted } from 'vue'
<div v-if="user"> import { useI18n } from 'vue-i18n'
<h2 class="title is-2">{{ user.userId }}</h2> import { storeToRefs } from 'pinia'
<div class="columns is-centered"> import { useUserStore } from '@/stores/user'
<div class="column"> import type IUser from '@/models/IUser'
<lang-changer />
</div>
<div class="column">
<div class="buttons is-centered">
<button
class="button is-warning"
type="button"
@click="logout"
v-t="'user.logout'"
></button>
<button
class="button is-warning"
type="button"
@click="purge"
v-t="'user.purge'"
></button>
<confirm-button class="is-danger" @confirm="remove">
{{ $t('user.delete') }}
</confirm-button>
</div>
</div>
</div>
<hr />
<account-list />
<div>
<h3 class="subtitle is-3">Comptes cloturés</h3>
<account-list :archived="true" />
</div>
<pricing-table />
<payment-checkout :email="user.email" />
</div>
<div v-else>
<div class="tabs is-centered is-fullwidth">
<ul>
<li :class="{ 'is-active': activeTab === 'login' }">
<a
href="#"
@click.prevent="activeTab = 'login'"
v-t="'user.login'"
></a>
</li>
<li :class="{ 'is-active': activeTab === 'signup' }">
<a
href="#"
@click.prevent="activeTab = 'signup'"
v-t="'user.signup'"
></a>
</li>
</ul>
</div>
<div class="user-container">
<div v-show="activeTab === 'login'">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">pseudo</label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
name="login"
id="login"
class="input"
type="text"
@keyup.enter="signin"
v-model.trim="userId"
/>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" v-t="'user.password'"></label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
name="password"
id="password"
class="input"
type="password"
@keyup.enter="signin"
v-model="password"
/>
</p>
</div>
</div>
</div>
<button
class="button is-primary"
:class="{ 'is-loading': isLoading }"
@click="signin"
v-t="'user.login'"
></button>
</div>
<div v-show="activeTab === 'signup'">
<div class="columns is-centered">
<div class="column is-half">
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label">pseudo</label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
required
class="input"
type="text"
@keyup.enter="register"
v-model.trim="userId"
/>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" v-t="'user.password'"></label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
required
class="input"
type="password"
@keyup.enter="register"
v-model="password"
/>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" v-t="'user.confirm_password'"></label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
required
class="input"
type="password"
@keyup.enter="register"
v-model="confirmPassword"
/>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" v-t="'user.firstname'"></label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
class="input"
type="text"
@keyup.enter="register"
v-model.trim="firstname"
/>
</p>
</div>
</div>
</div>
<div class="field is-horizontal">
<div class="field-label is-normal">
<label class="label" v-t="'user.lastname'">nom</label>
</div>
<div class="field-body">
<div class="field">
<p class="control">
<input
class="input"
type="text"
@keyup.enter="register"
v-model.trim="lastname"
/>
</p>
</div>
</div>
</div>
<!-- <pricing-table first-plan="free" @choose="plan => user.premium = plan === 'premium'"/> -->
<hr />
<button
class="button is-primary"
:class="{ 'is-loading': isLoading }"
@click="register(false)"
v-t="'user.signup'"
></button>
</div>
</div>
</div>
</div>
</div>
<div class="modal" :class="{ 'is-active': show }">
<div class="modal-background"></div>
<div class="modal-card">
<section class="modal-card-body">
Voulez-vous récupérer les comptes utilisés avant d'être inscrit ?
</section>
<footer class="modal-card-foot buttons has-addons is-centered">
<button
type="button"
class="button is-success"
@click="toReplicate(true)"
>
Oui
</button>
<button type="button" class="button" @click="toReplicate(false)">
Non
</button>
</footer>
</div>
<button class="modal-close is-large" aria-label="close"></button>
</div>
</div>
</template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Action, Getter } from 'vuex-class'
import { slug } from '@/utils'
import IUser from '@/models/IUser'
import LangChanger from '@/components/LangChanger.vue'
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 { validateAndNotify } from '@/utils/validate'
import { signupFormSchema } from '@/schemas/user'
import LangChanger from '@/components/LangChanger.vue'
import AccountList from '@/components/AccountList.vue'
import ConfirmButton from '@/components/ConfirmButton.vue'
const enableAnonymous: boolean = false const props = withDefaults(defineProps<{ premail?: string }>(), { premail: '' })
@Component({ const enableAnonymous = false
components: {
LangChanger, const userStore = useUserStore()
'account-list': () => import('@/components/AccountList.vue'), const { user } = storeToRefs(userStore)
'confirm-button': () => import('@/components/ConfirmButton.vue'), const { t } = useI18n()
'pricing-table': () => import('@/components/PricingTable.vue'),
'payment-checkout': () => import('@/components/Payment.vue') const activeTab = ref<'login' | 'signup'>('login')
const email = ref('')
const password = ref('')
const confirmPassword = ref('')
const firstname = ref('')
const lastname = ref('')
const isLoading = ref(false)
const show = ref(false)
const replicate = ref(false)
const localeUserId = ref('')
const userId = computed({
get: () => localeUserId.value,
set: (val: string) => {
localeUserId.value = (val || '').toLowerCase()
} }
}) })
export default class User extends Vue {
@Prop({ type: String, required: false, default: '' })
public premail!: string
@Getter
public user!: IUser | null
@Action
public login!: any
@Action
public signup!: any
@Action
public logout!: any
@Action
public remove!: any
public activeTab: string = 'login'
public email: string = ''
public password: string = ''
public confirmPassword: string = ''
public firstname: string = ''
public lastname: string = ''
public isLoading: boolean = false
public show: boolean = false
public replicate: boolean = false
public confirmed: boolean = false
private localeUserId: string = ''
public mounted(): void { const slugEmail = computed(() => slug(email.value))
if (this.premail) {
this.email = this.premail
}
}
public async signin(): Promise<void> { onMounted(() => {
this.isLoading = true if (props.premail) {
await this.login({ email.value = props.premail
userId: this.userId, }
password: this.password
}) })
this.isLoading = false
const signin = async () => {
isLoading.value = true
await userStore.login({ userId: userId.value, password: password.value })
isLoading.value = false
} }
public toReplicate(replicate: boolean): void { const validateRegistration = (): boolean =>
this.replicate = replicate validateAndNotify(
this.show = false signupFormSchema,
this.register(true) {
} userId: userId.value,
password: password.value,
confirmPassword: confirmPassword.value
},
t
)
public async register(confirmed: boolean): Promise<void> { const register = async (confirmed: boolean) => {
if (!this.validateRegistration()) { if (!validateRegistration()) return
return
}
try { try {
if ( if (enableAnonymous && (await userService.hasAnonymousData()) && !confirmed) {
enableAnonymous && if (!show.value) show.value = true
(await userService.hasAnonymousData()) &&
!confirmed
) {
if (!this.show) {
this.show = true
}
return return
} }
} catch (error) { } catch (error) {
queueNotifService.error( queueNotifService.error(
`Une erreur est survenue à la vérification d'un compte anonyme déjà créé.` `Une erreur est survenue à la vérification d'un compte anonyme déjà créé.`
) )
// tslint:disable-next-line
console.warn({ error }) console.warn({ error })
} }
this.isLoading = true isLoading.value = true
try { try {
const user: IUser = { const newUser: IUser = {
userId: this.userId, userId: userId.value,
email: this.email, email: email.value,
premium: false, premium: false,
slugEmail: this.slugEmail, slugEmail: slugEmail.value,
firstname: this.firstname, firstname: firstname.value,
lastname: this.lastname lastname: lastname.value
} }
await this.signup({ await userStore.signup({
user, user: newUser,
password: this.password, password: password.value,
replicate: this.replicate replicate: replicate.value
}) })
} catch (error) { } catch (error) {
queueNotifService.error( queueNotifService.error(
`Une erreur est survenue à la création du compte utilisateur.` `Une erreur est survenue à la création du compte utilisateur.`
) )
// tslint:disable-next-line
console.warn({ error }) console.warn({ error })
} }
this.isLoading = false isLoading.value = false
} }
public validateRegistration(): boolean { const toReplicate = (rep: boolean) => {
if (!this.userId) { replicate.value = rep
queueNotifService.error('Un pseudo est obligatoire.') show.value = false
return false register(true)
}
if (this.password !== this.confirmPassword) {
queueNotifService.error('Les mots de passe doivent être identique.')
return false
}
return true
} }
public async purge(): Promise<void> { const logout = () => userStore.logout()
const remove = () => userStore.remove()
const purge = async () => {
await userService.purge() await userService.purge()
queueNotifService.success(this.$t('user.purgeDone').toString()) queueNotifService.success(t('user.purgeDone'))
}
public get slugEmail(): string {
return slug(this.email)
}
public set userId(newUserId: string) {
this.localeUserId = (newUserId || '').toLowerCase()
}
public get userId() {
return this.localeUserId
}
} }
</script> </script>
<style scoped> <template>
.user { <div class="user p-4">
padding: 15px 0; <div v-if="user">
} <h2 class="text-3xl font-bold text-center mb-6">{{ user.userId }}</h2>
</style> <div class="grid md:grid-cols-2 gap-6 max-w-3xl mx-auto">
<LangChanger />
<div class="flex gap-2 flex-wrap justify-center">
<button class="btn btn-warning" type="button" @click="logout">
{{ t('user.logout') }}
</button>
<button class="btn btn-warning" type="button" @click="purge">
{{ t('user.purge') }}
</button>
<ConfirmButton class="btn-error" @confirm="remove">
{{ t('user.delete') }}
</ConfirmButton>
</div>
</div>
<hr class="my-6" />
<AccountList />
<div class="mt-8">
<h3 class="text-2xl font-semibold text-center mb-4">Comptes cloturés</h3>
<AccountList :archived="true" />
</div>
</div>
<div v-else class="max-w-xl mx-auto">
<div role="tablist" class="tabs tabs-boxed mb-6">
<a
role="tab"
href="#"
class="tab"
:class="{ 'tab-active': activeTab === 'login' }"
@click.prevent="activeTab = 'login'"
>
{{ t('user.login') }}
</a>
<a
role="tab"
href="#"
class="tab"
:class="{ 'tab-active': activeTab === 'signup' }"
@click.prevent="activeTab = 'signup'"
>
{{ t('user.signup') }}
</a>
</div>
<div v-show="activeTab === 'login'" class="space-y-4">
<div class="flex items-center gap-3">
<label class="label w-24">pseudo</label>
<input
id="login"
v-model.trim="userId"
name="login"
type="text"
class="input input-bordered flex-1"
@keyup.enter="signin"
/>
</div>
<div class="flex items-center gap-3">
<label class="label w-24">{{ t('user.password') }}</label>
<input
id="password"
v-model="password"
name="password"
type="password"
class="input input-bordered flex-1"
@keyup.enter="signin"
/>
</div>
<button
class="btn btn-primary"
:class="{ 'btn-loading': isLoading }"
@click="signin"
>
{{ t('user.login') }}
</button>
</div>
<div v-show="activeTab === 'signup'" class="space-y-4">
<div class="flex items-center gap-3">
<label class="label w-32">pseudo</label>
<input
v-model.trim="userId"
required
type="text"
class="input input-bordered flex-1"
@keyup.enter="register(false)"
/>
</div>
<div class="flex items-center gap-3">
<label class="label w-32">{{ t('user.password') }}</label>
<input
v-model="password"
required
type="password"
class="input input-bordered flex-1"
@keyup.enter="register(false)"
/>
</div>
<div class="flex items-center gap-3">
<label class="label w-32">{{ t('user.confirm_password') }}</label>
<input
v-model="confirmPassword"
required
type="password"
class="input input-bordered flex-1"
@keyup.enter="register(false)"
/>
</div>
<div class="flex items-center gap-3">
<label class="label w-32">{{ t('user.firstname') }}</label>
<input
v-model.trim="firstname"
type="text"
class="input input-bordered flex-1"
@keyup.enter="register(false)"
/>
</div>
<div class="flex items-center gap-3">
<label class="label w-32">{{ t('user.lastname') }}</label>
<input
v-model.trim="lastname"
type="text"
class="input input-bordered flex-1"
@keyup.enter="register(false)"
/>
</div>
<button
class="btn btn-primary"
:class="{ 'btn-loading': isLoading }"
@click="register(false)"
>
{{ t('user.signup') }}
</button>
</div>
</div>
<dialog class="modal" :class="{ 'modal-open': show }">
<div class="modal-box">
<p>Voulez-vous récupérer les comptes utilisés avant d'être inscrit ?</p>
<div class="modal-action">
<button type="button" class="btn btn-success" @click="toReplicate(true)">Oui</button>
<button type="button" class="btn" @click="toReplicate(false)">Non</button>
</div>
</div>
</dialog>
</div>
</template>

View File

@@ -1,569 +1,323 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import { useBaseAccount } from '@/composables/useBaseAccount'
import TransactionType from '@/enums/TransactionType'
import queueNotifService from '@/services/QueueNotifService'
import accountService from '@/services/AccountService'
import balanceService from '@/services/BalanceService'
import transactionService from '@/services/TransactionService'
import exchangeService from '@/services/ExchangeService'
import couchService from '@/services/CouchService'
import type IBalance from '@/models/IBalance'
import type ICurrency from '@/models/ICurrency'
import type IRefund from '@/models/IRefund'
import type IStat from '@/models/IStat'
import type ITransaction from '@/models/ITransaction'
import type IUser from '@/models/IUser'
import type ILocation from '@/models/ILocation'
import { primary, main, findContrastColor, findDarkValue } from '@/utils'
import AccountShare from '@/components/AccountShare.vue'
import AccountTransactionList from '@/components/AccountTransactionList.vue'
import EarthMap from '@/components/EarthMap.vue'
import TagList from '@/components/TagList.vue'
import ChartBalance from '@/components/ChartBalance.vue'
import AccountSetting from '@/components/AccountSetting.vue'
import OnlineView from '@/components/OnlineView.vue'
import RefundTransaction from '@/components/RefundTransaction.vue'
import FabButton from '@/components/FabButton.vue'
const props = defineProps<{ id: string }>()
const router = useRouter()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const retrieveTransactions = ref(false)
const transactions = ref<ITransaction[]>([])
const activeTab = ref<'transaction' | 'location' | 'tag' | 'balance' | 'settings'>(
'transaction'
)
const retrievingExchange = ref(false)
const getData = async (docIds?: string[]): Promise<void> => {
if (docIds && !docIds.find((i) => i === props.id)) return
try {
if (props.id) {
account.value = await accountService.get(props.id)
if (!account.value) {
queueNotifService.error(`le compte ${props.id} n'existe pas`)
router.push({ name: 'home' })
return
}
if (account.value.isPublic) {
userStore.addAccountId(props.id)
couchService.initLive()
}
transactions.value = await transactionService.getAllByAccountId(props.id)
retrieveTransactions.value = true
if (document.documentElement) {
if (account.value.color) {
document.documentElement.style.setProperty('--primary-color', account.value.color)
document.documentElement.style.setProperty(
'--primary-font-color',
findDarkValue(account.value.color) || main
)
} else {
document.documentElement.style.setProperty('--primary-color', primary)
document.documentElement.style.setProperty('--primary-font-color', main)
}
}
userStore.setTitle(account.value.name)
}
} catch (error) {
console.error({ error })
router.push({ name: 'home' })
}
}
const { account, colorStyle } = useBaseAccount(getData)
const toggleTab = (tab: typeof activeTab.value) => {
activeTab.value = tab
}
const retrieveExchange = async () => {
if (!transactionWithoutExchange.value.length) return
retrievingExchange.value = true
try {
const list = [...transactionWithoutExchange.value]
for (const t of list) {
t.exchange = await exchangeService.get(t.mainCurrency.code, t.date)
}
await transactionService.multipleSave(list)
queueNotifService.success('Dépenses mises à jour.')
} catch (error) {
console.warn({ error })
}
retrievingExchange.value = false
}
const transactionWithoutRefund = computed(() =>
transactions.value.filter((t) => t.transactionType === TransactionType.normal)
)
const tagCount = computed(
() => new Set(transactionWithoutRefund.value.map((t) => t.tag)).size
)
const transactionWithExchange = computed(() =>
transactions.value.filter((t) => !!t.exchange)
)
const transactionWithoutExchange = computed(() =>
transactions.value.filter((t) => !t.exchange)
)
const userAlias = computed<string>(() => {
if (!account.value || !user.value) return ''
const mainUser = user.value
const u = account.value.users.find((x) => x.slugEmail === mainUser.slugEmail)
return u?.alias || ''
})
const totalCost = computed(() => {
if (!account.value) return 0
return transactionService.getTotalCost(
transactionWithExchange.value,
account.value.mainCurrency.code
)
})
const userStats = computed<IStat[]>(() => {
if (!account.value || totalCost.value === 0) return []
const acc = account.value
const list = transactionWithExchange.value
const mainCurrency: ICurrency = acc.mainCurrency
return acc.users.map((u: IUser) => {
const userCost = transactionService.getTotalCost(list, acc.mainCurrency.code, u.alias)
const percent = Math.round((userCost / totalCost.value) * 100)
return {
label: u.alias || '',
value: userCost,
percent,
balance: balanceService.getBalanceByUser(u, mainCurrency, list)
}
})
})
const locations = computed<ILocation[]>(() =>
transactions.value
.filter((t) => t.location)
.map((t) => t.location as ILocation)
)
const userBalance = computed<IBalance[]>(() => {
if (!account.value || !account.value.users) return []
const mainCurrency: ICurrency = account.value.mainCurrency
const list = transactionWithExchange.value
return account.value.users.map((u) =>
balanceService.getBalanceByUser(u, mainCurrency, list)
)
})
const userRefunds = computed<IRefund[]>(() =>
account.value ? balanceService.getRefund(userBalance.value) : []
)
const isAdmin = computed(() => {
if (!account.value || !account.value.admin || !user.value) return true
return account.value.admin.slugEmail === user.value.slugEmail
})
const isMultiUser = computed(() => !!account.value && account.value.users.length > 1)
const isEncrypted = computed(
() => !!(account.value && account.value.name.length > 30)
)
const showShare = computed(
() =>
!!account.value &&
account.value.users.length > 1 &&
retrieveTransactions.value &&
!transactions.value.length
)
const backgroundColor = computed(() => {
if (!account.value || !account.value.color) return undefined
return {
backgroundColor: account.value.color,
color: findContrastColor(account.value.color) || 'black'
}
})
</script>
<template> <template>
<section class="hero is-fullheight account-item" v-if="account"> <section v-if="account" class="account-item p-4">
<account-encrypted v-if="isEncrypted" /> <div v-if="isEncrypted" class="alert alert-warning mb-4">
<div class="account-item-container"> <span>Compte chiffré</span>
<h2 class="title is-2 account-title"> </div>
<h2 class="text-3xl font-bold text-center mb-4 account-title">
<span :style="colorStyle()">{{ account.name }}</span> <span :style="colorStyle()">{{ account.name }}</span>
</h2> </h2>
<div class="columns is-centered no-margin" v-if="showShare"> <div v-if="showShare" class="flex justify-center mb-4">
<div class="column is-one-third"> <div class="w-full md:w-1/3">
<account-share :account="account" /> <AccountShare :account="account" />
</div> </div>
</div> </div>
<article <div v-if="transactionWithoutExchange.length" class="alert alert-warning mb-4">
class="message is-warning" <div>
v-if="transactionWithoutExchange.length" <p class="font-bold">Attention</p>
> <p>
<div class="message-header">
<p>Attention</p>
</div>
<div class="message-body">
Certaines dépenses ne sont pas comptées car en attente de récupérer Certaines dépenses ne sont pas comptées car en attente de récupérer
les taux pratiqués à leur date respectives. les taux pratiqués à leur date respectives.
<ul> </p>
<li v-for="(transaction, k) in transactionWithoutExchange" :key="k"> <ul class="list-disc list-inside">
- <li v-for="(t, k) in transactionWithoutExchange" :key="k">
<router-link <router-link :to="{ name: 'transaction', params: { id: t._id } }">
:to="{ name: 'transaction', params: { id: transaction._id } }" {{ t.name }}
>{{ transaction.name }}</router-link </router-link>
>
</li> </li>
</ul> </ul>
<online-view @online="retrieveExchange"> <OnlineView @online="retrieveExchange">
<button <button
class="btn btn-success btn-lg mt-2"
:class="{ 'btn-loading': retrievingExchange }"
@click="retrieveExchange" @click="retrieveExchange"
class="button is-success is-large is-rounded"
:class="{ 'is-loading': retrievingExchange }"
> >
Récupérer les devises Récupérer les devises
</button> </button>
</online-view> </OnlineView>
</div> </div>
</article> </div>
<div class="tabs is-toggle is-fullwidth"> <div role="tablist" class="tabs tabs-boxed max-w-md mx-auto mb-4">
<ul>
<li>
<a <a
:style="colorStyle(activeTab === 'transaction')" role="tab"
href="#" href="#"
class="tab"
:class="{ 'tab-active': activeTab === 'transaction' }"
:style="colorStyle(activeTab === 'transaction')"
@click.prevent="toggleTab('transaction')" @click.prevent="toggleTab('transaction')"
> >
<awe-icon icon="dollar-sign" /> <vaquant-icon icon="currency-dollar" />
</a> </a>
</li>
<li v-if="transactions.length && locations.length">
<a <a
:style="colorStyle(activeTab === 'location')" v-if="transactions.length && locations.length"
role="tab"
href="#" href="#"
class="tab"
:class="{ 'tab-active': activeTab === 'location' }"
:style="colorStyle(activeTab === 'location')"
@click.prevent="toggleTab('location')" @click.prevent="toggleTab('location')"
> >
<awe-icon icon="globe-europe" /> <vaquant-icon icon="world" />
</a> </a>
</li>
<li v-if="transactions.length && tagCount > 1">
<a <a
:style="colorStyle(activeTab === 'tag')" v-if="transactions.length && tagCount > 1"
role="tab"
href="#" href="#"
class="tab"
:class="{ 'tab-active': activeTab === 'tag' }"
:style="colorStyle(activeTab === 'tag')"
@click.prevent="toggleTab('tag')" @click.prevent="toggleTab('tag')"
> >
<awe-icon icon="tag" /> <vaquant-icon icon="tag" />
</a> </a>
</li>
<li v-if="transactions.length && isMultiUser">
<a <a
:style="colorStyle(activeTab === 'balance')" v-if="transactions.length && isMultiUser"
role="tab"
href="#" href="#"
class="tab"
:class="{ 'tab-active': activeTab === 'balance' }"
:style="colorStyle(activeTab === 'balance')"
@click.prevent="toggleTab('balance')" @click.prevent="toggleTab('balance')"
> >
<awe-icon icon="balance-scale" /> <vaquant-icon icon="scale" />
</a> </a>
</li>
<li v-if="isAdmin">
<a <a
:style="colorStyle(activeTab === 'settings')" v-if="isAdmin"
role="tab"
href="#" href="#"
class="tab"
:class="{ 'tab-active': activeTab === 'settings' }"
:style="colorStyle(activeTab === 'settings')"
@click.prevent="toggleTab('settings')" @click.prevent="toggleTab('settings')"
> >
<awe-icon icon="cog" /> <vaquant-icon icon="settings" />
</a> </a>
</li>
</ul>
</div> </div>
<div
key="transaction" <div v-if="activeTab === 'transaction'" class="mt-4">
class="transaction-panel tab-content is-centered" <AccountTransactionList :account="account" :transactions="transactions" />
v-if="activeTab === 'transaction'"
>
<account-transaction-list
:account="account"
:transactions="transactions"
/>
</div> </div>
<div <div v-if="activeTab === 'location' && locations.length" class="mt-4">
key="location" <EarthMap :locations="locations" />
class="location-panel tab-content is-centered"
v-if="locations.length && activeTab === 'location'"
>
<earth-map :locations="locations" />
</div> </div>
<div <div v-if="activeTab === 'tag'" class="mt-4">
key="tag" <TagList :transactions="transactionWithoutRefund" :account="account" />
class="tag-panel tab-content is-centered"
v-if="activeTab === 'tag'"
>
<tag-list :transactions="transactionWithoutRefund" :account="account" />
</div> </div>
<div <div v-if="activeTab === 'balance'" class="mt-4">
key="balance" <ChartBalance :account="account" :stats="userStats" :currency="account.mainCurrency" />
class="balance-panel tab-content is-centered" <div v-if="userRefunds.length" class="grid md:grid-cols-3 gap-4 mt-4">
v-if="activeTab === 'balance'" <div v-for="refund in userRefunds" :key="`${refund.from.alias}-${refund.to.alias}`">
> <RefundTransaction :refund="refund" :account="account" />
<chart-balance
:account="account"
:stats="userStats"
:currency="account.mainCurrency"
/>
<div
v-if="userRefunds.length"
class="refund-container no-margin columns is-centered is-multiline"
>
<div
class="column is-one-fifth"
v-for="(refund, k) in userRefunds"
:key="`${refund.from.alias}-${refund.to.alias}`"
>
<refund-transaction :refund="userRefunds[k]" :account="account" />
</div> </div>
</div> </div>
<div v-else class="equilibrium no-margin"> <div v-else class="text-center text-2xl mt-6">
<br />
Le compte est à l'équilibre&nbsp;! Le compte est à l'équilibre&nbsp;!
</div> </div>
</div> </div>
<div v-if="activeTab === 'settings'" class="mt-4">
<AccountSetting :account="account" />
</div>
<div <FabButton
key="settings"
class="settings-panel tab-content is-centered"
v-if="activeTab === 'settings'"
>
<account-setting :account="account" />
</div>
</div>
<fab-button
v-if="!account.archive" v-if="!account.archive"
:to="{ name: 'transaction-new', params: { id: account._id } }" :to="{ name: 'transaction-new', params: { id: account._id } }"
:button-style="backgroundColor" :button-style="backgroundColor"
:margin="true" :margin="true"
> >
<awe-icon icon="money-bill-wave" /> <vaquant-icon icon="cash" />
<span slot="fulltext">dépense</span> <template #fulltext>dépense</template>
</fab-button> </FabButton>
</section> </section>
</template> </template>
<script lang="ts">
import { Component, Watch } from 'vue-property-decorator'
import { Action, Getter } from 'vuex-class'
import BaseAccount from '@/base-components/BaseAccount'
import bus, { SYNC } from '@/utils/bus-event'
import TransactionType from '@/enums/TransactionType'
import queueNotifService from '@/services/QueueNotifService'
import accountService from '@/services/AccountService'
import chartService from '@/services/ChartService'
import balanceService from '@/services/BalanceService'
import transactionService from '@/services/TransactionService'
import exchangeService from '@/services/ExchangeService'
import IAccount from '@/models/IAccount'
import IBalance from '@/models/IBalance'
import ICurrency from '@/models/ICurrency'
import IRefund from '@/models/IRefund'
import IStat from '@/models/IStat'
import ITransaction from '@/models/ITransaction'
import IUser from '@/models/IUser'
import { primary, main, findContrastColor, findDarkValue } from '@/utils'
import couchService from '@/services/CouchService'
@Component({
components: {
'account-share': () => import('@/components/AccountShare.vue'),
'account-transaction-list': () =>
import('@/components/AccountTransactionList.vue'),
'earth-map': () => import('@/components/EarthMap.vue'),
'tag-list': () => import('@/components/TagList.vue'),
'chart-balance': () => import('@/components/ChartBalance.vue'),
'account-setting': () => import('@/components/AccountSetting.vue'),
'online-view': () => import('@/components/OnlineView.vue'),
'refund-transaction': () => import('@/components/RefundTransaction.vue'),
'fab-button': () => import('@/components/FabButton.vue')
}
})
export default class AccountItem extends BaseAccount {
@Action
public setTitle!: any
@Action
public addAccountId!: any
public retrieveTransactions: boolean = false
public transactions: ITransaction[] = []
public activeTab: string = 'transaction'
public retrievingExchange: boolean = false
public async getData(docIds?: string[]): Promise<void> {
if (docIds && !docIds.find((i: string) => i === this.id)) {
return
}
try {
if (this.id) {
this.account = await accountService.get(this.id)
if (!this.account) {
queueNotifService.error(`le compte ${this.id} n'existe pas`)
this.$router.push({ name: 'home' })
return
}
if (this.account.isPublic) {
this.addAccountId({ accountId: this.id })
couchService.initLive()
}
this.transactions = await transactionService.getAllByAccountId(this.id)
this.retrieveTransactions = true
if (document && document.documentElement) {
if (this.account.color) {
document.documentElement.style.setProperty(
'--primary-color',
this.account.color
)
document.documentElement.style.setProperty(
'--primary-font-color',
findDarkValue(this.account.color) || main
)
} else {
document.documentElement.style.setProperty(
'--primary-color',
primary
)
document.documentElement.style.setProperty(
'--primary-font-color',
main
)
}
}
this.setTitle(this.account.name)
}
} catch (error) {
// tslint:disable-next-line
console.error({ error })
this.$router.push({ name: 'home' })
return
}
}
public toggleTab(tab: string): void {
this.activeTab = tab
}
public async retrieveExchange(): Promise<void> {
if (!this.transactionWithoutExchange.length) {
return
}
this.retrievingExchange = true
try {
const transactions: ITransaction[] = [...this.transactionWithoutExchange]
for (const transaction of transactions) {
transaction.exchange = await exchangeService.get(
transaction.mainCurrency.code,
transaction.date
)
}
await transactionService.multipleSave(transactions)
queueNotifService.success('Dépenses mises à jour.')
} catch (error) {
// tslint:disable-next-line
console.warn({ error })
}
this.retrievingExchange = false
}
public totalCostByUserAlias(alias: string): number {
if (!this.account) {
return 0
}
const transactions: ITransaction[] = this.transactionWithExchange
return transactionService.getTotalCost(
transactions,
this.account.mainCurrency.code,
alias
)
}
public get totalUserCost(): Array<{ alias: string; total: number }> {
if (!this.account) {
return []
}
return this.account.users
.map((user: IUser) => ({
alias: user.alias || '',
total: this.totalCostByUserAlias(user.alias || '')
}))
.filter((user) => user.total > 0)
}
public get showShare(): boolean {
return !!(
this.account &&
this.account.users.length > 1 &&
this.retrieveTransactions &&
!this.transactions.length
)
}
public get transactionWithoutRefund(): ITransaction[] {
if (!this.transactions) {
return []
}
return this.transactions.filter(
(transaction: ITransaction) =>
transaction.transactionType === TransactionType.normal
)
}
public get tagCount(): number {
const tags = new Set(
this.transactionWithoutRefund.map(
(transaction: ITransaction) => transaction.tag
)
)
return tags.size
}
public get transactionWithExchange(): ITransaction[] {
if (!this.transactions) {
return []
}
return this.transactions.filter(
(transaction: ITransaction) => !!transaction.exchange
)
}
public get transactionWithoutExchange(): ITransaction[] {
if (!this.transactions) {
return []
}
return this.transactions.filter(
(transaction: ITransaction) => !transaction.exchange
)
}
public get userStats(): IStat[] {
const totalCost: number = this.totalCost
if (!this.account || totalCost === 0) {
return []
}
const account: IAccount = this.account
const transactions: ITransaction[] = this.transactionWithExchange
const mainCurrency: ICurrency = account.mainCurrency
return this.account.users.map((user: IUser) => {
const userCost: number = transactionService.getTotalCost(
transactions,
account.mainCurrency.code,
user.alias
)
const percent: number = Math.round((userCost / totalCost) * 100)
return {
label: user.alias || '',
value: userCost,
percent,
balance: balanceService.getBalanceByUser(
user,
mainCurrency,
transactions
)
}
})
}
public get locations() {
return this.transactions
.filter((transaction) => transaction.location)
.map((transaction) => transaction.location)
}
public get myTotalCost(): number {
if (!this.account) {
return 0
}
const transactions: ITransaction[] = this.transactionWithExchange
return transactionService.getTotalCost(
transactions,
this.account.mainCurrency.code,
this.userAlias
)
}
public get totalCost(): number {
if (!this.account) {
return 0
}
const transactions: ITransaction[] = this.transactionWithExchange
return transactionService.getTotalCost(
transactions,
this.account.mainCurrency.code
)
}
public get userBalance(): IBalance[] {
if (!this.account || !this.account.users) {
return []
}
const mainCurrency: ICurrency = this.account.mainCurrency
const transactions: ITransaction[] = this.transactionWithExchange
return this.account.users.map((user: IUser) =>
balanceService.getBalanceByUser(user, mainCurrency, transactions)
)
}
public get userRefunds(): IRefund[] {
if (!this.account) {
return []
}
return balanceService.getRefund(this.userBalance)
}
public get isAdmin(): boolean {
if (!this.account || !this.account.admin || !this.user) {
return true
}
return this.account.admin.slugEmail === this.user.slugEmail
}
public get userAlias(): string {
if (!this.account || !this.user) {
return ''
}
const mainUser = this.user
const user = this.account.users.find(
(u: IUser) => u.slugEmail === mainUser.slugEmail
)
return (user && user.alias) || ''
}
public get isEncrypted(): boolean {
return !!(this.account && this.account.name.length > 30)
}
public get backgroundColor(): any | null {
if (!this.account || !this.account.color) {
return null
}
return {
backgroundColor: this.account.color,
color: findContrastColor(this.account.color) || 'black'
}
}
public get isMultiUser(): boolean {
return !!this.account && this.account.users.length > 1
}
}
</script>
<style lang="scss">
@import '../../styles/variables';
.account-item {
font-size: 14pt;
h2.title,
a {
color: var(--primary-font-color);
}
.account-title {
margin-top: 10px;
}
.account-title {
span,
a {
padding: 0 10px;
margin: 0 10px;
}
}
.tabs.is-toggle {
max-width: 400pt;
margin: auto;
}
.tabs.is-toggle {
a {
height: 35pt;
width: 35pt;
border-radius: 50%;
margin: auto;
border-width: 2px;
font-size: 14pt;
}
li:first-child,
li:last-child {
a {
border-radius: 50%;
}
}
}
&.hero {
&.is-fullheight {
min-height: calc(100vh - 52px);
}
}
.equilibrium {
font-size: 25pt;
color: var(--primary-font-color);
}
table.table {
margin: auto;
}
th,
.pay-by {
text-align: center;
}
.numeric {
text-align: right;
}
.table-container {
overflow-x: auto;
}
.total-panel {
font-size: 16pt;
}
}
.no-transaction {
font-size: 16pt;
}
.tab-content {
position: absolute;
width: 100%;
margin: 10px 0;
&.columns {
&:last-child {
margin-bottom: 60px;
}
}
}
.slide-left-enter,
.slide-right-leave-active {
opacity: 0;
transform: translate(15px, 0);
}
.slide-left-leave-active,
.slide-right-enter {
opacity: 0;
transform: translate(-15px, 0);
}
.table {
th,
td {
vertical-align: middle;
}
}
.account-item-container {
flex: 1;
}
</style>

View File

@@ -1,202 +1,75 @@
<template> <script setup lang="ts">
<div class="account-new no-margin"> import { ref, watch, onMounted } from 'vue'
<h1 class="title is-1" v-t="'account.create'"></h1> import { useRouter } from 'vue-router'
<form @submit.prevent="submitAccount"> import { useI18n } from 'vue-i18n'
<div class="columns is-multiline is-centered"> import { storeToRefs } from 'pinia'
<div class="column is-two-thirds"> import { useUserStore } from '@/stores/user'
<h3 class="subtitle is-3">Nom</h3>
<div class="field">
<div class="control">
<input
type="text"
class="input"
maxlength="30"
v-model="name"
required
/>
</div>
<p class="help is-primary">
{{
$tc('validation.max_char', 30 - name.length, {
max: 30 - name.length
})
}}
</p>
</div>
</div>
<div class="column is-two-thirds">
<h3 class="subtitle is-3" v-t="'account.main_currency'"></h3>
<div class="control">
<div class="select is-fullwidth">
<select
name="main-currency"
id="main-currency"
v-model="mainCurrency"
>
<option v-for="(cur, k) in currencies" :key="k" :value="cur"
>{{ cur.name }} {{ cur.symbol }}</option
>
</select>
</div>
</div>
</div>
<div class="column is-two-thirds">
<h3 class="subtitle is-3">Compte privé / public</h3>
<div class="field">
<input
id="is-public"
type="checkbox"
name="is-public"
class="switch"
v-model="isPublic"
/>
<label for="is-public">{{ isPublic ? 'public' : 'privé' }}</label>
<p
class="help is-primary"
v-t="
isPublic
? 'account.publicInformation'
: 'account.privateInformation'
"
></p>
</div>
</div>
<div class="column is-two-thirds">
<color-picker v-model="color" />
</div>
<div class="column is-two-thirds">
<account-user-new v-model="users" />
</div>
<div class="column is-two-thirds">
<h3 class="subtitle is-3 title-currency" @click="toggleCurrencyShow">
<div class="icon-container">
<awe-icon
icon="chevron-right"
:rotation="currencyShow ? 90 : undefined"
/>
</div>
{{
$tc('account.used_currencies', accountCurrencies.length, {
count: accountCurrencies.length
})
}}
</h3>
<transition name="fade">
<div class="currency-list" v-if="currencyShow">
<div class="field">
<div class="columns no-margin is-multiline">
<div
class="column is-half"
v-for="(currency, k) in currencies"
:key="k"
>
<input
class="is-checkradio is-block is-medium"
type="checkbox"
:name="`cur-${currency.code}`"
:id="`cur-${currency.code}`"
v-model="accountCurrencies"
:value="currency"
:disabled="currency.code === mainCurrency.code"
/>
<label :for="`cur-${currency.code}`"
>{{ currency.name }} ({{
currency.symbol || currency.code
}})</label
>
</div>
</div>
</div>
</div>
</transition>
</div>
</div>
<div class="columns is-centered">
<div class="column is-two-thirds">
<button type="submit" class="button is-primary is-fullwidth is-large">
<awe-icon icon="check" />
</button>
</div>
</div>
</form>
</div>
</template>
<script lang="ts">
import { Component, Vue, Watch } from 'vue-property-decorator'
import { Action, Getter } from 'vuex-class'
import currencies from '@/data/currencies' import currencies from '@/data/currencies'
import IAccount from '@/models/IAccount' import type IAccount from '@/models/IAccount'
import ICurrency from '@/models/ICurrency' import type ICurrency from '@/models/ICurrency'
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 IUser from '@/models/IUser'
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 AccountUserNew from '@/components/AccountUserNew.vue'
@Component({ const userStore = useUserStore()
components: { const { user } = storeToRefs(userStore)
'color-picker': () => import('@/components/ColorPicker.vue'), const { t } = useI18n()
'account-user-new': () => import('@/components/AccountUserNew.vue') const router = useRouter()
const name = ref('')
const mainCurrency = ref<ICurrency>(currencies[0])
const accountCurrencies = ref<ICurrency[]>([currencies[0]])
const color = ref<string | null>(null)
const users = ref<IUser[]>([])
const currencyShow = ref(false)
const isPublic = ref(false)
onMounted(() => {
if (user.value && !user.value.alias) {
user.value.alias = user.value.userId
} }
}) })
export default class AccountNew extends Vue {
@Getter
public user!: IUser | null
public name: string = ''
public currencies: ICurrency[] = currencies
public mainCurrency: ICurrency = currencies[0]
public accountCurrencies: ICurrency[] = [currencies[0]]
public color: string | null = null
public users: IUser[] = []
public currencyShow: boolean = false
public isPublic: boolean = false
public mounted(): void { const toggleCurrencyShow = () => {
if (this.user && !this.user.alias) { currencyShow.value = !currencyShow.value
this.user.alias = this.user.userId
}
} }
public toggleCurrencyShow(): void { watch(
this.currencyShow = !this.currencyShow mainCurrency,
} (currency, oldCurrency) => {
if (oldCurrency) {
public validate(users: IUser[]): boolean { accountCurrencies.value = accountCurrencies.value.filter(
if (!this.name) { (c) => c.code !== oldCurrency.code
queueNotifService.error('Le nom doit être rempli.')
return false
}
const aliases: string[] = users.map((user: IUser) =>
user.alias ? user.alias.toLowerCase() : ''
) )
if (aliases.length === 0) { }
queueNotifService.error( const cur = currencies.find((c) => c.code === currency.code)
'Au moins une personne doit être ajoutée au compte.' if (cur) {
const already = accountCurrencies.value.find((c) => c.code === cur.code)
if (!already) accountCurrencies.value.push(cur)
}
},
{ immediate: true }
)
const validate = (allUsers: IUser[]): boolean => {
const aliases = allUsers.map((u) => (u.alias ? u.alias.toLowerCase() : ''))
const userIds = user.value ? allUsers.map((u) => u.userId) : undefined
return validateAndNotify(
accountFormSchema,
{ name: name.value, aliases, userIds },
t
) )
return false
}
if (aliases.length !== new Set(aliases).size) {
queueNotifService.error('Les alias doivent être uniques.')
return false
} }
if (this.user) { const submitAccount = async () => {
const userIds: string[] = users.map((user: IUser) => user.userId) let allUsers: IUser[] = user.value ? [user.value, ...users.value] : users.value
if (userIds.length !== new Set(userIds).size) { if (!validate(allUsers)) return
queueNotifService.error('Les identifiants doivent être uniques.') allUsers = allUsers.map((u) => ({
return false
}
}
return true
}
public async submitAccount(): Promise<void> {
let users: IUser[] = this.user ? [this.user, ...this.users] : this.users
if (!this.validate(users)) {
return
}
users = users.map((u: IUser) => ({
userId: u.userId, userId: u.userId,
email: u.email, email: u.email,
slugEmail: u.slugEmail, slugEmail: u.slugEmail,
@@ -205,96 +78,137 @@ export default class AccountNew extends Vue {
firstname: u.firstname, firstname: u.firstname,
lastname: u.lastname lastname: u.lastname
})) }))
const userIds: string[] = users.map((u: IUser) => slug(u.userId)) const userIds = allUsers.map((u) => slug(u.userId))
const newAccount: IAccount = { const newAccount: IAccount = {
doctype: 'account', doctype: 'account',
name: this.name, name: name.value,
admin: this.user admin: user.value
? { ? {
userId: this.user.userId, userId: user.value.userId,
email: this.user.email, email: user.value.email,
slugEmail: this.user.slugEmail, slugEmail: user.value.slugEmail,
premium: this.user.premium, premium: user.value.premium,
alias: this.user.alias, alias: user.value.alias,
firstname: this.user.firstname, firstname: user.value.firstname,
lastname: this.user.lastname lastname: user.value.lastname
} }
: null, : null,
color: this.color, color: color.value,
users, users: allUsers,
userIds, userIds,
mainCurrency: this.mainCurrency, mainCurrency: mainCurrency.value,
currencies: this.accountCurrencies, currencies: accountCurrencies.value,
isPublic: this.isPublic isPublic: isPublic.value
} }
const response: PouchDB.Core.Response = await accountService.add(newAccount) const response = await accountService.add(newAccount)
if (response.ok) { if (response.ok) {
this.$router.push({ name: 'account', params: { id: response.id } }) router.push({ name: 'account', params: { id: response.id } })
} else { } else {
// tslint:disable-next-line:no-console
console.warn(response) console.warn(response)
queueNotifService.error( queueNotifService.error(`Une erreur s'est produite à la création d'un compte.`)
`Une erreur s'est produite à la création d'un compte.`
)
}
}
@Watch('mainCurrency', { immediate: true })
public onMainCurrencyChange(
currency: ICurrency,
oldCurrency: ICurrency
): void {
if (oldCurrency) {
this.accountCurrencies = this.accountCurrencies.filter(
(c: ICurrency) => c.code !== oldCurrency.code
)
}
const cur: ICurrency | undefined = this.currencies.find(
(c: ICurrency) => c.code === currency.code
)
if (cur) {
const already: ICurrency | undefined = this.accountCurrencies.find(
(c: ICurrency) => c.code === cur.code
)
if (!already) {
this.accountCurrencies.push(cur)
}
}
} }
} }
</script> </script>
<style lang="scss" scoped> <template>
@import '~bulma-switch'; <div class="account-new p-4 max-w-3xl mx-auto">
<h1 class="text-4xl font-bold text-center mb-6">{{ t('account.create') }}</h1>
<form class="space-y-6" @submit.prevent="submitAccount">
<div>
<h3 class="text-2xl font-semibold mb-2">Nom</h3>
<input
v-model="name"
type="text"
class="input input-bordered w-full"
maxlength="30"
required
/>
<p class="text-xs text-primary mt-1">
{{ t('validation.max_char', { max: 30 - name.length }, 30 - name.length) }}
</p>
</div>
<div>
<h3 class="text-2xl font-semibold mb-2">{{ t('account.main_currency') }}</h3>
<select
id="main-currency"
v-model="mainCurrency"
name="main-currency"
class="select select-bordered w-full"
>
<option v-for="(cur, k) in currencies" :key="k" :value="cur">
{{ cur.name }} {{ cur.symbol }}
</option>
</select>
</div>
<div>
<h3 class="text-2xl font-semibold mb-2">Compte privé / public</h3>
<label class="flex items-center gap-2 cursor-pointer">
<input
id="is-public"
v-model="isPublic"
type="checkbox"
name="is-public"
class="toggle toggle-primary"
/>
<span>{{ isPublic ? 'public' : 'privé' }}</span>
</label>
<p class="text-xs text-primary mt-1">
{{ isPublic ? t('account.publicInformation') : t('account.privateInformation') }}
</p>
</div>
<ColorPicker v-model="color" />
<AccountUserNew v-model="users" />
<div>
<h3
class="text-2xl font-semibold mb-2 flex items-center gap-2 cursor-pointer"
@click="toggleCurrencyShow"
>
<vaquant-icon
icon="chevron-right"
:class="{ rotated: currencyShow }"
class="transition-transform"
/>
{{ t('account.used_currencies', { count: accountCurrencies.length }, accountCurrencies.length) }}
</h3>
<transition name="fade">
<div v-if="currencyShow" class="currency-list max-h-[50vh] overflow-y-auto">
<div class="grid md:grid-cols-2 gap-2">
<label
v-for="(currency, k) in currencies"
:key="k"
class="flex items-center gap-2 cursor-pointer"
>
<input
:id="`cur-${currency.code}`"
v-model="accountCurrencies"
type="checkbox"
:name="`cur-${currency.code}`"
:value="currency"
:disabled="currency.code === mainCurrency.code"
class="checkbox checkbox-primary"
/>
<span>{{ currency.name }} ({{ currency.symbol || currency.code }})</span>
</label>
</div>
</div>
</transition>
</div>
<button type="submit" class="btn btn-primary btn-block btn-lg">
<vaquant-icon icon="check" />
</button>
</form>
</div>
</template>
h3 { <style scoped>
max-width: 400px; .rotated {
margin: auto; transform: rotate(90deg);
}
.icon-container {
float: left;
svg {
transition: transform 0.3s cubic-bezier(0.55, 0, 0.1, 1);
}
}
.title-currency {
&:hover {
cursor: pointer;
}
}
.currency-list {
margin-top: 10px;
max-height: 50vh;
overflow-y: auto;
} }
.fade-enter-active, .fade-enter-active,
.fade-leave-active { .fade-leave-active {
transition: opacity 0.3s cubic-bezier(0.55, 0, 0.1, 1), transition: opacity 0.3s, transform 0.3s;
transform 0.3s cubic-bezier(0.55, 0, 0.1, 1);
} }
.fade-enter-from,
.fade-enter,
.fade-leave-to { .fade-leave-to {
opacity: 0; opacity: 0;
transform: translateY(-50px); transform: translateY(-50px);

View File

@@ -1,77 +1,59 @@
<template> <script setup lang="ts">
<div class="account-public">Récupération du compte public...</div> import { ref, onMounted, onBeforeUnmount } from 'vue'
</template> import { useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
<script lang="ts">
import Vue from 'vue'
import { Action, Getter } from 'vuex-class'
import { Component, Prop } from 'vue-property-decorator'
import bus, { SYNC } from '@/utils/bus-event' import bus, { SYNC } from '@/utils/bus-event'
import IUser from '@/models/IUser' import type IAccount from '@/models/IAccount'
import IAccount from '@/models/IAccount'
import queueNotifService from '@/services/QueueNotifService' import queueNotifService from '@/services/QueueNotifService'
import accountService from '@/services/AccountService' import accountService from '@/services/AccountService'
import couchService from '../../services/CouchService' import couchService from '@/services/CouchService'
@Component const props = defineProps<{ id: string }>()
export default class AccountPublic extends Vue { const router = useRouter()
@Getter public user!: IUser | null const userStore = useUserStore()
@Prop({ type: String, required: true })
public id!: string
public account: IAccount | null = null
@Action
public addAccountId!: any
public async mounted(): Promise<void> { const account = ref<IAccount | null>(null)
bus.$on(SYNC, this.goToAccount)
const goToAccount = () => {
router.push({ name: 'account', params: { id: props.id } })
}
onMounted(async () => {
bus.on(SYNC, goToAccount)
try { try {
if (this.id) { if (props.id) {
this.account = await accountService.get(this.id) account.value = await accountService.get(props.id)
if (this.account) { if (account.value) {
this.$router.push({ name: 'account', params: { id: this.id } }) router.push({ name: 'account', params: { id: props.id } })
return return
} }
} }
} catch (error) { } catch {
try { try {
this.account = await accountService.getRemote(this.id) account.value = await accountService.getRemote(props.id)
if (account.value && account.value.isPublic) {
if (this.account && this.account.isPublic) { userStore.addAccountId(props.id)
this.addAccountId({ accountId: this.id })
await couchService.initLive() await couchService.initLive()
queueNotifService.success( queueNotifService.success(
`Récupération du compte public ${this.account.name} en cours...` `Récupération du compte public ${account.value.name} en cours...`
) )
} }
} catch (err) { } catch {
queueNotifService.error( queueNotifService.error(
`Oups, le compte n'existe pas, êtes-vous sûr d'avoir récupérer le bon lien ?` `Oups, le compte n'existe pas, êtes-vous sûr d'avoir récupérer le bon lien ?`
) )
this.$router.push({ router.push({ name: 'account', params: { id: props.id } })
name: 'account', }
params: { id: this.id } }
}) })
}
}
}
public beforeDestroy() { onBeforeUnmount(() => {
bus.$off(SYNC, this.goToAccount) bus.off(SYNC, goToAccount)
}
public goToAccount() {
this.$router.push({
name: 'account',
params: { id: this.id }
}) })
}
}
</script> </script>
<style lang="scss" scoped> <template>
.account-public { <div class="account-public flex justify-center p-8">
display: flex; Récupération du compte public...
justify-content: center; </div>
flex: 1; </template>
}
</style>

View File

@@ -1,6 +1,139 @@
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { storeToRefs } from 'pinia'
import { useUserStore } from '@/stores/user'
import TransactionType from '@/enums/TransactionType'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
import bus, { SYNC } from '@/utils/bus-event'
import queueNotifService from '@/services/QueueNotifService'
import accountService from '@/services/AccountService'
import exchangeService from '@/services/ExchangeService'
import transactionService from '@/services/TransactionService'
import type IAccount from '@/models/IAccount'
import type ICurrency from '@/models/ICurrency'
import type ITransaction from '@/models/ITransaction'
import { money, moneypad, fulldate } from '@/utils/format'
import OnlineView from '@/components/OnlineView.vue'
import EarthMap from '@/components/EarthMap.vue'
import ConfirmButton from '@/components/ConfirmButton.vue'
const props = defineProps<{ id: string }>()
const router = useRouter()
const { t } = useI18n()
const userStore = useUserStore()
const { user } = storeToRefs(userStore)
const transaction = ref<ITransaction | null>(null)
const account = ref<IAccount | null>(null)
const removing = ref(false)
const noTag = TransactionTag.None
const accountId = computed(() => transaction.value?.accountId || '')
const getData = async (docIds?: string[]) => {
if (docIds && !docIds.find((i) => i === props.id)) return
try {
if (removing.value) return
transaction.value = await transactionService.get(props.id)
if (transaction.value) {
account.value = await accountService.get(accountId.value)
} else {
if (accountId.value) {
router.push({ name: 'account', params: { id: accountId.value } })
queueNotifService.error('Transaction supprimée.')
} else {
router.push({ name: 'home' })
queueNotifService.error('Compte inexistant.')
}
}
} catch (error) {
console.warn({ error })
router.push({ name: 'home' })
}
}
onMounted(() => {
getData()
bus.on(SYNC, getData)
})
onBeforeUnmount(() => {
bus.off(SYNC, getData)
})
const removeTransaction = async () => {
if (!transaction.value) return
removing.value = true
const ok = await transactionService.remove(props.id)
if (ok) {
if (accountId.value) {
router.push({ name: 'account', params: { id: accountId.value } })
} else {
router.push({ name: 'home' })
}
}
removing.value = false
}
const retrieveExchange = async () => {
if (transaction.value && !transaction.value.exchange) {
transaction.value.exchange = await exchangeService.get(
transaction.value.mainCurrency.code,
transaction.value.date
)
await transactionService.save(transaction.value)
}
}
const getRate = (code: string): number =>
transaction.value?.exchange?.rates[code] || 1
const userAlias = computed<string | undefined>(() => {
if (!account.value || !user.value) return ''
const u = user.value
return account.value.users.map((x) => x.slugEmail).find((email) => email === u.slugEmail)
})
const isAdmin = computed(() => {
if (!account.value || !user.value) return true
return !account.value.admin || account.value.admin.slugEmail === user.value.slugEmail
})
const canModify = computed(() => {
if (!transaction.value) return false
return (
(transaction.value.transactionType === TransactionType.normal && isAdmin.value) ||
transaction.value.payBy === userAlias.value
)
})
const payForLabel = computed<string[]>(() => {
if (!transaction.value) return []
return transaction.value.payFor.map((p) => {
const part = p.weight > 1 ? `(${p.weight} ${t('transaction.weight', p.weight)})` : ''
return `${p.alias} ${part}`.trim()
})
})
const uniqueUser = computed(() => {
if (!transaction.value) return false
return transaction.value.payBy === payForLabel.value.join(',')
})
const currencies = computed<ICurrency[]>(() => {
if (!transaction.value || !transaction.value.exchange) return []
const exchange = transaction.value.exchange
return transaction.value.currencies.filter(
(c) => exchange && c.code !== exchange.base
)
})
</script>
<template> <template>
<div v-if="transaction"> <div v-if="transaction" class="transaction-item p-4">
<nav class="breadcrumb" aria-label="breadcrumbs" v-if="account"> <nav v-if="account" class="breadcrumbs text-sm mb-4" aria-label="breadcrumbs">
<ul> <ul>
<li> <li>
<router-link <router-link
@@ -9,335 +142,88 @@
params: { id: account._id }, params: { id: account._id },
hash: `#${transaction._id}` hash: `#${transaction._id}`
}" }"
>{{ account.name }}</router-link
> >
{{ account.name }}
</router-link>
</li> </li>
<li <li v-if="transaction.tag && transaction.tag !== noTag">
class="is-active"
v-if="transaction.tag && transaction.tag !== noTag"
>
<a href="#" @click.prevent>{{ <a href="#" @click.prevent>{{
transactionTagLabel[(transaction.tag || '').toLowerCase()].label TransactionTagLabel[(transaction.tag || '').toLowerCase() as TransactionTag]?.label
}}</a> }}</a>
</li> </li>
<li class="is-active"> <li>
<a href="#" @click.prevent>{{ transaction.name }}</a> <a href="#" @click.prevent>{{ transaction.name }}</a>
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="buttons is-centered" v-if="account && !account.archive"> <div v-if="account && !account.archive" class="flex justify-center gap-2 mb-4">
<router-link <router-link
v-if="canModify" v-if="canModify"
tag="button"
class="button"
:to="{ name: 'transaction-update', params: { id } }" :to="{ name: 'transaction-update', params: { id } }"
class="btn"
> >
modifier modifier
</router-link> </router-link>
<confirm-button class="is-danger" @confirm="removeTransaction"> <ConfirmButton class="btn-error" @confirm="removeTransaction">
supprimer supprimer
</confirm-button> </ConfirmButton>
</div> </div>
<div class="columns no-margin is-centered"> <div class="grid md:grid-cols-2 gap-6 max-w-4xl mx-auto">
<div class="column is-half"> <div>
<h3 class="subtitle is-3">{{ transaction.date | fulldate }}</h3> <h3 class="text-2xl font-semibold mb-2">
<div class="resume"> {{ fulldate(String(transaction.date)) }}
Dépense payée par
<span class="pay-by">{{ transaction.payBy }}</span>
<span v-if="!uniqueUser">
pour
<span class="pay-for">{{ payForLabel.join(', ') }}</span>
</span>
<span v-if="transaction.location">
à {{ transaction.location.place }}
</span>
d'une somme de
<span class="numeric">{{
transaction.amount | money(transaction.mainCurrency)
}}</span>
.
</div>
</div>
<div
v-if="transaction.exchange && currencies.length"
class="exchange column is-half"
>
<h3 class="subtitle is-3">
Taux d'échange
</h3> </h3>
<div class="columns is-centered"> <p class="resume text-lg">
<div class="column"> Dépense payée par <span class="font-bold">{{ transaction.payBy }}</span>
<table class="table is-fullwidth is-striped is-bordered"> <span v-if="!uniqueUser">
<caption> pour <span class="font-bold">{{ payForLabel.join(', ') }}</span>
<span class="numeric">{{ </span>
transaction.amount | money(transaction.mainCurrency) <span v-if="transaction.location">à {{ transaction.location.place }}</span>
}}</span> d'une somme de
<span class="numeric">{{ money(transaction.amount, transaction.mainCurrency) }}</span>.
</p>
</div>
<div v-if="transaction.exchange && currencies.length">
<h3 class="text-2xl font-semibold mb-2">Taux d'échange</h3>
<div class="grid grid-cols-3 gap-2">
<table class="table table-zebra max-w-xs mx-auto">
<caption class="font-bold">
{{ money(transaction.amount, transaction.mainCurrency) }}
</caption> </caption>
<tbody> <tbody>
<tr v-for="(currency, k) in currencies" :key="k"> <tr v-for="(c, k) in currencies" :key="k">
<td class="numeric"> <td class="numeric text-right">
{{ {{ moneypad((transaction.amount || 1) * getRate(c.code), c) }}
(transaction && transaction.amount) ||
(1 * getRate(currency.code)) | moneypad(currency)
}}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> <table class="table table-zebra max-w-xs mx-auto">
<div class="column"> <caption class="font-bold">{{ money(1, transaction.mainCurrency) }}</caption>
<table class="table is-fullwidth is-striped is-bordered">
<caption>
<span class="numeric">{{
1 | money(transaction.mainCurrency)
}}</span>
</caption>
<tbody> <tbody>
<tr v-for="(currency, k) in currencies" :key="k"> <tr v-for="(c, k) in currencies" :key="k">
<td class="numeric"> <td class="numeric text-right">{{ moneypad(getRate(c.code), c) }}</td>
{{ getRate(currency.code) | moneypad(currency) }}
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> <table class="table table-zebra max-w-xs mx-auto">
<div class="column"> <caption class="font-bold">{{ t('transaction.money', currencies.length) }}</caption>
<table
class="table is-hoverable is-fullwidth is-striped is-bordered"
>
<caption>
{{
$tc('transaction.money', currencies.length)
}}
</caption>
<tbody> <tbody>
<tr v-for="(currency, k) in currencies" :key="k"> <tr v-for="(c, k) in currencies" :key="k">
<td>{{ 1 | money(currency) }}</td> <td>{{ money(1, c) }}</td>
<td class="numeric"> <td class="numeric text-right">
{{ {{ moneypad(1 / getRate(c.code), transaction.mainCurrency) }}
(1 / getRate(currency.code))
| moneypad(transaction && transaction.mainCurrency)
}}
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
</div> <div v-else>
<div v-else class="column is-half"> <OnlineView @online="retrieveExchange" />
<online-view @online="retrieveExchange" />
Le taux d'échange n'a pas pu être récupéré. Le taux d'échange n'a pas pu être récupéré.
</div> </div>
</div> </div>
<earth-map <EarthMap v-if="transaction.location" :locations="[transaction.location]" class="mt-6" />
v-if="transaction.location"
:locations="[transaction.location]"
/>
</div> </div>
</template> </template>
<script lang="ts">
import { Component, Prop, Vue } from 'vue-property-decorator'
import { Getter } from 'vuex-class'
import TransactionType from '@/enums/TransactionType'
import bus, { SYNC } from '@/utils/bus-event'
import queueNotifService from '@/services/QueueNotifService'
import TransactionTag, { TransactionTagLabel } from '@/enums/TransactionTag'
import accountService from '@/services/AccountService'
import exchangeService from '@/services/ExchangeService'
import transactionService from '@/services/TransactionService'
import IAccount from '@/models/IAccount'
import ICurrency from '@/models/ICurrency'
import ITransaction from '@/models/ITransaction'
import IUser from '@/models/IUser'
import ISplit from '@/models/ISplit'
@Component({
components: {
'online-view': () => import('@/components/OnlineView.vue'),
'earth-map': () => import('@/components/EarthMap.vue'),
'confirm-button': () => import('@/components/ConfirmButton.vue')
}
})
export default class TransactionItem extends Vue {
@Getter
public user!: IUser | null
@Prop({ type: String, required: true })
public id!: string
public transaction: ITransaction | null = null
public account: IAccount | null = null
public noTag: string = TransactionTag.None
public transactionTagLabel: typeof TransactionTagLabel = TransactionTagLabel
public removing: boolean = false
public earth: any | null = null
public created(): void {
this.getData()
bus.$on(SYNC, this.getData)
}
public beforeDestroy(): void {
bus.$off(SYNC, this.getData)
}
public async getData(docIds?: string[]): Promise<void> {
if (docIds && !docIds.find((i: string) => i === this.id)) {
return
}
try {
if (this.removing) {
return
}
this.transaction = await transactionService.get(this.id)
if (this.transaction) {
this.account = await accountService.get(this.accountId)
} else {
if (this.accountId) {
this.$router.push({ name: 'account', params: { id: this.accountId } })
queueNotifService.error('Transaction supprimée.')
} else {
this.$router.push({ name: 'home' })
queueNotifService.error('Compte inexistant.')
}
}
} catch (error) {
// tslint:disable-next-line
console.warn({ error })
this.$router.push({ name: 'home' })
}
}
public async removeTransaction(): Promise<void> {
if (!this.transaction) {
return
}
this.removing = true
const ok: boolean = await transactionService.remove(this.id)
if (ok) {
if (this.accountId) {
this.$router.push({
name: 'account',
params: {
id: this.accountId
}
})
} else {
this.$router.push({ name: 'home' })
}
}
this.removing = false
}
public async retrieveExchange(): Promise<void> {
if (this.transaction && !this.transaction.exchange) {
this.transaction.exchange = await exchangeService.get(
this.transaction.mainCurrency.code,
this.transaction.date
)
await transactionService.save(this.transaction)
}
}
public getRate(code: string) {
return (
(this.transaction &&
this.transaction.exchange &&
this.transaction.exchange.rates[code]) ||
1
)
}
public uniqueUser() {
if (!this.transaction) {
return false
}
return this.transaction.payBy === this.payForLabel.join(',')
}
public get accountId(): string {
return (this.transaction && this.transaction.accountId) || ''
}
public get canModify(): boolean {
if (!this.transaction) {
return false
}
return (
(this.transaction.transactionType === TransactionType.normal &&
this.isAdmin) ||
this.transaction.payBy === this.userAlias
)
}
public get userAlias(): string | undefined {
if (!this.account || !this.user) {
return ''
}
const user = this.user
return this.account.users
.map((u: IUser) => u.slugEmail)
.find((email: string | undefined) => email === user.slugEmail)
}
public get isAdmin(): boolean {
if (!this.account || !this.user) {
return true
}
return (
!this.account.admin ||
this.account.admin.slugEmail === this.user.slugEmail
)
}
public get payForLabel(): string[] {
if (!this.transaction) {
return []
}
return this.transaction.payFor.map((p: ISplit) => {
const part =
p.weight > 1
? `(${p.weight} ${this.$tc('transaction.weight', p.weight)})`
: ''
return `${p.alias} ${part}`.trim()
})
}
public get currencies(): ICurrency[] {
if (!this.transaction || !this.transaction.exchange) {
return []
}
const transaction = this.transaction
const exchange = transaction.exchange
return transaction.currencies.filter(
(currency: ICurrency) => exchange && currency.code !== exchange.base
)
}
}
</script>
<style lang="scss" scoped>
@import url('https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.css');
p.resume {
font-size: 18pt;
margin: 15px 0;
}
table.table {
margin: 15px auto 0;
max-width: 350px;
}
.main-cell {
vertical-align: middle;
}
.buttons {
margin-top: 15px;
}
.pay-by,
.pay-for {
font-weight: bold;
}
.numeric {
text-align: right;
}
</style>

Some files were not shown because too many files have changed in this diff Show More