Add shared form fields for contact details
This commit is contained in:
@@ -63,14 +63,19 @@
|
||||
<label class="label"><span class="label-text">Nom</span></label>
|
||||
<input v-model="createForm.name" type="text" class="input input-bordered" required />
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Email</span></label>
|
||||
<input v-model="createForm.email" type="email" class="input input-bordered" placeholder="ex: contact@constructeur.com" />
|
||||
</div>
|
||||
<div class="form-control mb-3">
|
||||
<label class="label"><span class="label-text">Téléphone</span></label>
|
||||
<input v-model="createForm.phone" type="text" class="input input-bordered" placeholder="ex: 01 23 45 67 89" />
|
||||
</div>
|
||||
<FieldEmail
|
||||
v-model="createForm.email"
|
||||
class="mb-3"
|
||||
label="Email"
|
||||
placeholder="ex: contact@constructeur.com"
|
||||
autocomplete="email"
|
||||
/>
|
||||
<FieldPhone
|
||||
v-model="createForm.phone"
|
||||
class="mb-3"
|
||||
label="Téléphone"
|
||||
placeholder="ex: 01 23 45 67 89"
|
||||
/>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeCreateModal">Annuler</button>
|
||||
@@ -87,6 +92,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import IconLucideChevronsUpDown from '~icons/lucide/chevrons-up-down'
|
||||
|
||||
|
||||
112
app/components/form/FieldEmail.vue
Normal file
112
app/components/form/FieldEmail.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<span v-if="required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
type="email"
|
||||
class="input input-bordered"
|
||||
:class="{ 'input-error': Boolean(errorMessage) }"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:pattern="EMAIL_INPUT_PATTERN"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="(event) => emit('focus', event)"
|
||||
/>
|
||||
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
|
||||
{{ help }}
|
||||
</p>
|
||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { normalizeEmail } from '~/utils/formatters/email'
|
||||
import { EMAIL_INPUT_PATTERN, EMAIL_VALIDATION_ERROR, emailSchema } from '~/shared/validation/email'
|
||||
|
||||
type Emits = {
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'blur', value: FocusEvent): void
|
||||
(event: 'focus', value: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
help?: string | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
autocomplete?: string
|
||||
normalizeOnBlur?: boolean
|
||||
validateOnBlur?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: 'Email',
|
||||
required: false,
|
||||
error: null,
|
||||
help: null,
|
||||
placeholder: 'ex: contact@example.com',
|
||||
disabled: false,
|
||||
name: undefined,
|
||||
id: undefined,
|
||||
autocomplete: 'email',
|
||||
normalizeOnBlur: false,
|
||||
validateOnBlur: false,
|
||||
}
|
||||
)
|
||||
|
||||
const fallbackId = useId()
|
||||
const inputId = computed(() => props.id || fallbackId)
|
||||
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
|
||||
const errorMessage = computed(() => props.error || null)
|
||||
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
|
||||
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
if (props.normalizeOnBlur) {
|
||||
const normalized = normalizeEmail(target.value)
|
||||
if (normalized !== target.value) {
|
||||
target.value = normalized
|
||||
emit('update:modelValue', normalized)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.validateOnBlur && !errorMessage.value) {
|
||||
const validation = emailSchema.validate(target.value)
|
||||
if (!validation.valid) {
|
||||
target.setCustomValidity(EMAIL_VALIDATION_ERROR)
|
||||
} else {
|
||||
target.setCustomValidity('')
|
||||
}
|
||||
}
|
||||
|
||||
emit('blur', event)
|
||||
}
|
||||
</script>
|
||||
113
app/components/form/FieldPhone.vue
Normal file
113
app/components/form/FieldPhone.vue
Normal file
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="form-control">
|
||||
<label v-if="label" class="label" :for="inputId">
|
||||
<span class="label-text">{{ label }}</span>
|
||||
<span v-if="required" class="label-text-alt text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
:id="inputId"
|
||||
:value="modelValue"
|
||||
type="tel"
|
||||
class="input input-bordered"
|
||||
:class="{ 'input-error': Boolean(errorMessage) }"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
:disabled="disabled"
|
||||
:name="name"
|
||||
:autocomplete="autocomplete"
|
||||
:pattern="PHONE_INPUT_PATTERN"
|
||||
inputmode="tel"
|
||||
:aria-invalid="Boolean(errorMessage)"
|
||||
:aria-describedby="describedBy"
|
||||
@input="onInput"
|
||||
@blur="onBlur"
|
||||
@focus="(event) => emit('focus', event)"
|
||||
/>
|
||||
<p v-if="help" :id="helpId" class="mt-2 text-xs text-gray-500">
|
||||
{{ help }}
|
||||
</p>
|
||||
<p v-if="errorMessage" :id="errorId" class="mt-2 text-xs text-error">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, useId } from 'vue'
|
||||
import { formatPhone } from '~/utils/formatters/phone'
|
||||
import { PHONE_INPUT_PATTERN, PHONE_VALIDATION_ERROR, phoneSchema } from '~/shared/validation/phone'
|
||||
|
||||
type Emits = {
|
||||
(event: 'update:modelValue', value: string): void
|
||||
(event: 'blur', value: FocusEvent): void
|
||||
(event: 'focus', value: FocusEvent): void
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string
|
||||
label?: string
|
||||
required?: boolean
|
||||
error?: string | null
|
||||
help?: string | null
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
name?: string
|
||||
id?: string
|
||||
autocomplete?: string
|
||||
normalizeOnBlur?: boolean
|
||||
validateOnBlur?: boolean
|
||||
}>(),
|
||||
{
|
||||
modelValue: '',
|
||||
label: 'Téléphone',
|
||||
required: false,
|
||||
error: null,
|
||||
help: null,
|
||||
placeholder: 'Ex: 06 00 00 00 00',
|
||||
disabled: false,
|
||||
name: undefined,
|
||||
id: undefined,
|
||||
autocomplete: 'tel',
|
||||
normalizeOnBlur: false,
|
||||
validateOnBlur: false,
|
||||
}
|
||||
)
|
||||
|
||||
const fallbackId = useId()
|
||||
const inputId = computed(() => props.id || fallbackId)
|
||||
const helpId = computed(() => (props.help ? `${inputId.value}-help` : undefined))
|
||||
const errorMessage = computed(() => props.error || null)
|
||||
const errorId = computed(() => (errorMessage.value ? `${inputId.value}-error` : undefined))
|
||||
const describedBy = computed(() => [helpId.value, errorId.value].filter(Boolean).join(' ') || undefined)
|
||||
|
||||
const onInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
}
|
||||
|
||||
const onBlur = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
|
||||
if (props.normalizeOnBlur) {
|
||||
const formatted = formatPhone(target.value)
|
||||
if (formatted !== target.value) {
|
||||
target.value = formatted
|
||||
emit('update:modelValue', formatted)
|
||||
}
|
||||
}
|
||||
|
||||
if (props.validateOnBlur && !errorMessage.value) {
|
||||
const validation = phoneSchema.validate(target.value)
|
||||
if (!validation.valid) {
|
||||
target.setCustomValidity(PHONE_VALIDATION_ERROR)
|
||||
} else {
|
||||
target.setCustomValidity('')
|
||||
}
|
||||
}
|
||||
|
||||
emit('blur', event)
|
||||
}
|
||||
</script>
|
||||
@@ -13,18 +13,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Téléphone</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.contactPhone"
|
||||
type="tel"
|
||||
placeholder="Ex: 06 00 00 00 00"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<FieldPhone v-model="form.contactPhone" required />
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
@@ -73,6 +62,8 @@
|
||||
import { toRefs } from 'vue'
|
||||
import type { PropType } from 'vue'
|
||||
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
|
||||
type SiteForm = {
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
|
||||
@@ -80,14 +80,8 @@
|
||||
<input v-model="form.name" type="text" class="input input-bordered" required />
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Email</span></label>
|
||||
<input v-model="form.email" type="email" class="input input-bordered" />
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label"><span class="label-text">Téléphone</span></label>
|
||||
<input v-model="form.phone" type="text" class="input input-bordered" />
|
||||
</div>
|
||||
<FieldEmail v-model="form.email" label="Email" />
|
||||
<FieldPhone v-model="form.phone" label="Téléphone" />
|
||||
</div>
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" @click="closeModal">Annuler</button>
|
||||
@@ -104,6 +98,8 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import FieldEmail from '~/components/form/FieldEmail.vue'
|
||||
import FieldPhone from '~/components/form/FieldPhone.vue'
|
||||
import { useConstructeurs } from '~/composables/useConstructeurs'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import IconLucidePlus from '~icons/lucide/plus'
|
||||
|
||||
@@ -212,74 +212,7 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Nom du contact</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="newSite.contactName"
|
||||
type="text"
|
||||
placeholder="Nom et prénom"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Téléphone</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="newSite.contactPhone"
|
||||
type="tel"
|
||||
placeholder="Ex: 06 00 00 00 00"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Adresse</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="newSite.contactAddress"
|
||||
type="text"
|
||||
placeholder="Adresse complète"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Code postal</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="newSite.contactPostalCode"
|
||||
type="text"
|
||||
placeholder="Code postal"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Ville</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="newSite.contactCity"
|
||||
type="text"
|
||||
placeholder="Ville"
|
||||
class="input input-bordered"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<SiteContactFormFields :form="newSite" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" @click="showAddSiteModal = false" class="btn btn-outline">
|
||||
@@ -386,6 +319,7 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, onMounted, computed } from 'vue'
|
||||
import SiteContactFormFields from '~/components/sites/SiteContactFormFields.vue'
|
||||
import { useSites } from '~/composables/useSites'
|
||||
import { useMachineTypesApi } from '~/composables/useMachineTypesApi'
|
||||
import { useMachines } from '~/composables/useMachines'
|
||||
|
||||
34
app/shared/validation/email.ts
Normal file
34
app/shared/validation/email.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { normalizeEmail } from '~/utils/formatters/email'
|
||||
|
||||
export const EMAIL_INPUT_PATTERN = '[^\s@]+'
|
||||
|
||||
const EMAIL_VALIDATION_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
export type EmailValidationResult = {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const EMAIL_VALIDATION_ERROR = 'Adresse email invalide'
|
||||
|
||||
/**
|
||||
* Minimal email schema to align validation logic across the UI.
|
||||
*/
|
||||
export const emailSchema = {
|
||||
pattern: EMAIL_VALIDATION_PATTERN,
|
||||
message: EMAIL_VALIDATION_ERROR,
|
||||
validate(value: string): EmailValidationResult {
|
||||
const normalized = normalizeEmail(value)
|
||||
if (!normalized) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (EMAIL_VALIDATION_PATTERN.test(normalized)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: false, error: EMAIL_VALIDATION_ERROR }
|
||||
},
|
||||
}
|
||||
|
||||
export const isEmailValid = (value: string): boolean => emailSchema.validate(value).valid
|
||||
36
app/shared/validation/phone.ts
Normal file
36
app/shared/validation/phone.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { normalizePhone } from '~/utils/formatters/phone'
|
||||
|
||||
/** Pattern used for the HTML input `pattern` attribute on phone fields. */
|
||||
export const PHONE_INPUT_PATTERN = '[0-9+ ]*'
|
||||
|
||||
const PHONE_VALIDATION_PATTERN = /^\+?\d{7,15}$/
|
||||
|
||||
export type PhoneValidationResult = {
|
||||
valid: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export const PHONE_VALIDATION_ERROR = 'Numéro de téléphone invalide'
|
||||
|
||||
/**
|
||||
* Lightweight phone schema mirroring the API used across the app.
|
||||
*/
|
||||
export const phoneSchema = {
|
||||
pattern: PHONE_VALIDATION_PATTERN,
|
||||
message: PHONE_VALIDATION_ERROR,
|
||||
validate(value: string): PhoneValidationResult {
|
||||
const normalized = normalizePhone(value)
|
||||
if (!normalized) {
|
||||
// Empty values are considered valid; required-ness is handled by the caller.
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
if (PHONE_VALIDATION_PATTERN.test(normalized)) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return { valid: false, error: PHONE_VALIDATION_ERROR }
|
||||
},
|
||||
}
|
||||
|
||||
export const isPhoneValid = (value: string): boolean => phoneSchema.validate(value).valid
|
||||
37
app/utils/formatters/email.ts
Normal file
37
app/utils/formatters/email.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Basic helpers around email formatting shared across components and composables.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalises an email by trimming whitespace and converting it to lowercase.
|
||||
*/
|
||||
export const normalizeEmail = (rawValue: string): string => {
|
||||
const value = (rawValue || '').trim()
|
||||
return value.toLowerCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks an email address by hiding the characters between the first and last letters
|
||||
* of the local part. Useful for UI fragments where we want partial obfuscation.
|
||||
*/
|
||||
export const maskEmail = (rawValue: string): string => {
|
||||
const value = normalizeEmail(rawValue)
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const [localPart, domain] = value.split('@')
|
||||
if (!domain) {
|
||||
return value
|
||||
}
|
||||
|
||||
if (localPart.length <= 2) {
|
||||
return `${localPart[0] ?? ''}·@${domain}`
|
||||
}
|
||||
|
||||
const start = localPart[0]
|
||||
const end = localPart.slice(-1)
|
||||
const masked = '·'.repeat(Math.max(0, localPart.length - 2))
|
||||
|
||||
return `${start}${masked}${end}@${domain}`
|
||||
}
|
||||
67
app/utils/formatters/phone.ts
Normal file
67
app/utils/formatters/phone.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Utilities to normalize and format phone numbers without relying on external libraries.
|
||||
* The helpers keep the behaviour permissive to avoid breaking existing flows while
|
||||
* still providing a single place where formatting rules live.
|
||||
*/
|
||||
|
||||
/** Matches characters that should be kept when normalising a phone number. */
|
||||
const PHONE_CHAR_PATTERN = /[^+\d]/g
|
||||
|
||||
/**
|
||||
* Normalises a phone number by trimming whitespace, removing spacing/separators and
|
||||
* converting international prefixes written with `00` to their `+` variant.
|
||||
*/
|
||||
export const normalizePhone = (rawValue: string): string => {
|
||||
const trimmed = (rawValue || '').trim()
|
||||
if (!trimmed) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const cleaned = trimmed.replace(PHONE_CHAR_PATTERN, '')
|
||||
if (cleaned.startsWith('00')) {
|
||||
return `+${cleaned.slice(2)}`
|
||||
}
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a phone number by grouping digits by two while keeping any international
|
||||
* prefix. The function remains tolerant to partially entered numbers.
|
||||
*/
|
||||
export const formatPhone = (rawValue: string): string => {
|
||||
const normalized = normalizePhone(rawValue)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const hasInternationalPrefix = normalized.startsWith('+')
|
||||
const prefix = hasInternationalPrefix ? normalized.slice(0, 1) : ''
|
||||
const digits = hasInternationalPrefix ? normalized.slice(1) : normalized
|
||||
|
||||
const groups = digits.match(/.{1,2}/g) ?? []
|
||||
const grouped = groups.join(' ')
|
||||
|
||||
return prefix ? `${prefix}${grouped ? ' ' : ''}${grouped}` : grouped
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks a phone number for display purposes by replacing the middle digits with ·.
|
||||
* Useful for UI fragments where the full number should not be exposed.
|
||||
*/
|
||||
export const maskPhone = (rawValue: string): string => {
|
||||
const normalized = normalizePhone(rawValue)
|
||||
if (!normalized) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (normalized.length <= 4) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
const start = normalized.slice(0, 2)
|
||||
const end = normalized.slice(-2)
|
||||
const maskedMiddle = '·'.repeat(Math.max(0, normalized.length - 4))
|
||||
|
||||
return `${start}${maskedMiddle}${end}`
|
||||
}
|
||||
Reference in New Issue
Block a user