Merge branch 'develop' into feat/promoters
This commit is contained in:
@@ -44,33 +44,34 @@ class PagesController < ApplicationController
|
||||
@promoter_events = current_user.events.includes(:orders, :tickets)
|
||||
.order(created_at: :desc)
|
||||
.limit(5)
|
||||
|
||||
|
||||
# Revenue metrics for promoter
|
||||
@total_revenue = current_user.events
|
||||
.joins(:orders)
|
||||
.where(orders: { status: ['paid', 'completed'] })
|
||||
.sum('orders.total_amount_cents') / 100.0
|
||||
|
||||
.where(orders: { status: [ "paid", "completed" ] })
|
||||
.sum("orders.total_amount_cents") / 100.0
|
||||
|
||||
@total_tickets_sold = current_user.events
|
||||
.joins(:tickets)
|
||||
.where(tickets: { status: 'active' })
|
||||
.where(tickets: { status: "active" })
|
||||
.count
|
||||
|
||||
@active_events_count = current_user.events.where(state: 'published').count
|
||||
@draft_events_count = current_user.events.where(state: 'draft').count
|
||||
|
||||
|
||||
@active_events_count = current_user.events.where(state: "published").count
|
||||
@draft_events_count = current_user.events.where(state: "draft").count
|
||||
|
||||
# Recent orders for promoter events
|
||||
@recent_orders = Order.joins(:event)
|
||||
.where(events: { user: current_user })
|
||||
.where(status: ['paid', 'completed'])
|
||||
.where(status: [ "paid", "completed" ])
|
||||
.includes(:event, :user, tickets: :ticket_type)
|
||||
.order(created_at: :desc)
|
||||
.limit(10)
|
||||
|
||||
|
||||
# Monthly revenue trend (last 6 months)
|
||||
@monthly_revenue = (0..5).map do |months_ago|
|
||||
start_date = months_ago.months.ago.beginning_of_month
|
||||
end_date = months_ago.months.ago.end_of_month
|
||||
<<<<<<< HEAD
|
||||
|
||||
revenue = current_user.events
|
||||
.joins(:orders)
|
||||
@@ -78,6 +79,15 @@ class PagesController < ApplicationController
|
||||
.where(orders: { created_at: start_date..end_date })
|
||||
.sum('orders.total_amount_cents') / 100.0
|
||||
|
||||
=======
|
||||
|
||||
revenue = current_user.events
|
||||
.joins(:orders)
|
||||
.where(orders: { status: [ "paid", "completed" ] })
|
||||
.where(orders: { created_at: start_date..end_date })
|
||||
.sum("orders.total_amount_cents") / 100.0
|
||||
|
||||
>>>>>>> develop
|
||||
{
|
||||
month: start_date.strftime("%B %Y"),
|
||||
revenue: revenue
|
||||
|
||||
@@ -2,13 +2,16 @@ import { Controller } from "@hotwired/stimulus"
|
||||
import slug from 'slug'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"]
|
||||
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
|
||||
static values = {
|
||||
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
||||
}
|
||||
|
||||
static lastGeocodingRequest = 0
|
||||
|
||||
connect() {
|
||||
this.geocodeTimeout = null
|
||||
this.isManualGeocodingInProgress = false
|
||||
|
||||
// Initialize map links if we have an address and coordinates already exist
|
||||
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
|
||||
@@ -43,12 +46,25 @@ export default class extends Controller {
|
||||
if (!address) {
|
||||
this.clearCoordinates()
|
||||
this.clearMapLinks()
|
||||
this.hideGeocodingSpinner()
|
||||
return
|
||||
}
|
||||
|
||||
// Show spinner after a brief delay to avoid flickering for very short typing
|
||||
const showSpinnerTimeout = setTimeout(() => {
|
||||
this.showGeocodingSpinner()
|
||||
}, 300)
|
||||
|
||||
// Debounce geocoding to avoid too many API calls
|
||||
this.geocodeTimeout = setTimeout(() => {
|
||||
this.geocodeAddressQuiet(address)
|
||||
this.geocodeTimeout = setTimeout(async () => {
|
||||
clearTimeout(showSpinnerTimeout) // Cancel spinner delay if still pending
|
||||
this.showGeocodingSpinner() // Show spinner for sure now
|
||||
|
||||
try {
|
||||
await this.geocodeAddressQuiet(address)
|
||||
} finally {
|
||||
this.hideGeocodingSpinner()
|
||||
}
|
||||
}, this.geocodeDelayValue)
|
||||
}
|
||||
|
||||
@@ -59,6 +75,7 @@ export default class extends Controller {
|
||||
return
|
||||
}
|
||||
|
||||
this.showGetCurrentLocationLoading()
|
||||
this.showLocationLoading()
|
||||
|
||||
const options = {
|
||||
@@ -87,8 +104,10 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
this.updateMapLinks()
|
||||
this.hideGetCurrentLocationLoading()
|
||||
|
||||
} catch (error) {
|
||||
this.hideGetCurrentLocationLoading()
|
||||
this.hideLocationLoading()
|
||||
let message = "Erreur lors de la récupération de la localisation."
|
||||
|
||||
@@ -105,6 +124,8 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
this.showLocationError(message)
|
||||
} finally {
|
||||
this.hideGetCurrentLocationLoading()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,8 +139,20 @@ export default class extends Controller {
|
||||
// Reverse geocode coordinates to get address
|
||||
async reverseGeocode(lat, lng) {
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Reverse geocoding response:', data) // Debug log
|
||||
|
||||
if (data && data.display_name) {
|
||||
return data.display_name
|
||||
@@ -146,7 +179,10 @@ export default class extends Controller {
|
||||
this.showLocationSuccess("Liens de carte mis à jour!")
|
||||
} else {
|
||||
// Otherwise geocode the address first
|
||||
this.geocodeAddress()
|
||||
this.showPreviewLocationLoading()
|
||||
this.geocodeAddress().finally(() => {
|
||||
this.hidePreviewLocationLoading()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +196,7 @@ export default class extends Controller {
|
||||
const address = this.addressTarget.value.trim()
|
||||
|
||||
try {
|
||||
this.isManualGeocodingInProgress = true
|
||||
this.showLocationLoading()
|
||||
const result = await this.performGeocode(address)
|
||||
|
||||
@@ -167,26 +204,47 @@ export default class extends Controller {
|
||||
this.latitudeTarget.value = result.lat
|
||||
this.longitudeTarget.value = result.lng
|
||||
this.updateMapLinks()
|
||||
this.showLocationSuccess("Coordonnées trouvées pour cette adresse!")
|
||||
|
||||
if (result.accuracy === 'exact') {
|
||||
this.showLocationSuccess("Coordonnées exactes trouvées pour cette adresse!")
|
||||
} else {
|
||||
this.showLocationSuccess(`Coordonnées approximatives trouvées: ${result.display_name}`)
|
||||
}
|
||||
} else {
|
||||
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.")
|
||||
}
|
||||
} catch (error) {
|
||||
this.showLocationError("Erreur lors de la recherche de l'adresse.")
|
||||
} finally {
|
||||
this.isManualGeocodingInProgress = false
|
||||
this.hideLocationLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// Geocode address quietly (no user feedback, for auto-geocoding)
|
||||
async geocodeAddressQuiet(address) {
|
||||
// Skip if address is too short or invalid
|
||||
if (!address || address.length < 5) {
|
||||
this.clearCoordinates()
|
||||
this.clearMapLinks()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.performGeocode(address)
|
||||
|
||||
if (result) {
|
||||
if (result && result.lat && result.lng) {
|
||||
this.latitudeTarget.value = result.lat
|
||||
this.longitudeTarget.value = result.lng
|
||||
this.updateMapLinks()
|
||||
console.log(`Auto-geocoded "${address}" to ${result.lat}, ${result.lng}`)
|
||||
|
||||
// Show success message based on accuracy
|
||||
if (result.accuracy === 'exact') {
|
||||
this.showGeocodingSuccess("Adresse géolocalisée avec précision", result.display_name)
|
||||
} else {
|
||||
this.showGeocodingSuccess("Adresse géolocalisée approximativement", result.display_name)
|
||||
}
|
||||
} else {
|
||||
// If auto-geocoding fails, show a subtle warning
|
||||
this.showGeocodingWarning(address)
|
||||
@@ -197,17 +255,101 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the actual geocoding request
|
||||
// Perform the actual geocoding request with fallback strategies
|
||||
async performGeocode(address) {
|
||||
const encodedAddress = encodeURIComponent(address)
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`)
|
||||
// Rate limiting: ensure at least 1 second between requests
|
||||
const now = Date.now()
|
||||
const timeSinceLastRequest = now - (this.constructor.lastGeocodingRequest || 0)
|
||||
if (timeSinceLastRequest < 1000) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLastRequest))
|
||||
}
|
||||
this.constructor.lastGeocodingRequest = Date.now()
|
||||
|
||||
// Try multiple geocoding strategies
|
||||
const strategies = [
|
||||
// Strategy 1: Exact address
|
||||
address,
|
||||
// Strategy 2: Street name + city (remove house number)
|
||||
address.replace(/^\d+\s*/, ''),
|
||||
// Strategy 3: Just city and postal code
|
||||
this.extractCityAndPostalCode(address)
|
||||
].filter(Boolean) // Remove null/undefined values
|
||||
|
||||
for (let i = 0; i < strategies.length; i++) {
|
||||
const searchAddress = strategies[i]
|
||||
console.log(`Geocoding attempt ${i + 1}: "${searchAddress}"`)
|
||||
|
||||
// Show progress for manual geocoding (not auto-geocoding)
|
||||
if (this.isManualGeocodingInProgress) {
|
||||
const strategyNames = ['adresse complète', 'rue et ville', 'ville seulement']
|
||||
this.showGeocodingProgress(strategyNames[i] || `stratégie ${i + 1}`, `${i + 1}/${strategies.length}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.tryGeocode(searchAddress)
|
||||
if (result) {
|
||||
console.log(`Geocoding successful with strategy ${i + 1}`)
|
||||
this.hideMessage("geocoding-progress")
|
||||
return result
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Strategy ${i + 1} failed:`, error.message)
|
||||
}
|
||||
|
||||
// Add small delay between attempts
|
||||
if (i < strategies.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
|
||||
this.hideMessage("geocoding-progress")
|
||||
|
||||
console.log('All geocoding strategies failed')
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract city and postal code from address
|
||||
extractCityAndPostalCode(address) {
|
||||
// Look for French postal code pattern (5 digits) + city
|
||||
const match = address.match(/(\d{5})\s+([^,]+)/);
|
||||
if (match) {
|
||||
return `${match[1]} ${match[2].trim()}`
|
||||
}
|
||||
|
||||
// Fallback: extract last part after comma (assume it's city)
|
||||
const parts = address.split(',')
|
||||
if (parts.length > 1) {
|
||||
return parts[parts.length - 1].trim()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Try a single geocoding request
|
||||
async tryGeocode(address) {
|
||||
const encodedAddress = encodeURIComponent(address.trim())
|
||||
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1&addressdetails=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const result = data[0]
|
||||
return {
|
||||
lat: parseFloat(result.lat).toFixed(6),
|
||||
lng: parseFloat(result.lon).toFixed(6)
|
||||
lng: parseFloat(result.lon).toFixed(6),
|
||||
display_name: result.display_name,
|
||||
accuracy: address === result.display_name ? 'exact' : 'approximate'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -236,16 +378,16 @@ export default class extends Controller {
|
||||
const encodedAddress = encodeURIComponent(address)
|
||||
|
||||
const providers = {
|
||||
google: {
|
||||
name: "Google Maps",
|
||||
url: `https://www.google.com/maps/search/${encodedAddress},16z`,
|
||||
icon: "🔍"
|
||||
},
|
||||
openstreetmap: {
|
||||
name: "OpenStreetMap",
|
||||
url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`,
|
||||
icon: "🗺️"
|
||||
},
|
||||
google: {
|
||||
name: "Google Maps",
|
||||
url: `https://www.google.com/maps/search/${encodedAddress}/@${lat},${lng},16z`,
|
||||
icon: "🔍"
|
||||
},
|
||||
apple: {
|
||||
name: "Apple Plans",
|
||||
url: `https://maps.apple.com/?address=${encodedAddress}&ll=${lat},${lng}`,
|
||||
@@ -285,10 +427,84 @@ export default class extends Controller {
|
||||
}
|
||||
}
|
||||
|
||||
// Show geocoding spinner in address input
|
||||
showGeocodingSpinner() {
|
||||
if (this.hasGeocodingSpinnerTarget) {
|
||||
this.geocodingSpinnerTarget.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
// Hide geocoding spinner in address input
|
||||
hideGeocodingSpinner() {
|
||||
if (this.hasGeocodingSpinnerTarget) {
|
||||
this.geocodingSpinnerTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state on "Ma position" button
|
||||
showGetCurrentLocationLoading() {
|
||||
if (this.hasGetCurrentLocationBtnTarget) {
|
||||
this.getCurrentLocationBtnTarget.disabled = true
|
||||
}
|
||||
if (this.hasGetCurrentLocationIconTarget) {
|
||||
this.getCurrentLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-white border-t-transparent rounded-full animate-spin"></div>'
|
||||
}
|
||||
if (this.hasGetCurrentLocationTextTarget) {
|
||||
this.getCurrentLocationTextTarget.textContent = 'Localisation...'
|
||||
}
|
||||
}
|
||||
|
||||
// Hide loading state on "Ma position" button
|
||||
hideGetCurrentLocationLoading() {
|
||||
if (this.hasGetCurrentLocationBtnTarget) {
|
||||
this.getCurrentLocationBtnTarget.disabled = false
|
||||
}
|
||||
if (this.hasGetCurrentLocationIconTarget) {
|
||||
this.getCurrentLocationIconTarget.innerHTML = '<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>'
|
||||
// Re-initialize Lucide icons
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
if (this.hasGetCurrentLocationTextTarget) {
|
||||
this.getCurrentLocationTextTarget.textContent = 'Ma position'
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state on "Prévisualiser" button
|
||||
showPreviewLocationLoading() {
|
||||
if (this.hasPreviewLocationBtnTarget) {
|
||||
this.previewLocationBtnTarget.disabled = true
|
||||
}
|
||||
if (this.hasPreviewLocationIconTarget) {
|
||||
this.previewLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-purple-700 border-t-transparent rounded-full animate-spin"></div>'
|
||||
}
|
||||
if (this.hasPreviewLocationTextTarget) {
|
||||
this.previewLocationTextTarget.textContent = 'Recherche...'
|
||||
}
|
||||
}
|
||||
|
||||
// Hide loading state on "Prévisualiser" button
|
||||
hidePreviewLocationLoading() {
|
||||
if (this.hasPreviewLocationBtnTarget) {
|
||||
this.previewLocationBtnTarget.disabled = false
|
||||
}
|
||||
if (this.hasPreviewLocationIconTarget) {
|
||||
this.previewLocationIconTarget.innerHTML = '<i data-lucide="map" class="w-3 h-3 mr-1"></i>'
|
||||
// Re-initialize Lucide icons
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
if (this.hasPreviewLocationTextTarget) {
|
||||
this.previewLocationTextTarget.textContent = 'Prévisualiser'
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
showLocationLoading() {
|
||||
this.hideAllLocationMessages()
|
||||
this.showMessage("location-loading", "Géolocalisation en cours...", "info")
|
||||
this.showMessage("location-loading", "Géolocalisation en cours...", "loading")
|
||||
}
|
||||
|
||||
// Hide loading state
|
||||
@@ -318,37 +534,115 @@ export default class extends Controller {
|
||||
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
|
||||
}
|
||||
|
||||
// Show a message with given type
|
||||
showMessage(id, message, type) {
|
||||
const colors = {
|
||||
info: "bg-blue-50 border-blue-200 text-blue-800",
|
||||
success: "bg-green-50 border-green-200 text-green-800",
|
||||
error: "bg-red-50 border-red-200 text-red-800",
|
||||
warning: "bg-yellow-50 border-yellow-200 text-yellow-800"
|
||||
}
|
||||
// Show info about approximate location
|
||||
showApproximateLocationInfo(foundLocation) {
|
||||
this.hideMessage("approximate-location-info")
|
||||
const message = `Localisation approximative trouvée: ${foundLocation}`
|
||||
this.showMessage("approximate-location-info", message, "info")
|
||||
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
|
||||
}
|
||||
|
||||
const icons = {
|
||||
info: "info",
|
||||
success: "check-circle",
|
||||
error: "alert-circle",
|
||||
warning: "alert-triangle"
|
||||
}
|
||||
// Show geocoding success with location details
|
||||
showGeocodingSuccess(title, location) {
|
||||
this.hideMessage("geocoding-success")
|
||||
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
||||
this.showMessage("geocoding-success", message, "success")
|
||||
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
|
||||
}
|
||||
|
||||
const messageHtml = `
|
||||
<div id="${id}" class="flex items-center space-x-2 p-3 ${colors[type]} border rounded-lg mb-4">
|
||||
<i data-lucide="${icons[type]}" class="w-4 h-4 flex-shrink-0"></i>
|
||||
<span class="text-sm font-medium">${message}</span>
|
||||
// Show geocoding progress with strategy info
|
||||
showGeocodingProgress(strategy, attempt) {
|
||||
this.hideMessage("geocoding-progress")
|
||||
const message = `Recherche en cours... (${attempt}/${strategy})`
|
||||
this.showMessage("geocoding-progress", message, "loading")
|
||||
}
|
||||
|
||||
// Message template configurations
|
||||
getMessageTemplate(type) {
|
||||
const templates = {
|
||||
info: {
|
||||
bgColor: "bg-blue-50",
|
||||
borderColor: "border-blue-200",
|
||||
textColor: "text-blue-800",
|
||||
icon: "info",
|
||||
iconColor: "text-blue-500"
|
||||
},
|
||||
success: {
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
textColor: "text-green-800",
|
||||
icon: "check-circle",
|
||||
iconColor: "text-green-500"
|
||||
},
|
||||
error: {
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
textColor: "text-red-800",
|
||||
icon: "alert-circle",
|
||||
iconColor: "text-red-500"
|
||||
},
|
||||
warning: {
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
textColor: "text-yellow-800",
|
||||
icon: "alert-triangle",
|
||||
iconColor: "text-yellow-500"
|
||||
},
|
||||
loading: {
|
||||
bgColor: "bg-purple-50",
|
||||
borderColor: "border-purple-200",
|
||||
textColor: "text-purple-800",
|
||||
icon: "loader-2",
|
||||
iconColor: "text-purple-500",
|
||||
animated: true
|
||||
}
|
||||
}
|
||||
return templates[type] || templates.info
|
||||
}
|
||||
|
||||
// Create dynamic message HTML using template
|
||||
createMessageHTML(id, message, type) {
|
||||
const template = this.getMessageTemplate(type)
|
||||
const animationClass = template.animated ? 'animate-spin' : ''
|
||||
|
||||
return `
|
||||
<div id="${id}" class="flex items-start space-x-3 p-4 ${template.bgColor} ${template.borderColor} border rounded-lg shadow-sm transition-all duration-200 ease-in-out">
|
||||
<div class="flex-shrink-0">
|
||||
<i data-lucide="${template.icon}" class="w-5 h-5 ${template.iconColor} ${animationClass}"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium ${template.textColor} leading-relaxed">${message}</p>
|
||||
</div>
|
||||
<button type="button" onclick="this.parentElement.remove()" class="flex-shrink-0 ${template.textColor} hover:opacity-70 transition-opacity">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Insert after the venue section header
|
||||
const venueSection = this.element.querySelector('h3')
|
||||
if (venueSection) {
|
||||
venueSection.insertAdjacentHTML('afterend', messageHtml)
|
||||
// Show a message with given type using template system
|
||||
showMessage(id, message, type) {
|
||||
// Remove existing message with same ID first
|
||||
this.hideMessage(id)
|
||||
|
||||
const messageHtml = this.createMessageHTML(id, message, type)
|
||||
|
||||
// Insert into the dedicated messages container in the venue section
|
||||
if (this.hasMessagesContainerTarget) {
|
||||
this.messagesContainerTarget.insertAdjacentHTML('beforeend', messageHtml)
|
||||
// Re-initialize Lucide icons for the new elements
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
} else {
|
||||
// Fallback: insert before the address input if messages container not found
|
||||
const addressInput = this.hasAddressTarget ? this.addressTarget.parentElement : null
|
||||
if (addressInput) {
|
||||
addressInput.insertAdjacentHTML('beforebegin', messageHtml)
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -366,5 +660,8 @@ export default class extends Controller {
|
||||
this.hideMessage("location-success")
|
||||
this.hideMessage("location-error")
|
||||
this.hideMessage("geocoding-warning")
|
||||
this.hideMessage("approximate-location-info")
|
||||
this.hideMessage("geocoding-success")
|
||||
this.hideMessage("geocoding-progress")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Event model representing nightlife events and events
|
||||
# Manages event details, location data, and publication state
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
class Event < ApplicationRecord
|
||||
# Define states for Event lifecycle management
|
||||
@@ -23,33 +23,13 @@ class Event < ApplicationRecord
|
||||
has_many :orders
|
||||
|
||||
# === Callbacks ===
|
||||
before_validation :geocode_address, if: :venue_address_changed?
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
return false if latitude.blank? || longitude.blank?
|
||||
|
||||
# Check if coordinates are exactly the fallback coordinates
|
||||
fallback_lat = 46.603354
|
||||
fallback_lng = 1.888334
|
||||
|
||||
!(latitude == fallback_lat && longitude == fallback_lng)
|
||||
end
|
||||
|
||||
# Get a user-friendly status message about geocoding
|
||||
def geocoding_status_message
|
||||
return nil if geocoding_successful?
|
||||
|
||||
"Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée."
|
||||
end
|
||||
before_validation :geocode_address, if: :should_geocode_address?
|
||||
|
||||
# Validations for Event attributes
|
||||
# Basic information
|
||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
validates :description, presence: true, length: { minimum: 10, maximum: 1000 }
|
||||
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
|
||||
validates :state, presence: true, inclusion: { in: states.keys }
|
||||
validates :image, length: { maximum: 500 } # URL or path to image
|
||||
|
||||
@@ -75,41 +55,97 @@ class Event < ApplicationRecord
|
||||
# Scope for published events ordered by start time
|
||||
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
||||
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
coordinates_look_valid?
|
||||
end
|
||||
|
||||
# Get a user-friendly status message about geocoding
|
||||
def geocoding_status_message
|
||||
return nil if geocoding_successful?
|
||||
|
||||
"Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Determine if we should perform server-side geocoding
|
||||
def should_geocode_address?
|
||||
# Don't geocode if address is blank
|
||||
return false if venue_address.blank?
|
||||
|
||||
# Don't geocode if we already have valid coordinates (likely from frontend)
|
||||
return false if coordinates_look_valid?
|
||||
|
||||
# Only geocode if address changed and we don't have coordinates
|
||||
venue_address_changed?
|
||||
end
|
||||
|
||||
# Check if the current coordinates look like they were set by frontend geocoding
|
||||
def coordinates_look_valid?
|
||||
return false if latitude.blank? || longitude.blank?
|
||||
|
||||
lat_f = latitude.to_f
|
||||
lng_f = longitude.to_f
|
||||
|
||||
# Basic sanity checks for coordinate ranges
|
||||
return false if lat_f < -90 || lat_f > 90
|
||||
return false if lng_f < -180 || lng_f > 180
|
||||
|
||||
# Check if coordinates are not the default fallback coordinates
|
||||
fallback_lat = 46.603354
|
||||
fallback_lng = 1.888334
|
||||
|
||||
# Check if coordinates are not exactly 0,0 (common invalid default)
|
||||
return false if lat_f == 0.0 && lng_f == 0.0
|
||||
|
||||
# Coordinates are valid if they're not exactly the fallback coordinates
|
||||
!(lat_f == fallback_lat && lng_f == fallback_lng)
|
||||
end
|
||||
|
||||
# Automatically geocode address to get latitude and longitude
|
||||
# This only runs when no valid coordinates are provided (fallback for non-JS users)
|
||||
def geocode_address
|
||||
return if venue_address.blank?
|
||||
|
||||
# If we already have coordinates and this is an update, try to geocode
|
||||
# If it fails, keep the existing coordinates
|
||||
Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)"
|
||||
|
||||
# Store original coordinates in case we need to fall back
|
||||
original_lat = latitude
|
||||
original_lng = longitude
|
||||
|
||||
begin
|
||||
# Use OpenStreetMap Nominatim API for geocoding
|
||||
encoded_address = URI.encode_www_form_component(venue_address.strip)
|
||||
uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1")
|
||||
|
||||
response = Net::HTTP.get_response(uri)
|
||||
|
||||
if response.code == '200'
|
||||
uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1")
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
request = Net::HTTP::Get.new(uri)
|
||||
request['User-Agent'] = 'AperoNight Event Platform/1.0 (https://aperonight.com)'
|
||||
request['Accept'] = 'application/json'
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
if response.code == "200"
|
||||
data = JSON.parse(response.body)
|
||||
|
||||
|
||||
if data.any?
|
||||
result = data.first
|
||||
self.latitude = result['lat'].to_f.round(6)
|
||||
self.longitude = result['lon'].to_f.round(6)
|
||||
Rails.logger.info "Geocoded address '#{venue_address}' to coordinates: #{latitude}, #{longitude}"
|
||||
self.latitude = result["lat"].to_f.round(6)
|
||||
self.longitude = result["lon"].to_f.round(6)
|
||||
Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}"
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# If we reach here, geocoding failed
|
||||
handle_geocoding_failure(original_lat, original_lng)
|
||||
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Geocoding failed for address '#{venue_address}': #{e.message}"
|
||||
Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}"
|
||||
handle_geocoding_failure(original_lat, original_lng)
|
||||
end
|
||||
end
|
||||
@@ -143,33 +179,33 @@ class Event < ApplicationRecord
|
||||
# Extract country/city from address and return approximate coordinates
|
||||
def get_fallback_coordinates_from_address
|
||||
address_lower = venue_address.downcase
|
||||
|
||||
|
||||
# Common French cities with approximate coordinates
|
||||
french_cities = {
|
||||
'paris' => { lat: 48.8566, lng: 2.3522 },
|
||||
'lyon' => { lat: 45.7640, lng: 4.8357 },
|
||||
'marseille' => { lat: 43.2965, lng: 5.3698 },
|
||||
'toulouse' => { lat: 43.6047, lng: 1.4442 },
|
||||
'nice' => { lat: 43.7102, lng: 7.2620 },
|
||||
'nantes' => { lat: 47.2184, lng: -1.5536 },
|
||||
'montpellier' => { lat: 43.6110, lng: 3.8767 },
|
||||
'strasbourg' => { lat: 48.5734, lng: 7.7521 },
|
||||
'bordeaux' => { lat: 44.8378, lng: -0.5792 },
|
||||
'lille' => { lat: 50.6292, lng: 3.0573 }
|
||||
"paris" => { lat: 48.8566, lng: 2.3522 },
|
||||
"lyon" => { lat: 45.7640, lng: 4.8357 },
|
||||
"marseille" => { lat: 43.2965, lng: 5.3698 },
|
||||
"toulouse" => { lat: 43.6047, lng: 1.4442 },
|
||||
"nice" => { lat: 43.7102, lng: 7.2620 },
|
||||
"nantes" => { lat: 47.2184, lng: -1.5536 },
|
||||
"montpellier" => { lat: 43.6110, lng: 3.8767 },
|
||||
"strasbourg" => { lat: 48.5734, lng: 7.7521 },
|
||||
"bordeaux" => { lat: 44.8378, lng: -0.5792 },
|
||||
"lille" => { lat: 50.6292, lng: 3.0573 }
|
||||
}
|
||||
|
||||
|
||||
# Check if any known city is mentioned in the address
|
||||
french_cities.each do |city, coords|
|
||||
if address_lower.include?(city)
|
||||
return coords
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
# Check for common country indicators
|
||||
if address_lower.include?('france') || address_lower.include?('french')
|
||||
if address_lower.include?("france") || address_lower.include?("french")
|
||||
return { lat: 46.603354, lng: 1.888334 } # Center of France
|
||||
end
|
||||
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<!-- Breadcrumb -->
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Événements', path: events_path }
|
||||
] %>
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Événements', path: events_path }
|
||||
] %>
|
||||
|
||||
<!-- Page Header -->
|
||||
<header class="flex flex-col sm:flex-row justify-between items-start sm:items-center mb-8 gap-4">
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<% content_for(:title, "Mes événements") %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="min-h-screen max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Tableau de bord', path: dashboard_path },
|
||||
{ name: 'Mes événements' }
|
||||
] %>
|
||||
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 mb-2">Mes événements</h1>
|
||||
@@ -66,7 +74,7 @@
|
||||
Complet
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
|
||||
<% if event.featured? %>
|
||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 ml-1">
|
||||
<i data-lucide="star" class="w-3 h-3 mr-1"></i>
|
||||
@@ -103,7 +111,7 @@
|
||||
<i data-lucide="download" class="w-4 h-4"></i>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= button_to promoter_event_path(event), method: :delete,
|
||||
<%= button_to promoter_event_path(event), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ?" },
|
||||
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4"></i>
|
||||
@@ -133,4 +141,4 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,9 @@
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Lieu de l'événement</h3>
|
||||
|
||||
<!-- Geocoding Messages Container -->
|
||||
<div data-event-form-target="messagesContainer" class="space-y-3 mb-6 empty:mb-0 empty:hidden"></div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
@@ -96,17 +99,28 @@
|
||||
<div>
|
||||
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="space-y-2">
|
||||
<%= form.text_field :venue_address, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris", data: { "event-form-target": "address", action: "input->event-form#addressChanged" } %>
|
||||
<div class="relative">
|
||||
<%= form.text_field :venue_address, class: "w-full px-4 py-2 pr-12 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: 1 Boulevard Poissonnière, 75002 Paris", data: { "event-form-target": "address", action: "input->event-form#addressChanged" } %>
|
||||
|
||||
<!-- Geocoding Loading Spinner -->
|
||||
<div data-event-form-target="geocodingSpinner" class="absolute right-3 top-1/2 transform -translate-y-1/2 hidden">
|
||||
<div class="w-5 h-5 border-2 border-purple-200 border-t-purple-600 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Actions -->
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button type="button" data-action="click->event-form#getCurrentLocation" class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors">
|
||||
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
|
||||
Ma position
|
||||
<button type="button" data-action="click->event-form#getCurrentLocation" data-event-form-target="getCurrentLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-white bg-green-600 rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span data-event-form-target="getCurrentLocationIcon">
|
||||
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
|
||||
</span>
|
||||
<span data-event-form-target="getCurrentLocationText">Ma position</span>
|
||||
</button>
|
||||
<button type="button" data-action="click->event-form#previewLocation" class="inline-flex items-center px-3 py-2 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors">
|
||||
<i data-lucide="map" class="w-3 h-3 mr-1"></i>
|
||||
Prévisualiser
|
||||
<button type="button" data-action="click->event-form#previewLocation" data-event-form-target="previewLocationBtn" class="inline-flex items-center px-3 py-2 text-xs font-medium text-purple-700 bg-purple-50 border border-purple-200 rounded-lg hover:bg-purple-100 transition-colors disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span data-event-form-target="previewLocationIcon">
|
||||
<i data-lucide="map" class="w-3 h-3 mr-1"></i>
|
||||
</span>
|
||||
<span data-event-form-target="previewLocationText">Prévisualiser</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +131,7 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Hidden coordinate fields for form submission -->
|
||||
<!-- Hidden coordinate fields populated by JavaScript geocoding -->
|
||||
<%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %>
|
||||
<%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>
|
||||
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
<% content_for(:title, @event.name) %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Tableau de bord', path: dashboard_path },
|
||||
{ name: 'Mes événements', path: promoter_events_path },
|
||||
{ name: @event.name }
|
||||
] %>
|
||||
|
||||
<!-- Header with actions -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center justify-between">
|
||||
@@ -27,11 +36,18 @@
|
||||
<i data-lucide="edit" class="w-4 h-4 mr-2"></i>
|
||||
Modifier
|
||||
<% end %>
|
||||
|
||||
|
||||
<% if @event.draft? %>
|
||||
<%= button_to publish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
Publier
|
||||
<% if @event.ticket_types.blank? %>
|
||||
<%= button_to publish_promoter_event_path(@event), method: :patch, disabled: true, class: "inline-flex items-center px-4 py-2 bg-gray-400 text-white font-medium rounded-lg cursor-not-allowed transition-colors duration-200", title: "Vous devez créer au moins un type de billet avant de publier" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
Publier
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%= button_to publish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-green-600 text-white font-medium rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
|
||||
<i data-lucide="upload" class="w-4 h-4 mr-2"></i>
|
||||
Publier
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% elsif @event.published? %>
|
||||
<%= button_to unpublish_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-yellow-600 text-white font-medium rounded-lg hover:bg-yellow-700 transition-colors duration-200" do %>
|
||||
@@ -39,7 +55,7 @@
|
||||
Dépublier
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
|
||||
<% if @event.published? %>
|
||||
<%= button_to cancel_promoter_event_path(@event), method: :patch, class: "inline-flex items-center px-4 py-2 bg-red-600 text-white font-medium rounded-lg hover:bg-red-700 transition-colors duration-200", data: { confirm: "Êtes-vous sûr de vouloir annuler cet événement ?" } do %>
|
||||
<i data-lucide="x-circle" class="w-4 h-4 mr-2"></i>
|
||||
@@ -63,6 +79,23 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if @event.ticket_types.blank? %>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-2xl p-4 mt-4">
|
||||
<div class="flex items-center">
|
||||
<i data-lucide="alert-triangle" class="w-5 h-5 text-amber-400 mr-3"></i>
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-amber-900">Aucun type de billet configuré</h3>
|
||||
<p class="text-sm text-amber-700">Vous devez créer au moins un type de billet avant de pouvoir publier cet événement.</p>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<%= link_to promoter_event_ticket_types_path(@event), class: "text-amber-600 hover:text-amber-800 font-medium text-sm" do %>
|
||||
Configurer les billets <i data-lucide="external-link" class="w-4 h-4 inline ml-1"></i>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% when "published" %>
|
||||
<div class="bg-green-50 border border-green-200 rounded-2xl p-4">
|
||||
<div class="flex items-center">
|
||||
@@ -99,7 +132,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
|
||||
<% if @event.featured? %>
|
||||
<div class="bg-yellow-50 border border-yellow-200 rounded-2xl p-4 mt-4">
|
||||
<div class="flex items-center">
|
||||
@@ -178,10 +211,6 @@
|
||||
<div class="bg-white rounded-2xl border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">Informations</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Slug</span>
|
||||
<p class="font-mono text-sm"><%= @event.slug %></p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-gray-500">Créé le</span>
|
||||
<p class="text-sm"><%= @event.created_at.strftime("%d/%m/%Y à %H:%M") %></p>
|
||||
@@ -218,7 +247,7 @@
|
||||
Marquer comme complet
|
||||
<% end %>
|
||||
<hr class="border-gray-200">
|
||||
<%= button_to promoter_event_path(@event), method: :delete,
|
||||
<%= button_to promoter_event_path(@event), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
|
||||
class: "w-full inline-flex items-center px-4 py-2 text-red-600 font-medium rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||
@@ -228,4 +257,4 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<% content_for(:title, "Modifier #{@ticket_type.name}") %>
|
||||
|
||||
<div class="container py-8">
|
||||
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4">
|
||||
@@ -45,7 +46,7 @@
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-yellow-900">Attention</h3>
|
||||
<p class="text-sm text-yellow-800 mt-1">
|
||||
<%= pluralize(@ticket_type.tickets.count, 'billet') %> de ce type ont déjà été vendus.
|
||||
<%= pluralize(@ticket_type.tickets.count, 'billet') %> de ce type ont déjà été vendus.
|
||||
Modifier certains paramètres pourrait impacter les acheteurs existants.
|
||||
</p>
|
||||
</div>
|
||||
@@ -56,7 +57,7 @@
|
||||
<!-- Basic Information -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
||||
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
@@ -73,14 +74,14 @@
|
||||
<!-- Pricing & Quantity -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Prix et quantité</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<div class="relative">
|
||||
<%= form.number_field :price_euros,
|
||||
step: 0.01,
|
||||
min: 0.01,
|
||||
<%= form.number_field :price_euros,
|
||||
step: 0.01,
|
||||
min: 0.01,
|
||||
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
|
||||
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
|
||||
<div class="absolute left-3 top-2.5 text-gray-500">€</div>
|
||||
@@ -92,11 +93,11 @@
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :quantity,
|
||||
min: @ticket_type.tickets.count,
|
||||
<%= form.number_field :quantity,
|
||||
min: @ticket_type.tickets.count,
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
|
||||
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
|
||||
<% if @ticket_type.tickets.any? %>
|
||||
@@ -119,7 +120,7 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="p-4 bg-green-50 rounded-lg border border-green-200">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-green-900">Revenus déjà générés</span>
|
||||
@@ -134,11 +135,11 @@
|
||||
<!-- Sales Period -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Période de vente</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :sale_start_at,
|
||||
<%= form.datetime_local_field :sale_start_at,
|
||||
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
|
||||
<% if @ticket_type.tickets.any? %>
|
||||
@@ -148,10 +149,10 @@
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<div>
|
||||
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.datetime_local_field :sale_end_at,
|
||||
<%= form.datetime_local_field :sale_end_at,
|
||||
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
|
||||
</div>
|
||||
@@ -173,13 +174,13 @@
|
||||
<!-- Access Requirements -->
|
||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Conditions d'accès</h3>
|
||||
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||
<%= form.number_field :minimum_age,
|
||||
min: 0,
|
||||
max: 120,
|
||||
<%= form.number_field :minimum_age,
|
||||
min: 0,
|
||||
max: 120,
|
||||
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
|
||||
placeholder: "Laisser vide si aucune restriction" %>
|
||||
</div>
|
||||
@@ -214,11 +215,11 @@
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<%= form.submit "Sauvegarder les modifications", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
<% content_for(:title, "Types de billets - #{@event.name}") %>
|
||||
|
||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<%= render 'components/breadcrumb', crumbs: [
|
||||
{ name: 'Accueil', path: root_path },
|
||||
{ name: 'Tableau de bord', path: dashboard_path },
|
||||
{ name: 'Mes événements', path: promoter_events_path },
|
||||
{ name: @event.name, path: promoter_event_path(@event) },
|
||||
{ name: 'Billets' }
|
||||
] %>
|
||||
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center space-x-4 mb-4">
|
||||
<%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
|
||||
@@ -45,7 +56,7 @@
|
||||
</h3>
|
||||
<p class="text-gray-600 mb-3"><%= ticket_type.description %></p>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Status badge -->
|
||||
<div class="ml-4">
|
||||
<% case ticket_type.sales_status %>
|
||||
@@ -81,21 +92,21 @@
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Prix</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-gray-900">
|
||||
<%= ticket_type.available_quantity %>/<%= ticket_type.quantity %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Disponibles</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-gray-900">
|
||||
<%= ticket_type.tickets.count %>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">Vendus</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="text-center p-3 bg-gray-50 rounded-lg">
|
||||
<div class="text-2xl font-bold text-green-600">
|
||||
<%= number_to_currency(ticket_type.current_revenue, unit: "€") %>
|
||||
@@ -139,7 +150,7 @@
|
||||
<i data-lucide="copy" class="w-5 h-5"></i>
|
||||
<% end %>
|
||||
<% if ticket_type.tickets.empty? %>
|
||||
<%= button_to promoter_event_ticket_type_path(@event, ticket_type), method: :delete,
|
||||
<%= button_to promoter_event_ticket_type_path(@event, ticket_type), method: :delete,
|
||||
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce type de billet ?" },
|
||||
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
|
||||
<i data-lucide="trash-2" class="w-5 h-5"></i>
|
||||
@@ -167,4 +178,4 @@
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user