Enhance checkout flow with new components and validation
- Added AddressForm and CheckoutForm components for user input during checkout. - Implemented validation using Zod and VeeValidate for billing address fields. - Created OrderSummary and MockPayPalButton components for order confirmation and payment simulation. - Updated CartSheet and CartSidebar to navigate to the new checkout page at '/kasse'. - Introduced new API endpoints for validating checkout data and creating orders. - Enhanced user experience with responsive design and error handling. These changes complete the checkout functionality, allowing users to enter billing information, simulate payment, and confirm orders.
This commit is contained in:
295
app/utils/dateFormat.ts
Normal file
295
app/utils/dateFormat.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Date Formatting Utilities
|
||||
*
|
||||
* Provides utilities for formatting dates in German locale and converting
|
||||
* between different date formats for display and database storage.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Format a date to German format (DD.MM.YYYY)
|
||||
*
|
||||
* @param date - Date to format (Date object, ISO string, or timestamp)
|
||||
* @returns Formatted date string (e.g., "24.12.2024")
|
||||
*
|
||||
* @example
|
||||
* formatDateGerman(new Date('2024-12-24'))
|
||||
* // => "24.12.2024"
|
||||
*
|
||||
* formatDateGerman('2024-12-24T10:30:00Z')
|
||||
* // => "24.12.2024"
|
||||
*/
|
||||
export function formatDateGerman(date: Date | string | number): string {
|
||||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date to German long format (e.g., "24. Dezember 2024")
|
||||
*
|
||||
* @param date - Date to format
|
||||
* @returns Formatted date string with month name
|
||||
*
|
||||
* @example
|
||||
* formatDateGermanLong(new Date('2024-12-24'))
|
||||
* // => "24. Dezember 2024"
|
||||
*/
|
||||
export function formatDateGermanLong(date: Date | string | number): string {
|
||||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return dateObj.toLocaleDateString('de-DE', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date with time to German format (DD.MM.YYYY HH:MM)
|
||||
*
|
||||
* @param date - Date to format
|
||||
* @returns Formatted datetime string
|
||||
*
|
||||
* @example
|
||||
* formatDateTimeGerman(new Date('2024-12-24T15:30:00Z'))
|
||||
* // => "24.12.2024 15:30"
|
||||
*/
|
||||
export function formatDateTimeGerman(date: Date | string | number): string {
|
||||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return dateObj.toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a date input string (DD.MM.YYYY or YYYY-MM-DD) to ISO format (YYYY-MM-DD)
|
||||
* Suitable for database storage and HTML date inputs
|
||||
*
|
||||
* @param dateString - Date string in DD.MM.YYYY or YYYY-MM-DD format
|
||||
* @returns ISO date string (YYYY-MM-DD) or null if invalid
|
||||
*
|
||||
* @example
|
||||
* parseDateToISO('24.12.2024')
|
||||
* // => "2024-12-24"
|
||||
*
|
||||
* parseDateToISO('2024-12-24')
|
||||
* // => "2024-12-24"
|
||||
*
|
||||
* parseDateToISO('invalid')
|
||||
* // => null
|
||||
*/
|
||||
export function parseDateToISO(dateString: string): string | null {
|
||||
if (!dateString || dateString.trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check if already in ISO format (YYYY-MM-DD)
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) {
|
||||
const date = new Date(dateString)
|
||||
return isNaN(date.getTime()) ? null : dateString
|
||||
}
|
||||
|
||||
// Parse German format (DD.MM.YYYY)
|
||||
const germanMatch = dateString.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})$/)
|
||||
if (germanMatch) {
|
||||
const [, day, month, year] = germanMatch
|
||||
const paddedDay = day.padStart(2, '0')
|
||||
const paddedMonth = month.padStart(2, '0')
|
||||
const isoDate = `${year}-${paddedMonth}-${paddedDay}`
|
||||
|
||||
// Validate the date
|
||||
const date = new Date(isoDate)
|
||||
if (isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return isoDate
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ISO date string (YYYY-MM-DD) to German format (DD.MM.YYYY)
|
||||
*
|
||||
* @param isoDate - ISO date string (YYYY-MM-DD)
|
||||
* @returns German date string (DD.MM.YYYY) or empty string if invalid
|
||||
*
|
||||
* @example
|
||||
* isoToGermanDate('2024-12-24')
|
||||
* // => "24.12.2024"
|
||||
*/
|
||||
export function isoToGermanDate(isoDate: string): string {
|
||||
if (!isoDate || !/^\d{4}-\d{2}-\d{2}$/.test(isoDate)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const [year, month, day] = isoDate.split('-')
|
||||
return `${day}.${month}.${year}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a date of birth indicates a person is at least 18 years old
|
||||
*
|
||||
* @param dateOfBirth - Date of birth (Date object, ISO string, or German format)
|
||||
* @returns true if person is 18 or older
|
||||
*
|
||||
* @example
|
||||
* isAgeAtLeast18(new Date('2000-01-01'))
|
||||
* // => true (as of 2024)
|
||||
*
|
||||
* isAgeAtLeast18('01.01.2010')
|
||||
* // => false (as of 2024)
|
||||
*/
|
||||
export function isAgeAtLeast18(dateOfBirth: Date | string): boolean {
|
||||
let dateObj: Date
|
||||
|
||||
if (typeof dateOfBirth === 'string') {
|
||||
// Try parsing German format first
|
||||
const isoDate = parseDateToISO(dateOfBirth)
|
||||
if (isoDate) {
|
||||
dateObj = new Date(isoDate)
|
||||
} else {
|
||||
dateObj = new Date(dateOfBirth)
|
||||
}
|
||||
} else {
|
||||
dateObj = dateOfBirth
|
||||
}
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return false
|
||||
}
|
||||
|
||||
const today = new Date()
|
||||
const age = today.getFullYear() - dateObj.getFullYear()
|
||||
const monthDiff = today.getMonth() - dateObj.getMonth()
|
||||
const dayDiff = today.getDate() - dateObj.getDate()
|
||||
|
||||
// Check if birthday has occurred this year
|
||||
if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
|
||||
return age - 1 >= 18
|
||||
}
|
||||
|
||||
return age >= 18
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a relative date (e.g., "vor 2 Tagen", "gerade eben")
|
||||
*
|
||||
* @param date - Date to format
|
||||
* @returns Relative time string in German
|
||||
*
|
||||
* @example
|
||||
* formatRelativeDate(new Date(Date.now() - 1000 * 60 * 5))
|
||||
* // => "vor 5 Minuten"
|
||||
*/
|
||||
export function formatRelativeDate(date: Date | string | number): string {
|
||||
const dateObj = typeof date === 'string' || typeof date === 'number' ? new Date(date) : date
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - dateObj.getTime()
|
||||
const diffSecs = Math.floor(diffMs / 1000)
|
||||
const diffMins = Math.floor(diffSecs / 60)
|
||||
const diffHours = Math.floor(diffMins / 60)
|
||||
const diffDays = Math.floor(diffHours / 24)
|
||||
|
||||
if (diffSecs < 60) {
|
||||
return 'gerade eben'
|
||||
}
|
||||
|
||||
if (diffMins < 60) {
|
||||
return `vor ${diffMins} ${diffMins === 1 ? 'Minute' : 'Minuten'}`
|
||||
}
|
||||
|
||||
if (diffHours < 24) {
|
||||
return `vor ${diffHours} ${diffHours === 1 ? 'Stunde' : 'Stunden'}`
|
||||
}
|
||||
|
||||
if (diffDays < 7) {
|
||||
return `vor ${diffDays} ${diffDays === 1 ? 'Tag' : 'Tagen'}`
|
||||
}
|
||||
|
||||
if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7)
|
||||
return `vor ${weeks} ${weeks === 1 ? 'Woche' : 'Wochen'}`
|
||||
}
|
||||
|
||||
if (diffDays < 365) {
|
||||
const months = Math.floor(diffDays / 30)
|
||||
return `vor ${months} ${months === 1 ? 'Monat' : 'Monaten'}`
|
||||
}
|
||||
|
||||
const years = Math.floor(diffDays / 365)
|
||||
return `vor ${years} ${years === 1 ? 'Jahr' : 'Jahren'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get today's date in ISO format (YYYY-MM-DD)
|
||||
*
|
||||
* @returns Today's date in ISO format
|
||||
*
|
||||
* @example
|
||||
* getTodayISO()
|
||||
* // => "2024-12-24"
|
||||
*/
|
||||
export function getTodayISO(): string {
|
||||
const today = new Date()
|
||||
return today.toISOString().split('T')[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a date is in the future
|
||||
*
|
||||
* @param date - Date to validate
|
||||
* @returns true if date is in the future
|
||||
*/
|
||||
export function isDateInFuture(date: Date | string): boolean {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return false
|
||||
}
|
||||
|
||||
return dateObj.getTime() > Date.now()
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if a date is in the past
|
||||
*
|
||||
* @param date - Date to validate
|
||||
* @returns true if date is in the past
|
||||
*/
|
||||
export function isDateInPast(date: Date | string): boolean {
|
||||
const dateObj = typeof date === 'string' ? new Date(date) : date
|
||||
|
||||
if (isNaN(dateObj.getTime())) {
|
||||
return false
|
||||
}
|
||||
|
||||
return dateObj.getTime() < Date.now()
|
||||
}
|
||||
262
app/utils/errorMessages.ts
Normal file
262
app/utils/errorMessages.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Error Message Utilities
|
||||
*
|
||||
* Provides German error messages for form validation and API errors.
|
||||
* Uses informal "Du" form as per project requirements.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Common validation error messages (German)
|
||||
*/
|
||||
export const validationMessages = {
|
||||
required: 'Dieses Feld ist erforderlich',
|
||||
email: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
minLength: (min: number) => `Mindestens ${min} Zeichen erforderlich`,
|
||||
maxLength: (max: number) => `Maximal ${max} Zeichen erlaubt`,
|
||||
min: (min: number) => `Wert muss mindestens ${min} sein`,
|
||||
max: (max: number) => `Wert darf maximal ${max} sein`,
|
||||
pattern: 'Ungültiges Format',
|
||||
url: 'Bitte gib eine gültige URL ein',
|
||||
numeric: 'Nur Zahlen erlaubt',
|
||||
phoneNumber: 'Bitte gib eine gültige Telefonnummer ein',
|
||||
postCode: 'Bitte gib eine gültige Postleitzahl ein',
|
||||
dateInvalid: 'Ungültiges Datum',
|
||||
dateFuture: 'Datum darf nicht in der Zukunft liegen',
|
||||
datePast: 'Datum darf nicht in der Vergangenheit liegen',
|
||||
dateOfBirthTooYoung: 'Du musst mindestens 18 Jahre alt sein',
|
||||
passwordWeak:
|
||||
'Passwort muss mindestens 8 Zeichen, einen Großbuchstaben, einen Kleinbuchstaben und eine Zahl enthalten',
|
||||
passwordMismatch: 'Passwörter stimmen nicht überein',
|
||||
terms: 'Bitte akzeptiere die AGB',
|
||||
privacy: 'Bitte akzeptiere die Datenschutzerklärung',
|
||||
}
|
||||
|
||||
/**
|
||||
* Field-specific error messages
|
||||
*/
|
||||
export const fieldMessages = {
|
||||
salutation: {
|
||||
required: 'Bitte wähle eine Anrede',
|
||||
},
|
||||
firstName: {
|
||||
required: 'Bitte gib deinen Vornamen ein',
|
||||
minLength: 'Vorname muss mindestens 2 Zeichen lang sein',
|
||||
},
|
||||
lastName: {
|
||||
required: 'Bitte gib deinen Nachnamen ein',
|
||||
minLength: 'Nachname muss mindestens 2 Zeichen lang sein',
|
||||
},
|
||||
email: {
|
||||
required: 'Bitte gib deine E-Mail-Adresse ein',
|
||||
invalid: 'Bitte gib eine gültige E-Mail-Adresse ein',
|
||||
},
|
||||
password: {
|
||||
required: 'Bitte gib ein Passwort ein',
|
||||
weak:
|
||||
'Passwort muss mindestens 8 Zeichen, einen Großbuchstaben, einen Kleinbuchstaben und eine Zahl enthalten',
|
||||
},
|
||||
dateOfBirth: {
|
||||
required: 'Bitte gib dein Geburtsdatum ein',
|
||||
invalid: 'Ungültiges Datum',
|
||||
tooYoung: 'Du musst mindestens 18 Jahre alt sein',
|
||||
future: 'Geburtsdatum darf nicht in der Zukunft liegen',
|
||||
},
|
||||
street: {
|
||||
required: 'Bitte gib deine Straße und Hausnummer ein',
|
||||
minLength: 'Straße muss mindestens 3 Zeichen lang sein',
|
||||
},
|
||||
postCode: {
|
||||
required: 'Bitte gib deine Postleitzahl ein',
|
||||
invalid: 'Ungültige Postleitzahl',
|
||||
},
|
||||
city: {
|
||||
required: 'Bitte gib deinen Ort ein',
|
||||
minLength: 'Ort muss mindestens 2 Zeichen lang sein',
|
||||
},
|
||||
countryCode: {
|
||||
required: 'Bitte wähle ein Land',
|
||||
},
|
||||
phone: {
|
||||
invalid: 'Bitte gib eine gültige Telefonnummer ein',
|
||||
},
|
||||
quantity: {
|
||||
min: 'Menge muss mindestens 1 sein',
|
||||
max: (max: number) => `Maximal ${max} Stück verfügbar`,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* API error messages
|
||||
*/
|
||||
export const apiErrorMessages = {
|
||||
// Authentication errors
|
||||
auth: {
|
||||
invalidCredentials: 'E-Mail oder Passwort ist falsch',
|
||||
emailAlreadyExists: 'Diese E-Mail-Adresse wird bereits verwendet',
|
||||
sessionExpired: 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an',
|
||||
unauthorized: 'Du musst angemeldet sein, um diese Aktion durchzuführen',
|
||||
forbidden: 'Du hast keine Berechtigung für diese Aktion',
|
||||
},
|
||||
// Product errors
|
||||
products: {
|
||||
notFound: 'Produkt nicht gefunden',
|
||||
unavailable: 'Produkt ist nicht verfügbar',
|
||||
outOfStock: 'Produkt ist nicht mehr auf Lager',
|
||||
insufficientStock: 'Nicht genügend auf Lager',
|
||||
},
|
||||
// Cart errors
|
||||
cart: {
|
||||
notFound: 'Warenkorb nicht gefunden',
|
||||
itemNotFound: 'Artikel nicht im Warenkorb gefunden',
|
||||
empty: 'Dein Warenkorb ist leer',
|
||||
invalidQuantity: 'Ungültige Menge',
|
||||
addFailed: 'Artikel konnte nicht hinzugefügt werden',
|
||||
updateFailed: 'Artikel konnte nicht aktualisiert werden',
|
||||
removeFailed: 'Artikel konnte nicht entfernt werden',
|
||||
},
|
||||
// Order errors
|
||||
orders: {
|
||||
notFound: 'Bestellung nicht gefunden',
|
||||
createFailed: 'Bestellung konnte nicht erstellt werden',
|
||||
paymentFailed: 'Zahlung fehlgeschlagen',
|
||||
invalidAddress: 'Ungültige Lieferadresse',
|
||||
submissionFailed: 'Bestellung konnte nicht übermittelt werden',
|
||||
},
|
||||
// Generic errors
|
||||
generic: {
|
||||
networkError: 'Netzwerkfehler. Bitte überprüfe deine Internetverbindung',
|
||||
serverError: 'Ein Serverfehler ist aufgetreten. Bitte versuche es später erneut',
|
||||
validationError: 'Bitte überprüfe deine Eingaben',
|
||||
unknownError: 'Ein unbekannter Fehler ist aufgetreten',
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Format API error response to user-friendly message
|
||||
*
|
||||
* @param error - Error object from API call
|
||||
* @returns User-friendly error message in German
|
||||
*
|
||||
* @example
|
||||
* try {
|
||||
* await $fetch('/api/cart/items', { method: 'POST', body: { productId, quantity } })
|
||||
* } catch (error) {
|
||||
* const message = formatApiError(error)
|
||||
* toast.error(message)
|
||||
* }
|
||||
*/
|
||||
export function formatApiError(error: unknown): string {
|
||||
// Handle FetchError from Nuxt $fetch
|
||||
if (error && typeof error === 'object' && 'statusCode' in error) {
|
||||
const statusCode = (error as { statusCode: number }).statusCode
|
||||
const data = (error as { data?: { message?: string } }).data
|
||||
|
||||
// Use custom message from API if available
|
||||
if (data?.message) {
|
||||
return data.message
|
||||
}
|
||||
|
||||
// Map status codes to generic messages
|
||||
switch (statusCode) {
|
||||
case 400:
|
||||
return apiErrorMessages.generic.validationError
|
||||
case 401:
|
||||
return apiErrorMessages.auth.unauthorized
|
||||
case 403:
|
||||
return apiErrorMessages.auth.forbidden
|
||||
case 404:
|
||||
return 'Ressource nicht gefunden'
|
||||
case 409:
|
||||
return 'Konflikt: Diese Aktion kann nicht durchgeführt werden'
|
||||
case 422:
|
||||
return apiErrorMessages.generic.validationError
|
||||
case 429:
|
||||
return 'Zu viele Anfragen. Bitte warte einen Moment'
|
||||
case 500:
|
||||
return apiErrorMessages.generic.serverError
|
||||
case 503:
|
||||
return 'Service vorübergehend nicht verfügbar'
|
||||
default:
|
||||
return apiErrorMessages.generic.unknownError
|
||||
}
|
||||
}
|
||||
|
||||
// Handle network errors
|
||||
if (error instanceof TypeError && error.message.includes('fetch')) {
|
||||
return apiErrorMessages.generic.networkError
|
||||
}
|
||||
|
||||
// Handle Error objects with message
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
// Fallback for unknown error types
|
||||
return apiErrorMessages.generic.unknownError
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation error message for a specific field and error type
|
||||
*
|
||||
* @param field - Field name
|
||||
* @param errorType - Zod error type (e.g., 'required', 'invalid_string', 'too_small')
|
||||
* @param params - Additional parameters (e.g., min, max values)
|
||||
* @returns User-friendly validation error message
|
||||
*
|
||||
* @example
|
||||
* const message = getValidationMessage('email', 'invalid_string')
|
||||
* // => "Bitte gib eine gültige E-Mail-Adresse ein"
|
||||
*/
|
||||
export function getValidationMessage(
|
||||
field: string,
|
||||
errorType: string,
|
||||
params?: { minimum?: number; maximum?: number }
|
||||
): string {
|
||||
// Check field-specific messages first
|
||||
const fieldKey = field as keyof typeof fieldMessages
|
||||
if (fieldKey in fieldMessages) {
|
||||
const fieldMessage = fieldMessages[fieldKey]
|
||||
|
||||
// Map Zod error types to field-specific messages
|
||||
if (errorType === 'invalid_type' || errorType === 'required') {
|
||||
return fieldMessage.required || validationMessages.required
|
||||
}
|
||||
|
||||
if (errorType === 'invalid_string' || errorType === 'invalid_email') {
|
||||
return 'invalid' in fieldMessage
|
||||
? fieldMessage.invalid
|
||||
: validationMessages.email
|
||||
}
|
||||
|
||||
if (errorType === 'too_small' && 'minLength' in fieldMessage) {
|
||||
return fieldMessage.minLength
|
||||
}
|
||||
|
||||
if (errorType === 'too_small' && params?.minimum) {
|
||||
return validationMessages.minLength(params.minimum)
|
||||
}
|
||||
|
||||
if (errorType === 'too_big' && params?.maximum) {
|
||||
return validationMessages.maxLength(params.maximum)
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to generic messages
|
||||
if (errorType === 'invalid_type' || errorType === 'required') {
|
||||
return validationMessages.required
|
||||
}
|
||||
|
||||
if (errorType === 'invalid_string' || errorType === 'invalid_email') {
|
||||
return validationMessages.email
|
||||
}
|
||||
|
||||
if (errorType === 'too_small' && params?.minimum) {
|
||||
return validationMessages.minLength(params.minimum)
|
||||
}
|
||||
|
||||
if (errorType === 'too_big' && params?.maximum) {
|
||||
return validationMessages.maxLength(params.maximum)
|
||||
}
|
||||
|
||||
return validationMessages.required
|
||||
}
|
||||
Reference in New Issue
Block a user