Compare commits
3 Commits
83e76f71bf
...
1a7fb818df
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a7fb818df | ||
|
|
9b5d8fcf97 | ||
|
|
748f839346 |
15
BACKLOG.md
15
BACKLOG.md
@@ -17,6 +17,21 @@
|
|||||||
- [ ] feat: Dynamic pricing based on demand
|
- [ ] feat: Dynamic pricing based on demand
|
||||||
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||||
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||||
|
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
||||||
|
- [ ] feat: Platform commission tracking and fee structure display
|
||||||
|
- [ ] feat: Tax reporting and revenue export for promoters
|
||||||
|
- [ ] feat: Event update notifications to ticket holders
|
||||||
|
- [ ] feat: Marketing tools with promotional codes and discounts
|
||||||
|
- [ ] feat: Customer support messaging between promoters and attendees
|
||||||
|
- [ ] feat: Attendance tracking (who showed up vs tickets sold)
|
||||||
|
- [ ] feat: Customer insights and demographics for promoters
|
||||||
|
- [ ] feat: Performance metrics and conversion rate analytics
|
||||||
|
- [ ] feat: Event templates for reusing successful formats
|
||||||
|
- [ ] feat: Staff management and role assignment for promoter teams
|
||||||
|
- [ ] feat: Multiple payment gateway options
|
||||||
|
- [ ] feat: Calendar sync (Google Calendar, Outlook integration)
|
||||||
|
- [ ] feat: Social media auto-posting for events
|
||||||
|
- [ ] feat: CRM and email marketing tool integrations
|
||||||
|
|
||||||
### Low Priority
|
### Low Priority
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,52 @@ class PagesController < ApplicationController
|
|||||||
.can_retry_payment
|
.can_retry_payment
|
||||||
.order(:expires_at)
|
.order(:expires_at)
|
||||||
|
|
||||||
|
# Promoter-specific data if user is a promoter
|
||||||
|
if current_user.promoter?
|
||||||
|
@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
|
||||||
|
|
||||||
|
@total_tickets_sold = current_user.events
|
||||||
|
.joins(:tickets)
|
||||||
|
.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
|
||||||
|
|
||||||
|
# Recent orders for promoter events
|
||||||
|
@recent_orders = Order.joins(:event)
|
||||||
|
.where(events: { user: current_user })
|
||||||
|
.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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
{
|
||||||
|
month: start_date.strftime("%B %Y"),
|
||||||
|
revenue: revenue
|
||||||
|
}
|
||||||
|
end.reverse
|
||||||
|
end
|
||||||
|
|
||||||
# Simplified upcoming events preview - only show if user has orders
|
# Simplified upcoming events preview - only show if user has orders
|
||||||
if @user_orders.any?
|
if @user_orders.any?
|
||||||
ordered_event_ids = @user_orders.map(&:event).map(&:id)
|
ordered_event_ids = @user_orders.map(&:event).map(&:id)
|
||||||
|
|||||||
@@ -18,6 +18,6 @@ function initializeLucideIcons() {
|
|||||||
// Run on initial page load
|
// Run on initial page load
|
||||||
document.addEventListener('DOMContentLoaded', initializeLucideIcons);
|
document.addEventListener('DOMContentLoaded', initializeLucideIcons);
|
||||||
|
|
||||||
// Run on Turbo navigation (Rails 7+ SPA behavior)
|
// Run on Turbo navigation (Rails 7+ SPA behavior)
|
||||||
document.addEventListener('turbo:render', initializeLucideIcons);
|
document.addEventListener('turbo:render', initializeLucideIcons);
|
||||||
document.addEventListener('turbo:frame-render', initializeLucideIcons);
|
document.addEventListener('turbo:frame-render', initializeLucideIcons);
|
||||||
|
|||||||
370
app/javascript/controllers/event_form_controller.js
Normal file
370
app/javascript/controllers/event_form_controller.js
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
import slug from 'slug'
|
||||||
|
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"]
|
||||||
|
static values = {
|
||||||
|
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
||||||
|
}
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.geocodeTimeout = null
|
||||||
|
|
||||||
|
// Initialize map links if we have an address and coordinates already exist
|
||||||
|
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
|
||||||
|
this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||||
|
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||||
|
this.updateMapLinks()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
if (this.geocodeTimeout) {
|
||||||
|
clearTimeout(this.geocodeTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug from name
|
||||||
|
generateSlug() {
|
||||||
|
const name = this.nameTarget.value
|
||||||
|
|
||||||
|
this.slugTarget.value = slug(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle address changes with debounced geocoding
|
||||||
|
addressChanged() {
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (this.geocodeTimeout) {
|
||||||
|
clearTimeout(this.geocodeTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.addressTarget.value.trim()
|
||||||
|
|
||||||
|
if (!address) {
|
||||||
|
this.clearCoordinates()
|
||||||
|
this.clearMapLinks()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debounce geocoding to avoid too many API calls
|
||||||
|
this.geocodeTimeout = setTimeout(() => {
|
||||||
|
this.geocodeAddressQuiet(address)
|
||||||
|
}, this.geocodeDelayValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user's current location and reverse geocode to address
|
||||||
|
async getCurrentLocation() {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
this.showLocationError("La géolocalisation n'est pas supportée par ce navigateur.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showLocationLoading()
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
enableHighAccuracy: true,
|
||||||
|
timeout: 10000,
|
||||||
|
maximumAge: 60000
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const position = await this.getCurrentPositionPromise(options)
|
||||||
|
const lat = position.coords.latitude
|
||||||
|
const lng = position.coords.longitude
|
||||||
|
|
||||||
|
// Set coordinates first
|
||||||
|
this.latitudeTarget.value = lat.toFixed(6)
|
||||||
|
this.longitudeTarget.value = lng.toFixed(6)
|
||||||
|
|
||||||
|
// Then reverse geocode to get address
|
||||||
|
const address = await this.reverseGeocode(lat, lng)
|
||||||
|
|
||||||
|
if (address) {
|
||||||
|
this.addressTarget.value = address
|
||||||
|
this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
|
||||||
|
} else {
|
||||||
|
this.showLocationSuccess("Position actuelle détectée!")
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateMapLinks()
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
this.hideLocationLoading()
|
||||||
|
let message = "Erreur lors de la récupération de la localisation."
|
||||||
|
|
||||||
|
switch(error.code) {
|
||||||
|
case error.PERMISSION_DENIED:
|
||||||
|
message = "L'accès à la localisation a été refusé."
|
||||||
|
break
|
||||||
|
case error.POSITION_UNAVAILABLE:
|
||||||
|
message = "Les informations de localisation ne sont pas disponibles."
|
||||||
|
break
|
||||||
|
case error.TIMEOUT:
|
||||||
|
message = "La demande de localisation a expiré."
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
this.showLocationError(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Promise wrapper for geolocation
|
||||||
|
getCurrentPositionPromise(options) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
navigator.geolocation.getCurrentPosition(resolve, reject, options)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 data = await response.json()
|
||||||
|
|
||||||
|
if (data && data.display_name) {
|
||||||
|
return data.display_name
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Reverse geocoding failed:", error)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview location - same as updating map links but with user feedback
|
||||||
|
previewLocation() {
|
||||||
|
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||||
|
this.showLocationError("Veuillez saisir une adresse pour la prévisualiser.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we already have coordinates, just update map links
|
||||||
|
if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||||
|
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||||
|
this.updateMapLinks()
|
||||||
|
this.showLocationSuccess("Liens de carte mis à jour!")
|
||||||
|
} else {
|
||||||
|
// Otherwise geocode the address first
|
||||||
|
this.geocodeAddress()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geocode address manually (with user feedback)
|
||||||
|
async geocodeAddress() {
|
||||||
|
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||||
|
this.showLocationError("Veuillez saisir une adresse.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const address = this.addressTarget.value.trim()
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.showLocationLoading()
|
||||||
|
const result = await this.performGeocode(address)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.latitudeTarget.value = result.lat
|
||||||
|
this.longitudeTarget.value = result.lng
|
||||||
|
this.updateMapLinks()
|
||||||
|
this.showLocationSuccess("Coordonnées trouvées pour cette adresse!")
|
||||||
|
} 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.hideLocationLoading()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Geocode address quietly (no user feedback, for auto-geocoding)
|
||||||
|
async geocodeAddressQuiet(address) {
|
||||||
|
try {
|
||||||
|
const result = await this.performGeocode(address)
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
this.latitudeTarget.value = result.lat
|
||||||
|
this.longitudeTarget.value = result.lng
|
||||||
|
this.updateMapLinks()
|
||||||
|
} else {
|
||||||
|
// If auto-geocoding fails, show a subtle warning
|
||||||
|
this.showGeocodingWarning(address)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log("Auto-geocoding failed:", error)
|
||||||
|
this.showGeocodingWarning(address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform the actual geocoding request
|
||||||
|
async performGeocode(address) {
|
||||||
|
const encodedAddress = encodeURIComponent(address)
|
||||||
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update map links based on current coordinates
|
||||||
|
updateMapLinks() {
|
||||||
|
if (!this.hasMapLinksContainerTarget) return
|
||||||
|
|
||||||
|
const lat = parseFloat(this.latitudeTarget.value)
|
||||||
|
const lng = parseFloat(this.longitudeTarget.value)
|
||||||
|
const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
|
||||||
|
|
||||||
|
if (isNaN(lat) || isNaN(lng) || !address) {
|
||||||
|
this.clearMapLinks()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const links = this.generateMapLinks(lat, lng, address)
|
||||||
|
this.mapLinksContainerTarget.innerHTML = links
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate map links HTML
|
||||||
|
generateMapLinks(lat, lng, address) {
|
||||||
|
const encodedAddress = encodeURIComponent(address)
|
||||||
|
|
||||||
|
const providers = {
|
||||||
|
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}`,
|
||||||
|
icon: "🍎"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
|
||||||
|
<span class="text-sm font-medium text-gray-700">Voir sur la carte :</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
${Object.entries(providers).map(([key, provider]) => `
|
||||||
|
<a href="${provider.url}" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<span class="mr-2">${provider.icon}</span>
|
||||||
|
${provider.name}
|
||||||
|
</a>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear coordinates
|
||||||
|
clearCoordinates() {
|
||||||
|
if (this.hasLatitudeTarget) this.latitudeTarget.value = ""
|
||||||
|
if (this.hasLongitudeTarget) this.longitudeTarget.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear map links
|
||||||
|
clearMapLinks() {
|
||||||
|
if (this.hasMapLinksContainerTarget) {
|
||||||
|
this.mapLinksContainerTarget.innerHTML = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
showLocationLoading() {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-loading", "Géolocalisation en cours...", "info")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide loading state
|
||||||
|
hideLocationLoading() {
|
||||||
|
this.hideMessage("location-loading")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
showLocationSuccess(message) {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-success", message, "success")
|
||||||
|
setTimeout(() => this.hideMessage("location-success"), 4000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show error message
|
||||||
|
showLocationError(message) {
|
||||||
|
this.hideAllLocationMessages()
|
||||||
|
this.showMessage("location-error", message, "error")
|
||||||
|
setTimeout(() => this.hideMessage("location-error"), 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show geocoding warning (less intrusive than error)
|
||||||
|
showGeocodingWarning(address) {
|
||||||
|
this.hideMessage("geocoding-warning")
|
||||||
|
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
|
||||||
|
this.showMessage("geocoding-warning", message, "warning")
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
const icons = {
|
||||||
|
info: "info",
|
||||||
|
success: "check-circle",
|
||||||
|
error: "alert-circle",
|
||||||
|
warning: "alert-triangle"
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
// Insert after the venue section header
|
||||||
|
const venueSection = this.element.querySelector('h3')
|
||||||
|
if (venueSection) {
|
||||||
|
venueSection.insertAdjacentHTML('afterend', messageHtml)
|
||||||
|
// Re-initialize Lucide icons for the new elements
|
||||||
|
if (window.lucide) {
|
||||||
|
window.lucide.createIcons()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide a specific message
|
||||||
|
hideMessage(id) {
|
||||||
|
const element = document.getElementById(id)
|
||||||
|
if (element) {
|
||||||
|
element.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide all location messages
|
||||||
|
hideAllLocationMessages() {
|
||||||
|
this.hideMessage("location-loading")
|
||||||
|
this.hideMessage("location-success")
|
||||||
|
this.hideMessage("location-error")
|
||||||
|
this.hideMessage("geocoding-warning")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,3 +21,6 @@ application.register("header", HeaderController);
|
|||||||
|
|
||||||
import QrCodeController from "./qr_code_controller";
|
import QrCodeController from "./qr_code_controller";
|
||||||
application.register("qr-code", QrCodeController);
|
application.register("qr-code", QrCodeController);
|
||||||
|
|
||||||
|
import EventFormController from "./event_form_controller";
|
||||||
|
application.register("event-form", EventFormController);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
# Event model representing nightlife events and events
|
# Event model representing nightlife events and events
|
||||||
# Manages event details, location data, and publication state
|
# Manages event details, location data, and publication state
|
||||||
|
require 'net/http'
|
||||||
|
require 'json'
|
||||||
|
|
||||||
class Event < ApplicationRecord
|
class Event < ApplicationRecord
|
||||||
# Define states for Event lifecycle management
|
# Define states for Event lifecycle management
|
||||||
# draft: Initial state when Event is being created
|
# draft: Initial state when Event is being created
|
||||||
@@ -19,6 +22,29 @@ class Event < ApplicationRecord
|
|||||||
has_many :tickets, through: :ticket_types
|
has_many :tickets, through: :ticket_types
|
||||||
has_many :orders
|
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
|
||||||
|
|
||||||
# Validations for Event attributes
|
# Validations for Event attributes
|
||||||
# Basic information
|
# Basic information
|
||||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||||
@@ -48,4 +74,102 @@ class Event < ApplicationRecord
|
|||||||
|
|
||||||
# Scope for published events ordered by start time
|
# Scope for published events ordered by start time
|
||||||
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
# Automatically geocode address to get latitude and longitude
|
||||||
|
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
|
||||||
|
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'
|
||||||
|
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}"
|
||||||
|
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}"
|
||||||
|
handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Handle geocoding failure with fallback strategies
|
||||||
|
def handle_geocoding_failure(original_lat, original_lng)
|
||||||
|
# Strategy 1: Keep existing coordinates if this is an update
|
||||||
|
if original_lat.present? && original_lng.present?
|
||||||
|
self.latitude = original_lat
|
||||||
|
self.longitude = original_lng
|
||||||
|
Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strategy 2: Try to extract country/city and use approximate coordinates
|
||||||
|
fallback_coordinates = get_fallback_coordinates_from_address
|
||||||
|
if fallback_coordinates
|
||||||
|
self.latitude = fallback_coordinates[:lat]
|
||||||
|
self.longitude = fallback_coordinates[:lng]
|
||||||
|
Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}"
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Strategy 3: Use default coordinates (center of France) as last resort
|
||||||
|
# This ensures the event can still be created
|
||||||
|
self.latitude = 46.603354 # Center of France
|
||||||
|
self.longitude = 1.888334
|
||||||
|
Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
return { lat: 46.603354, lng: 1.888334 } # Center of France
|
||||||
|
end
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
class Order < ApplicationRecord
|
class Order < ApplicationRecord
|
||||||
# === Constants ===
|
# === Constants ===
|
||||||
DRAFT_EXPIRY_TIME = 30.minutes
|
DRAFT_EXPIRY_TIME = 15.minutes
|
||||||
MAX_PAYMENT_ATTEMPTS = 3
|
MAX_PAYMENT_ATTEMPTS = 3
|
||||||
|
|
||||||
# === Associations ===
|
# === Associations ===
|
||||||
|
|||||||
@@ -40,9 +40,8 @@ class User < ApplicationRecord
|
|||||||
|
|
||||||
# Authorization methods
|
# Authorization methods
|
||||||
def can_manage_events?
|
def can_manage_events?
|
||||||
# For now, all authenticated users can manage events
|
# Only professional users can manage events
|
||||||
# This can be extended later with role-based permissions
|
is_professionnal?
|
||||||
true
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def promoter?
|
def promoter?
|
||||||
|
|||||||
17
app/views/components/_delete_account.html.erb
Normal file
17
app/views/components/_delete_account.html.erb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<!-- Delete Account Section -->
|
||||||
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-4">Supprimer mon compte</h3>
|
||||||
|
<p class="text-gray-600 mb-6">
|
||||||
|
Vous êtes certain de vouloir supprimer votre compte ? Cette action est irréversible.
|
||||||
|
</p>
|
||||||
|
<%= button_to registration_path(resource_name),
|
||||||
|
data: {
|
||||||
|
confirm: "Êtes-vous certain ?",
|
||||||
|
turbo_confirm: "Êtes-vous certain ?"
|
||||||
|
},
|
||||||
|
method: :delete,
|
||||||
|
class: "group relative w-full flex justify-center items-center py-3 px-4 border border-red-300 text-sm font-semibold rounded-xl text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200" do %>
|
||||||
|
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||||
|
Supprimer mon compte
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -19,6 +19,18 @@
|
|||||||
Tableau de bord
|
Tableau de bord
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if user_signed_in? && current_user.promoter? %>
|
||||||
|
<%= link_to new_promoter_event_path,
|
||||||
|
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
||||||
|
Créer un événement
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to promoter_events_path,
|
||||||
|
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
||||||
|
Mes événements
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<!-- <%= link_to "#",
|
<!-- <%= link_to "#",
|
||||||
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
class: "text-gray-700 hover:text-brand-primary py-2 text-sm font-medium transition-colors duration-200 relative after:absolute after:bottom-0 after:left-0 after:h-0.5 after:w-0 hover:after:w-full after:bg-brand-primary after:transition-all after:duration-200" do %>
|
||||||
Concerts
|
Concerts
|
||||||
@@ -93,10 +105,24 @@
|
|||||||
|
|
||||||
<%= link_to dashboard_path,
|
<%= link_to dashboard_path,
|
||||||
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
<i data-lucide="calendar" class="w-4 h-4 mr-3"></i>
|
<i data-lucide="bar-chart-3" class="w-4 h-4 mr-3"></i>
|
||||||
Tableau de bord
|
Tableau de bord
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if user_signed_in? && current_user.promoter? %>
|
||||||
|
<%= link_to new_promoter_event_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="plus-circle" class="w-4 h-4 mr-3"></i>
|
||||||
|
Créer un événement
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= link_to promoter_events_path,
|
||||||
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
|
<i data-lucide="calendar-check" class="w-4 h-4 mr-3"></i>
|
||||||
|
Mes événements
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<!-- <%= link_to events_path,
|
<!-- <%= link_to events_path,
|
||||||
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
class: "flex items-center px-3 py-2 rounded-lg text-base font-medium text-gray-700 hover:text-brand-primary hover:bg-gray-50" do %>
|
||||||
<i data-lucide="glass-water" class="w-4 h-4 mr-3"></i>
|
<i data-lucide="glass-water" class="w-4 h-4 mr-3"></i>
|
||||||
|
|||||||
@@ -7,16 +7,16 @@
|
|||||||
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
|
<i data-lucide="calendar" class="w-8 h-8 text-white"></i>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<h2 class="text-3xl font-bold text-gray-900">Modifier votre compte</h2>
|
<h2 class="text-3xl font-bold text-gray-900">Modifier vos informations de sécurité</h2>
|
||||||
<p class="mt-2 text-gray-600">
|
<p class="mt-2 text-gray-600">
|
||||||
Gérez vos informations et préférences
|
Gérez vos informations et préférences de sécurité
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Profile Form -->
|
<!-- Profile Form -->
|
||||||
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-6">Informations du compte</h3>
|
<h3 class="text-xl font-semibold text-gray-900 mb-6">Informations du compte</h3>
|
||||||
|
|
||||||
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: "space-y-6" }) do |f| %>
|
<%= form_for(resource, as: resource_name, url: registration_path(resource_name), html: { method: :put, class: "space-y-6" }) do |f| %>
|
||||||
<%= render "devise/shared/error_messages", resource: resource %>
|
<%= render "devise/shared/error_messages", resource: resource %>
|
||||||
|
|
||||||
@@ -39,35 +39,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
||||||
<div>
|
|
||||||
<%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
<%= f.password_field :password, autocomplete: "new-password",
|
|
||||||
placeholder: "Laisser vide si vous ne souhaitez pas le changer",
|
|
||||||
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
|
||||||
</div>
|
|
||||||
<% if @minimum_password_length %>
|
|
||||||
<p class="mt-2 text-sm text-gray-500"><%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %></p>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= f.label :password_confirmation, "Confirmer le nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
||||||
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
|
||||||
placeholder: "Confirmez votre nouveau mot de passe",
|
|
||||||
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= f.label :current_password, "Mot de passe actuel", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
<%= f.label :current_password, "Mot de passe actuel", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -82,6 +53,35 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password, autocomplete: "new-password",
|
||||||
|
placeholder: "Laisser vide si vous ne souhaitez pas le changer",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
<% if @minimum_password_length %>
|
||||||
|
<p class="mt-2 text-sm text-gray-500"><%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :password_confirmation, "Confirmer le nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
|
<i data-lucide="lock" class="w-5 h-5 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
<%= f.password_field :password_confirmation, autocomplete: "new-password",
|
||||||
|
placeholder: "Confirmez votre nouveau mot de passe",
|
||||||
|
class: "block w-full pl-10 pr-3 py-3 border border-gray-300 rounded-xl shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500 transition-colors" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pt-4">
|
<div class="pt-4">
|
||||||
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
|
<%= f.button type: "submit", class: "group relative w-full flex justify-center items-center py-3 px-4 border border-transparent text-sm font-semibold rounded-xl text-white bg-gray-900 hover:bg-gray-800 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-gray-500 transition-all duration-200 shadow-lg hover:shadow-xl transform hover:-translate-y-0.5" do %>
|
||||||
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
|
<i data-lucide="save" class="w-4 h-4 mr-2"></i>
|
||||||
@@ -91,30 +91,14 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Delete Account Section -->
|
<%# render "components/delete_account" %>
|
||||||
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
|
||||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">Supprimer mon compte</h3>
|
|
||||||
<p class="text-gray-600 mb-6">
|
|
||||||
<%= t('devise.registrations.edit.unhappy') %> Cette action est irréversible.
|
|
||||||
</p>
|
|
||||||
<%= button_to registration_path(resource_name),
|
|
||||||
data: {
|
|
||||||
confirm: t('devise.registrations.edit.confirm_delete'),
|
|
||||||
turbo_confirm: t('devise.registrations.edit.confirm_delete')
|
|
||||||
},
|
|
||||||
method: :delete,
|
|
||||||
class: "group relative w-full flex justify-center items-center py-3 px-4 border border-red-300 text-sm font-semibold rounded-xl text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200" do %>
|
|
||||||
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
|
||||||
<%= t('devise.registrations.edit.delete_account') %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Back Link -->
|
<!-- Back Link -->
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<%= link_to :back, class: "inline-flex items-center text-purple-600 hover:text-purple-500 transition-colors" do %>
|
<%= link_to :back, class: "inline-flex items-center text-purple-600 hover:text-purple-500 transition-colors" do %>
|
||||||
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
<i data-lucide="arrow-left" class="w-4 h-4 mr-2"></i>
|
||||||
<%= t('devise.registrations.edit.back') %>
|
Retour
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
<!-- Breadcrumb -->
|
<!-- Breadcrumb -->
|
||||||
<%= render 'components/breadcrumb', crumbs: [
|
<%= render 'components/breadcrumb', crumbs: [
|
||||||
{ name: 'Accueil', path: root_path },
|
{ name: 'Accueil', path: root_path },
|
||||||
@@ -55,7 +55,48 @@
|
|||||||
Lieu
|
Lieu
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-gray-700 font-medium"><%= @event.venue_name %></p>
|
<p class="text-gray-700 font-medium"><%= @event.venue_name %></p>
|
||||||
<p class="text-gray-600 mt-1"><%= @event.venue_address %></p>
|
<p class="text-gray-600 mt-2 mb-4"><%= @event.venue_address %></p>
|
||||||
|
|
||||||
|
<% if @event.latitude.present? && @event.longitude.present? %>
|
||||||
|
<div class="border-t border-gray-200 pt-4">
|
||||||
|
<% if @event.geocoding_status_message %>
|
||||||
|
<div class="mb-3 p-2 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i data-lucide="alert-triangle" class="w-4 h-4 text-yellow-600 mr-2"></i>
|
||||||
|
<p class="text-xs text-yellow-800"><%= @event.geocoding_status_message %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<p class="text-sm font-medium text-gray-700 mb-2">Ouvrir dans :</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<%
|
||||||
|
encoded_address = URI.encode_www_form_component(@event.venue_address)
|
||||||
|
lat = @event.latitude
|
||||||
|
lng = @event.longitude
|
||||||
|
|
||||||
|
map_providers = {
|
||||||
|
"OpenStreetMap" => "https://www.openstreetmap.org/?mlat=#{lat}&mlon=#{lng}#map=16/#{lat}/#{lng}",
|
||||||
|
"Google Maps" => "https://www.google.com/maps/search/#{encoded_address}/@#{lat},#{lng},16z",
|
||||||
|
"Apple Plans" => "https://maps.apple.com/?address=#{encoded_address}&ll=#{lat},#{lng}"
|
||||||
|
}
|
||||||
|
|
||||||
|
icons = {
|
||||||
|
"OpenStreetMap" => "🗺️",
|
||||||
|
"Google Maps" => "🔍",
|
||||||
|
"Apple Plans" => "🍎"
|
||||||
|
}
|
||||||
|
%>
|
||||||
|
|
||||||
|
<% map_providers.each do |name, url| %>
|
||||||
|
<%= link_to url, target: "_blank", rel: "noopener",
|
||||||
|
class: "inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors" do %>
|
||||||
|
<span class="mr-1"><%= icons[name] %></span>
|
||||||
|
<%= name %>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="bg-gray-50 rounded-xl p-6">
|
<div class="bg-gray-50 rounded-xl p-6">
|
||||||
@@ -150,4 +191,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<title><%= content_for(:title) || "Aperonight" %></title>
|
<title><%= content_for(:title) || "Aperonight" %></title>
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
@@ -10,35 +10,11 @@
|
|||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
|
|
||||||
<!-- Fonts -->
|
<!-- Fonts -->
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
<!--<link rel="preconnect" href="https://fonts.googleapis.com">-->
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<!--<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>-->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
<!--<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=DM+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">-->
|
||||||
|
|
||||||
<!-- TailwindCSS -->
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<script>
|
|
||||||
tailwind.config = {
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
'brand-primary': '#667eea',
|
|
||||||
'brand-secondary': '#764ba2',
|
|
||||||
'brand-accent': '#facc15'
|
|
||||||
},
|
|
||||||
fontFamily: {
|
|
||||||
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
|
||||||
'display': ['DM Sans', 'system-ui', 'sans-serif']
|
|
||||||
},
|
|
||||||
backgroundImage: {
|
|
||||||
'gradient-primary': 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<!-- Lucide Icons -->
|
<!-- Lucide Icons -->
|
||||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
|
|
||||||
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
<%# Enable PWA manifest for installable apps (make sure to enable in config/routes.rb too!) %>
|
||||||
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<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: 'Tableau de bord', path: dashboard_path }
|
||||||
|
] %>
|
||||||
|
|
||||||
<!-- Page Header -->
|
<!-- Page Header -->
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
@@ -28,6 +36,169 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Promoter Dashboard Section -->
|
||||||
|
<% if current_user.promoter? && @promoter_events.present? %>
|
||||||
|
<!-- Promoter Metrics -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-2xl p-6 border border-green-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-green-600 text-sm font-medium">Revenus Total</p>
|
||||||
|
<p class="text-2xl font-bold text-green-900">€<%= number_with_delimiter(@total_revenue, delimiter: ' ') %></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-200 rounded-full p-3">
|
||||||
|
<i data-lucide="euro" class="w-6 h-6 text-green-700"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gradient-to-br from-blue-50 to-blue-100 rounded-2xl p-6 border border-blue-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-blue-600 text-sm font-medium">Billets Vendus</p>
|
||||||
|
<p class="text-2xl font-bold text-blue-900"><%= @total_tickets_sold %></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-200 rounded-full p-3">
|
||||||
|
<i data-lucide="ticket" class="w-6 h-6 text-blue-700"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gradient-to-br from-purple-50 to-purple-100 rounded-2xl p-6 border border-purple-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-purple-600 text-sm font-medium">Événements Publiés</p>
|
||||||
|
<p class="text-2xl font-bold text-purple-900"><%= @active_events_count %></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-purple-200 rounded-full p-3">
|
||||||
|
<i data-lucide="calendar-check" class="w-6 h-6 text-purple-700"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-2xl p-6 border border-orange-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-orange-600 text-sm font-medium">Brouillons</p>
|
||||||
|
<p class="text-2xl font-bold text-orange-900"><%= @draft_events_count %></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-200 rounded-full p-3">
|
||||||
|
<i data-lucide="edit-3" class="w-6 h-6 text-orange-700"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Revenue Chart & Recent Events -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||||||
|
<!-- Monthly Revenue Chart -->
|
||||||
|
<div class="lg:col-span-2 bg-white rounded-2xl shadow-lg">
|
||||||
|
<div class="border-b border-gray-100 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900">Revenus Mensuels</h2>
|
||||||
|
<p class="text-gray-600 mt-1">Derniers 6 mois</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-3">
|
||||||
|
<% @monthly_revenue.each do |month_data| %>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-gray-700"><%= month_data[:month] %></span>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<div class="w-32 bg-gray-200 rounded-full h-3 relative">
|
||||||
|
<div class="bg-green-500 h-3 rounded-full" style="width: <%= [month_data[:revenue] / ([@monthly_revenue.max_by{|m| m[:revenue]}[:revenue], 1].max) * 100, 5].max %>%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm font-bold text-gray-900 w-16 text-right">€<%= number_with_delimiter(month_data[:revenue], delimiter: ' ') %></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Events -->
|
||||||
|
<div class="bg-white rounded-2xl shadow-lg">
|
||||||
|
<div class="border-b border-gray-100 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900">Mes Événements</h2>
|
||||||
|
<%= link_to promoter_events_path, class: "text-purple-600 hover:text-purple-800 font-medium text-sm" do %>
|
||||||
|
Voir tout →
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% @promoter_events.each do |event| %>
|
||||||
|
<div class="border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
||||||
|
<div class="flex items-start justify-between mb-2">
|
||||||
|
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
||||||
|
<span class="text-xs px-2 py-1 rounded-full <%= event.state == 'published' ? 'bg-green-100 text-green-800' : event.state == 'draft' ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800' %>">
|
||||||
|
<%= event.state.humanize %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-600 space-y-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i data-lucide="calendar" class="w-3 h-3 mr-2"></i>
|
||||||
|
<%= event.start_time&.strftime("%d %B %Y") || "Non programmé" %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i data-lucide="ticket" class="w-3 h-3 mr-2"></i>
|
||||||
|
<%= event.tickets.where(status: 'active').count %> billets vendus
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex space-x-2">
|
||||||
|
<%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
|
||||||
|
Gérer →
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 text-center">
|
||||||
|
<%= link_to new_promoter_event_path, class: "inline-flex items-center px-4 py-2 bg-gray-900 text-white text-sm font-medium rounded-lg hover:bg-gray-800 transition-colors" do %>
|
||||||
|
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
|
||||||
|
Nouvel Événement
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Orders -->
|
||||||
|
<% if @recent_orders.any? %>
|
||||||
|
<div class="bg-white rounded-2xl shadow-lg mb-8">
|
||||||
|
<div class="border-b border-gray-100 p-6">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900">Commandes Récentes</h2>
|
||||||
|
<p class="text-gray-600 mt-1">Dernières commandes pour vos événements</p>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr class="text-left border-b border-gray-200">
|
||||||
|
<th class="pb-3 text-sm font-medium text-gray-600">Événement</th>
|
||||||
|
<th class="pb-3 text-sm font-medium text-gray-600">Client</th>
|
||||||
|
<th class="pb-3 text-sm font-medium text-gray-600">Billets</th>
|
||||||
|
<th class="pb-3 text-sm font-medium text-gray-600">Montant</th>
|
||||||
|
<th class="pb-3 text-sm font-medium text-gray-600">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-100">
|
||||||
|
<% @recent_orders.each do |order| %>
|
||||||
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="py-3 text-sm font-medium text-gray-900"><%= order.event.name %></td>
|
||||||
|
<td class="py-3 text-sm text-gray-700"><%= order.user.email %></td>
|
||||||
|
<td class="py-3 text-sm text-gray-700"><%= order.tickets.count %></td>
|
||||||
|
<td class="py-3 text-sm font-medium text-gray-900">€<%= order.total_amount_euros %></td>
|
||||||
|
<td class="py-3 text-sm text-gray-500"><%= order.created_at.strftime("%d/%m/%Y") %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<!-- Draft orders needing payment -->
|
<!-- Draft orders needing payment -->
|
||||||
<% if @draft_orders.any? %>
|
<% if @draft_orders.any? %>
|
||||||
<div class="bg-orange-50 border border-orange-200 rounded-2xl shadow-lg mb-8">
|
<div class="bg-orange-50 border border-orange-200 rounded-2xl shadow-lg mb-8">
|
||||||
@@ -238,4 +409,4 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,26 +116,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :venue_address, "Adresse", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-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" %>
|
<div class="space-y-2">
|
||||||
</div>
|
<%= 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="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<!-- Location Actions -->
|
||||||
<div>
|
<div class="flex flex-wrap gap-2">
|
||||||
<%= form.label :latitude, "Latitude", class: "block text-sm font-medium text-gray-700 mb-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">
|
||||||
<%= form.number_field :latitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "48.8566" %>
|
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
|
||||||
|
Ma position
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
<%= form.label :longitude, "Longitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
||||||
<%= form.number_field :longitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "2.3522" %>
|
Les coordonnées GPS seront automatiquement calculées à partir de cette adresse.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-500">
|
<!-- Hidden coordinate fields for form submission -->
|
||||||
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
<%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %>
|
||||||
Utilisez un service comme <a href="https://www.latlong.net/" target="_blank" class="text-purple-600 hover:text-purple-800">latlong.net</a> pour obtenir les coordonnées GPS.
|
<%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>
|
||||||
</p>
|
|
||||||
|
<!-- Map Links Container (shown when address is valid) -->
|
||||||
|
<div data-event-form-target="mapLinksContainer" class="empty:hidden bg-gray-50 rounded-lg p-3 border border-gray-200"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if @event.published? && @event.tickets.any? %>
|
<% if @event.published? && @event.tickets.any? %>
|
||||||
|
|||||||
@@ -94,26 +94,35 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :venue_address, "Adresse", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.label :venue_address, "Adresse complète", class: "block text-sm font-medium text-gray-700 mb-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" %>
|
<div class="space-y-2">
|
||||||
</div>
|
<%= 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="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<!-- Location Actions -->
|
||||||
<div>
|
<div class="flex flex-wrap gap-2">
|
||||||
<%= form.label :latitude, "Latitude", class: "block text-sm font-medium text-gray-700 mb-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">
|
||||||
<%= form.number_field :latitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "48.8566" %>
|
<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>
|
||||||
|
Ma position
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
<%= form.label :longitude, "Longitude", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
||||||
<%= form.number_field :longitude, step: :any, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "2.3522" %>
|
Les coordonnées GPS seront automatiquement calculées à partir de cette adresse.
|
||||||
</div>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-sm text-gray-500">
|
<!-- Hidden coordinate fields for form submission -->
|
||||||
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
|
<%= form.hidden_field :latitude, data: { "event-form-target": "latitude" } %>
|
||||||
Utilisez un service comme <a href="https://www.latlong.net/" target="_blank" class="text-purple-600 hover:text-purple-800">latlong.net</a> pour obtenir les coordonnées GPS.
|
<%= form.hidden_field :longitude, data: { "event-form-target": "longitude" } %>
|
||||||
</p>
|
|
||||||
|
<!-- Map Links Container (shown when address is valid) -->
|
||||||
|
<div data-event-form-target="mapLinksContainer" class="empty:hidden bg-gray-50 rounded-lg p-3 border border-gray-200"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -37,11 +37,12 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
|
|||||||
t.string :first_name, null: true # Prénom
|
t.string :first_name, null: true # Prénom
|
||||||
|
|
||||||
# Company informations
|
# Company informations
|
||||||
t.string :company_name, null: true # Nom de la société
|
t.boolean :is_professionnal, default: false, null: true
|
||||||
# t.string :company_address, null: true # Adresse de la société
|
t.string :company_name, null: true # Nom de la société
|
||||||
# t.string :company_phone, null: true # Téléphone de la société
|
t.string :company_address, null: true # Adresse de la société
|
||||||
# t.string :company_email, null: true # Email de la société
|
t.string :company_phone, null: true # Téléphone de la société
|
||||||
# t.string :company_website, null: true # Site web de la société
|
t.string :company_email, null: true # Email de la société
|
||||||
|
t.string :company_website, null: true # Site web de la société
|
||||||
|
|
||||||
# Link user to Stripe customer
|
# Link user to Stripe customer
|
||||||
# We assume user does not have a stripe account yet
|
# We assume user does not have a stripe account yet
|
||||||
|
|||||||
@@ -10,8 +10,12 @@ class CreateEvents < ActiveRecord::Migration[8.0]
|
|||||||
t.string :venue_address, null: false
|
t.string :venue_address, null: false
|
||||||
t.datetime :start_time
|
t.datetime :start_time
|
||||||
t.datetime :end_time
|
t.datetime :end_time
|
||||||
|
|
||||||
|
# Latitude and longitude of the place
|
||||||
t.decimal :latitude, precision: 10, scale: 6, null: false
|
t.decimal :latitude, precision: 10, scale: 6, null: false
|
||||||
t.decimal :longitude, precision: 10, scale: 6, null: false
|
t.decimal :longitude, precision: 10, scale: 6, null: false
|
||||||
|
|
||||||
|
# Only admin or later premium promoters could select this
|
||||||
t.boolean :featured, default: false, null: false
|
t.boolean :featured, default: false, null: false
|
||||||
t.references :user, null: false, foreign_key: false
|
t.references :user, null: false, foreign_key: false
|
||||||
|
|
||||||
|
|||||||
9
db/schema.rb
generated
9
db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_09_08_092220) do
|
ActiveRecord::Schema[8.0].define(version: 2025_08_23_171354) do
|
||||||
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
@@ -90,11 +90,16 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_08_092220) do
|
|||||||
t.datetime "remember_created_at"
|
t.datetime "remember_created_at"
|
||||||
t.string "last_name"
|
t.string "last_name"
|
||||||
t.string "first_name"
|
t.string "first_name"
|
||||||
|
t.boolean "is_professionnal", default: false
|
||||||
t.string "company_name"
|
t.string "company_name"
|
||||||
|
t.string "company_address"
|
||||||
|
t.string "company_phone"
|
||||||
|
t.string "company_email"
|
||||||
|
t.string "company_website"
|
||||||
t.string "stripe_customer_id"
|
t.string "stripe_customer_id"
|
||||||
|
t.boolean "onboarding_completed", default: false, null: false
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.boolean "onboarding_completed", default: false, null: false
|
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,572 +0,0 @@
|
|||||||
# Invoice System Implementation Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This document outlines various approaches for adding invoice generation to the ApéroNight event ticketing system, based on the current Rails architecture with Stripe integration.
|
|
||||||
|
|
||||||
## Current System Analysis
|
|
||||||
|
|
||||||
### Existing Infrastructure
|
|
||||||
- **Rails 8.0.2** with MySQL database
|
|
||||||
- **Stripe** payment processing via Checkout Sessions
|
|
||||||
- **Prawn** gem for PDF generation (tickets)
|
|
||||||
- **Order/Ticket** models with pricing logic
|
|
||||||
- **Devise** for user authentication
|
|
||||||
|
|
||||||
### Current Payment Flow
|
|
||||||
```
|
|
||||||
User selects tickets → Order created (draft) → Stripe Checkout → Payment → Order marked as paid → Tickets activated → PDF tickets emailed
|
|
||||||
```
|
|
||||||
|
|
||||||
## Invoice Generation Approaches
|
|
||||||
|
|
||||||
### 1. Extend Existing PDF Infrastructure (Recommended)
|
|
||||||
|
|
||||||
**Pros**:
|
|
||||||
- Leverages existing Prawn setup
|
|
||||||
- Consistent styling with tickets
|
|
||||||
- No additional dependencies
|
|
||||||
|
|
||||||
**Implementation**:
|
|
||||||
```ruby
|
|
||||||
# app/services/invoice_pdf_generator.rb
|
|
||||||
class InvoicePdfGenerator
|
|
||||||
attr_reader :order
|
|
||||||
|
|
||||||
def initialize(order)
|
|
||||||
@order = order
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate
|
|
||||||
Prawn::Document.new(page_size: "A4", margin: 40) do |pdf|
|
|
||||||
# Header
|
|
||||||
pdf.fill_color "2D1B69"
|
|
||||||
pdf.font "Helvetica", style: :bold, size: 24
|
|
||||||
pdf.text "ApéroNight Invoice", align: :center
|
|
||||||
pdf.move_down 20
|
|
||||||
|
|
||||||
# Invoice details
|
|
||||||
pdf.fill_color "000000"
|
|
||||||
pdf.font "Helvetica", size: 12
|
|
||||||
|
|
||||||
# Invoice number and date
|
|
||||||
pdf.text "Invoice #: #{@order.invoice_number}"
|
|
||||||
pdf.text "Date: #{@order.created_at.strftime('%B %d, %Y')}"
|
|
||||||
pdf.text "Due Date: #{@order.created_at.strftime('%B %d, %Y')}" # Same day for events
|
|
||||||
pdf.move_down 20
|
|
||||||
|
|
||||||
# Customer details
|
|
||||||
pdf.text "Bill To:", style: :bold
|
|
||||||
pdf.text @order.user.email
|
|
||||||
pdf.move_down 20
|
|
||||||
|
|
||||||
# Order details table
|
|
||||||
pdf.text "Event: #{@order.event.name}", style: :bold, size: 14
|
|
||||||
pdf.move_down 10
|
|
||||||
|
|
||||||
# Line items
|
|
||||||
items = [["Description", "Quantity", "Unit Price", "Total"]]
|
|
||||||
|
|
||||||
@order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets|
|
|
||||||
items << [
|
|
||||||
"#{ticket_type.name} - #{@order.event.name}",
|
|
||||||
tickets.count.to_s,
|
|
||||||
"€#{ticket_type.price_cents / 100.0}",
|
|
||||||
"€#{(tickets.count * ticket_type.price_cents) / 100.0}"
|
|
||||||
]
|
|
||||||
end
|
|
||||||
|
|
||||||
pdf.table(items, header: true, width: pdf.bounds.width) do
|
|
||||||
row(0).font_style = :bold
|
|
||||||
columns(1..3).align = :right
|
|
||||||
end
|
|
||||||
|
|
||||||
pdf.move_down 20
|
|
||||||
|
|
||||||
# Total
|
|
||||||
pdf.text "Total: €#{@order.total_amount_cents / 100.0}",
|
|
||||||
style: :bold, size: 16, align: :right
|
|
||||||
|
|
||||||
# Footer
|
|
||||||
pdf.move_down 40
|
|
||||||
pdf.text "Thank you for your purchase!", align: :center
|
|
||||||
pdf.text "Generated on #{Time.current.strftime('%B %d, %Y at %I:%M %p')}",
|
|
||||||
align: :center, size: 8
|
|
||||||
end.render
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. HTML-to-PDF Solutions
|
|
||||||
|
|
||||||
**Using WickedPdf**:
|
|
||||||
```ruby
|
|
||||||
# Gemfile
|
|
||||||
gem 'wicked_pdf'
|
|
||||||
|
|
||||||
# app/controllers/invoices_controller.rb
|
|
||||||
def show
|
|
||||||
@order = current_user.orders.find(params[:order_id])
|
|
||||||
|
|
||||||
respond_to do |format|
|
|
||||||
format.html
|
|
||||||
format.pdf do
|
|
||||||
render pdf: "invoice_#{@order.id}",
|
|
||||||
template: 'invoices/show.html.erb',
|
|
||||||
layout: 'pdf'
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Third-Party Services
|
|
||||||
|
|
||||||
**Stripe Invoicing Integration**:
|
|
||||||
```ruby
|
|
||||||
# app/services/stripe_invoice_service.rb
|
|
||||||
class StripeInvoiceService
|
|
||||||
def initialize(order)
|
|
||||||
@order = order
|
|
||||||
end
|
|
||||||
|
|
||||||
def create_post_payment_invoice
|
|
||||||
customer = find_or_create_stripe_customer
|
|
||||||
|
|
||||||
invoice = Stripe::Invoice.create({
|
|
||||||
customer: customer.id,
|
|
||||||
collection_method: 'charge_automatically',
|
|
||||||
paid: true, # Already paid via checkout
|
|
||||||
metadata: {
|
|
||||||
order_id: @order.id,
|
|
||||||
user_id: @order.user.id
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
# Add line items
|
|
||||||
@order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets|
|
|
||||||
Stripe::InvoiceItem.create({
|
|
||||||
customer: customer.id,
|
|
||||||
invoice: invoice.id,
|
|
||||||
amount: ticket_type.price_cents * tickets.count,
|
|
||||||
currency: 'eur',
|
|
||||||
description: "#{@order.event.name} - #{ticket_type.name} (#{tickets.count}x)"
|
|
||||||
})
|
|
||||||
end
|
|
||||||
|
|
||||||
invoice.finalize_invoice
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def find_or_create_stripe_customer
|
|
||||||
if @order.user.stripe_customer_id.present?
|
|
||||||
Stripe::Customer.retrieve(@order.user.stripe_customer_id)
|
|
||||||
else
|
|
||||||
customer = Stripe::Customer.create({
|
|
||||||
email: @order.user.email,
|
|
||||||
metadata: { user_id: @order.user.id }
|
|
||||||
})
|
|
||||||
|
|
||||||
@order.user.update(stripe_customer_id: customer.id)
|
|
||||||
customer
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Stripe Payment Methods Comparison
|
|
||||||
|
|
||||||
### Payment Intents vs Invoicing vs Checkout Sessions
|
|
||||||
|
|
||||||
| Feature | Payment Intents | Stripe Invoicing | Checkout Sessions (Current) |
|
|
||||||
|---------|----------------|------------------|---------------------------|
|
|
||||||
| **Timing** | Immediate | Deferred (days/weeks) | Immediate |
|
|
||||||
| **User Experience** | Custom UI on your site | Stripe-hosted invoice page | Stripe-hosted checkout |
|
|
||||||
| **Payment Methods** | Cards, wallets, BNPL | Cards, bank transfers, checks | Cards, wallets, BNPL |
|
|
||||||
| **Documentation** | Custom receipts | Formal invoices | Stripe receipts |
|
|
||||||
| **Integration Complexity** | Medium | Low | Low |
|
|
||||||
| **Best For** | Custom checkout flows | B2B billing | Quick implementation |
|
|
||||||
|
|
||||||
### For Event Ticketing Use Case
|
|
||||||
|
|
||||||
**Current Checkout Sessions are ideal** because:
|
|
||||||
- Events require immediate payment confirmation
|
|
||||||
- Time-sensitive inventory management
|
|
||||||
- Users expect instant ticket delivery
|
|
||||||
- Built-in fraud protection
|
|
||||||
|
|
||||||
**Recommended**: Keep Checkout Sessions, add invoice generation for accounting records
|
|
||||||
|
|
||||||
## Recommended Implementation
|
|
||||||
|
|
||||||
### Phase 1: Database Schema
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# Migration
|
|
||||||
class CreateInvoices < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
create_table :invoices do |t|
|
|
||||||
t.references :order, null: false, foreign_key: true
|
|
||||||
t.string :invoice_number, null: false
|
|
||||||
t.integer :total_amount_cents, null: false
|
|
||||||
t.string :currency, default: 'eur'
|
|
||||||
t.string :status, default: 'issued' # issued, paid, cancelled
|
|
||||||
t.datetime :issued_at
|
|
||||||
t.datetime :paid_at
|
|
||||||
t.string :stripe_invoice_id # Optional: if using Stripe
|
|
||||||
t.text :notes
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :invoices, :invoice_number, unique: true
|
|
||||||
add_index :invoices, :status
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Add to User model for Stripe integration
|
|
||||||
class AddStripeFieldsToUsers < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
add_column :users, :stripe_customer_id, :string
|
|
||||||
add_index :users, :stripe_customer_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 2: Models
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# app/models/invoice.rb
|
|
||||||
class Invoice < ApplicationRecord
|
|
||||||
belongs_to :order
|
|
||||||
has_one :user, through: :order
|
|
||||||
has_one :event, through: :order
|
|
||||||
|
|
||||||
validates :invoice_number, presence: true, uniqueness: true
|
|
||||||
validates :total_amount_cents, presence: true, numericality: { greater_than: 0 }
|
|
||||||
validates :status, inclusion: { in: %w[issued paid cancelled] }
|
|
||||||
|
|
||||||
before_validation :generate_invoice_number, on: :create
|
|
||||||
before_validation :set_defaults, on: :create
|
|
||||||
|
|
||||||
scope :paid, -> { where(status: 'paid') }
|
|
||||||
scope :unpaid, -> { where(status: 'issued') }
|
|
||||||
|
|
||||||
def total_amount_euros
|
|
||||||
total_amount_cents / 100.0
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_pdf
|
|
||||||
InvoicePdfGenerator.new(self).generate
|
|
||||||
end
|
|
||||||
|
|
||||||
def mark_as_paid!
|
|
||||||
update!(status: 'paid', paid_at: Time.current)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def generate_invoice_number
|
|
||||||
return if invoice_number.present?
|
|
||||||
|
|
||||||
year = Time.current.year
|
|
||||||
month = Time.current.strftime('%m')
|
|
||||||
|
|
||||||
# Find highest invoice number for current month
|
|
||||||
last_invoice = Invoice.where(
|
|
||||||
'invoice_number LIKE ?',
|
|
||||||
"INV-#{year}#{month}-%"
|
|
||||||
).order(:invoice_number).last
|
|
||||||
|
|
||||||
if last_invoice
|
|
||||||
sequence = last_invoice.invoice_number.split('-').last.to_i + 1
|
|
||||||
else
|
|
||||||
sequence = 1
|
|
||||||
end
|
|
||||||
|
|
||||||
self.invoice_number = "INV-#{year}#{month}-#{sequence.to_s.rjust(4, '0')}"
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_defaults
|
|
||||||
self.total_amount_cents = order.total_amount_cents if order
|
|
||||||
self.issued_at = Time.current
|
|
||||||
self.status = 'paid' if order&.status == 'paid' # Auto-mark as paid for completed orders
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# app/models/order.rb - Add invoice association
|
|
||||||
class Order < ApplicationRecord
|
|
||||||
# ... existing code ...
|
|
||||||
has_one :invoice, dependent: :destroy
|
|
||||||
|
|
||||||
# Add method to create invoice after payment
|
|
||||||
def create_invoice!
|
|
||||||
return invoice if invoice.present?
|
|
||||||
|
|
||||||
Invoice.create!(
|
|
||||||
order: self,
|
|
||||||
total_amount_cents: self.total_amount_cents
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 3: Controllers
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# app/controllers/invoices_controller.rb
|
|
||||||
class InvoicesController < ApplicationController
|
|
||||||
before_action :authenticate_user!
|
|
||||||
before_action :set_invoice
|
|
||||||
|
|
||||||
def show
|
|
||||||
# HTML view of invoice
|
|
||||||
end
|
|
||||||
|
|
||||||
def download_pdf
|
|
||||||
pdf = @invoice.generate_pdf
|
|
||||||
|
|
||||||
send_data pdf,
|
|
||||||
filename: "invoice_#{@invoice.invoice_number}.pdf",
|
|
||||||
type: 'application/pdf',
|
|
||||||
disposition: 'attachment'
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_invoice
|
|
||||||
@invoice = current_user.invoices.joins(:order).find(params[:id])
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
redirect_to dashboard_path, alert: "Invoice not found"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update app/controllers/orders_controller.rb
|
|
||||||
class OrdersController < ApplicationController
|
|
||||||
# ... existing code ...
|
|
||||||
|
|
||||||
def payment_success
|
|
||||||
# ... existing payment success logic ...
|
|
||||||
|
|
||||||
if stripe_session.payment_status == "paid"
|
|
||||||
@order.mark_as_paid!
|
|
||||||
|
|
||||||
# Generate invoice
|
|
||||||
@order.create_invoice!
|
|
||||||
|
|
||||||
# Send confirmation emails with invoice attached
|
|
||||||
@order.tickets.each do |ticket|
|
|
||||||
begin
|
|
||||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
|
||||||
rescue => e
|
|
||||||
Rails.logger.error "Failed to send confirmation: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# ... rest of existing code ...
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 4: Mailer Updates
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# app/mailers/ticket_mailer.rb - Update to include invoice
|
|
||||||
class TicketMailer < ApplicationMailer
|
|
||||||
def purchase_confirmation(ticket)
|
|
||||||
@ticket = ticket
|
|
||||||
@order = ticket.order
|
|
||||||
@user = ticket.user
|
|
||||||
@event = ticket.event
|
|
||||||
|
|
||||||
# Attach ticket PDF
|
|
||||||
ticket_pdf = ticket.to_pdf
|
|
||||||
attachments["ticket_#{ticket.id}.pdf"] = ticket_pdf
|
|
||||||
|
|
||||||
# Attach invoice PDF
|
|
||||||
if @order.invoice.present?
|
|
||||||
invoice_pdf = @order.invoice.generate_pdf
|
|
||||||
attachments["invoice_#{@order.invoice.invoice_number}.pdf"] = invoice_pdf
|
|
||||||
end
|
|
||||||
|
|
||||||
mail(
|
|
||||||
to: @user.email,
|
|
||||||
subject: "Your tickets for #{@event.name}"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 5: Routes
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# config/routes.rb
|
|
||||||
Rails.application.routes.draw do
|
|
||||||
# ... existing routes ...
|
|
||||||
|
|
||||||
resources :invoices, only: [:show] do
|
|
||||||
member do
|
|
||||||
get :download_pdf
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
resources :orders do
|
|
||||||
member do
|
|
||||||
get :invoice # Shortcut to order's invoice
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### Phase 6: Views
|
|
||||||
|
|
||||||
```erb
|
|
||||||
<!-- app/views/invoices/show.html.erb -->
|
|
||||||
<div class="max-w-4xl mx-auto p-6">
|
|
||||||
<div class="bg-white shadow-lg rounded-lg p-8">
|
|
||||||
<div class="flex justify-between items-start mb-8">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Invoice</h1>
|
|
||||||
<p class="text-gray-600">Invoice #<%= @invoice.invoice_number %></p>
|
|
||||||
</div>
|
|
||||||
<div class="text-right">
|
|
||||||
<p class="text-sm text-gray-600">Date: <%= @invoice.issued_at.strftime('%B %d, %Y') %></p>
|
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full
|
|
||||||
<%= @invoice.status == 'paid' ? 'bg-green-100 text-green-800' : 'bg-yellow-100 text-yellow-800' %>">
|
|
||||||
<%= @invoice.status.capitalize %>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Invoice details -->
|
|
||||||
<div class="grid grid-cols-2 gap-8 mb-8">
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-900 mb-2">Bill To:</h3>
|
|
||||||
<p class="text-gray-600"><%= @invoice.user.email %></p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3 class="font-semibold text-gray-900 mb-2">Event:</h3>
|
|
||||||
<p class="text-gray-600"><%= @invoice.event.name %></p>
|
|
||||||
<p class="text-sm text-gray-500"><%= @invoice.event.start_time.strftime('%B %d, %Y at %I:%M %p') %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Line items -->
|
|
||||||
<div class="border-t border-b border-gray-200 py-4 mb-8">
|
|
||||||
<table class="w-full">
|
|
||||||
<thead>
|
|
||||||
<tr class="text-left text-gray-500 text-sm">
|
|
||||||
<th class="pb-2">Description</th>
|
|
||||||
<th class="pb-2 text-right">Qty</th>
|
|
||||||
<th class="pb-2 text-right">Unit Price</th>
|
|
||||||
<th class="pb-2 text-right">Total</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<% @invoice.order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets| %>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2"><%= ticket_type.name %></td>
|
|
||||||
<td class="py-2 text-right"><%= tickets.count %></td>
|
|
||||||
<td class="py-2 text-right">€<%= ticket_type.price_cents / 100.0 %></td>
|
|
||||||
<td class="py-2 text-right">€<%= (tickets.count * ticket_type.price_cents) / 100.0 %></td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Total -->
|
|
||||||
<div class="text-right mb-8">
|
|
||||||
<p class="text-2xl font-bold text-gray-900">
|
|
||||||
Total: €<%= @invoice.total_amount_euros %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actions -->
|
|
||||||
<div class="flex justify-end space-x-4">
|
|
||||||
<%= link_to "Download PDF", download_pdf_invoice_path(@invoice),
|
|
||||||
class: "bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Integration Guidelines
|
|
||||||
|
|
||||||
### 1. Testing Strategy
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# test/services/invoice_pdf_generator_test.rb
|
|
||||||
require 'test_helper'
|
|
||||||
|
|
||||||
class InvoicePdfGeneratorTest < ActiveSupport::TestCase
|
|
||||||
setup do
|
|
||||||
@order = orders(:paid_order)
|
|
||||||
@generator = InvoicePdfGenerator.new(@order.invoice)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "generates PDF successfully" do
|
|
||||||
pdf = @generator.generate
|
|
||||||
assert pdf.present?
|
|
||||||
assert pdf.is_a?(String)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "includes order details in PDF" do
|
|
||||||
# Test PDF content includes expected information
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Performance Considerations
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# app/jobs/invoice_generation_job.rb
|
|
||||||
class InvoiceGenerationJob < ApplicationJob
|
|
||||||
queue_as :default
|
|
||||||
|
|
||||||
def perform(order_id)
|
|
||||||
order = Order.find(order_id)
|
|
||||||
invoice = order.create_invoice!
|
|
||||||
|
|
||||||
# Generate PDF in background
|
|
||||||
pdf = invoice.generate_pdf
|
|
||||||
|
|
||||||
# Store in cloud storage if needed
|
|
||||||
# S3Service.store_invoice_pdf(invoice, pdf)
|
|
||||||
|
|
||||||
# Send email notification
|
|
||||||
InvoiceMailer.invoice_ready(invoice).deliver_now
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Call from OrdersController#payment_success
|
|
||||||
InvoiceGenerationJob.perform_later(@order.id)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Security Considerations
|
|
||||||
|
|
||||||
- Ensure users can only access their own invoices
|
|
||||||
- Validate invoice numbers are unique
|
|
||||||
- Sanitize user input in PDF generation
|
|
||||||
- Use HTTPS for invoice downloads
|
|
||||||
- Consider adding invoice access tokens for sharing
|
|
||||||
|
|
||||||
### 4. Deployment Checklist
|
|
||||||
|
|
||||||
- [ ] Run database migrations
|
|
||||||
- [ ] Update mailer templates
|
|
||||||
- [ ] Test PDF generation in production environment
|
|
||||||
- [ ] Verify email attachments work correctly
|
|
||||||
- [ ] Set up monitoring for PDF generation failures
|
|
||||||
- [ ] Add invoice generation to payment success flow
|
|
||||||
|
|
||||||
## Conclusion
|
|
||||||
|
|
||||||
The recommended approach combines the best of both worlds:
|
|
||||||
- Keep the existing immediate payment flow for better user experience
|
|
||||||
- Generate professional invoices for accounting and user records
|
|
||||||
- Use existing Prawn infrastructure for consistent PDF styling
|
|
||||||
- Optionally integrate with Stripe invoicing for advanced features
|
|
||||||
|
|
||||||
This implementation provides a solid foundation that can be extended with additional features like tax calculations, discounts, or integration with accounting systems.
|
|
||||||
@@ -1,414 +0,0 @@
|
|||||||
# Order System Implementation Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
This guide outlines how to implement an Order system in your Rails ticketing application, replacing the current individual ticket-based approach with a more robust order-based system.
|
|
||||||
|
|
||||||
## Current System Analysis
|
|
||||||
|
|
||||||
Your current system has:
|
|
||||||
- Individual tickets directly associated with users
|
|
||||||
- Payment attempts tracked per ticket
|
|
||||||
- No grouping of related tickets
|
|
||||||
- Complex checkout logic in controllers
|
|
||||||
|
|
||||||
## Proposed Order System Architecture
|
|
||||||
|
|
||||||
### Database Schema Changes
|
|
||||||
|
|
||||||
**New `orders` table:**
|
|
||||||
```sql
|
|
||||||
CREATE TABLE orders (
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
|
||||||
event_id INTEGER NOT NULL REFERENCES events(id),
|
|
||||||
status VARCHAR(255) NOT NULL DEFAULT 'draft',
|
|
||||||
total_amount_cents INTEGER NOT NULL DEFAULT 0,
|
|
||||||
payment_attempts INTEGER NOT NULL DEFAULT 0,
|
|
||||||
expires_at TIMESTAMP,
|
|
||||||
last_payment_attempt_at TIMESTAMP,
|
|
||||||
created_at TIMESTAMP NOT NULL,
|
|
||||||
updated_at TIMESTAMP NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Indexes for performance
|
|
||||||
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
|
|
||||||
CREATE INDEX idx_orders_event_status ON orders(event_id, status);
|
|
||||||
CREATE INDEX idx_orders_expires_at ON orders(expires_at);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Updated `tickets` table:**
|
|
||||||
```sql
|
|
||||||
-- Add order_id column
|
|
||||||
ALTER TABLE tickets ADD COLUMN order_id INTEGER REFERENCES orders(id);
|
|
||||||
|
|
||||||
-- Update existing tickets (if any)
|
|
||||||
UPDATE tickets SET order_id = (SELECT id FROM orders WHERE user_id = tickets.user_id LIMIT 1);
|
|
||||||
|
|
||||||
-- Make order_id NOT NULL after data migration
|
|
||||||
ALTER TABLE tickets ALTER COLUMN order_id SET NOT NULL;
|
|
||||||
|
|
||||||
-- Remove user_id from tickets (optional, but recommended)
|
|
||||||
-- ALTER TABLE tickets DROP COLUMN user_id;
|
|
||||||
```
|
|
||||||
|
|
||||||
## 1. Create Order Model
|
|
||||||
|
|
||||||
**File: `app/models/order.rb`**
|
|
||||||
```ruby
|
|
||||||
class Order < ApplicationRecord
|
|
||||||
# === Constants ===
|
|
||||||
DRAFT_EXPIRY_TIME = 30.minutes
|
|
||||||
MAX_PAYMENT_ATTEMPTS = 3
|
|
||||||
|
|
||||||
# === Associations ===
|
|
||||||
belongs_to :user
|
|
||||||
belongs_to :event
|
|
||||||
has_many :tickets, dependent: :destroy
|
|
||||||
|
|
||||||
# === Validations ===
|
|
||||||
validates :user_id, presence: true
|
|
||||||
validates :event_id, presence: true
|
|
||||||
validates :status, presence: true, inclusion: {
|
|
||||||
in: %w[draft pending_payment paid completed cancelled expired]
|
|
||||||
}
|
|
||||||
validates :total_amount_cents, presence: true,
|
|
||||||
numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :payment_attempts, presence: true,
|
|
||||||
numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
|
|
||||||
# === Scopes ===
|
|
||||||
scope :draft, -> { where(status: "draft") }
|
|
||||||
scope :active, -> { where(status: %w[paid completed]) }
|
|
||||||
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
|
|
||||||
scope :can_retry_payment, -> {
|
|
||||||
draft.where("payment_attempts < ? AND expires_at > ?",
|
|
||||||
MAX_PAYMENT_ATTEMPTS, Time.current)
|
|
||||||
}
|
|
||||||
|
|
||||||
before_validation :set_expiry, on: :create
|
|
||||||
|
|
||||||
# === Instance Methods ===
|
|
||||||
|
|
||||||
# Total amount in euros (formatted)
|
|
||||||
def total_amount_euros
|
|
||||||
total_amount_cents / 100.0
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if order can be retried for payment
|
|
||||||
def can_retry_payment?
|
|
||||||
draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if order is expired
|
|
||||||
def expired?
|
|
||||||
expires_at.present? && expires_at < Time.current
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mark order as expired if it's past expiry time
|
|
||||||
def expire_if_overdue!
|
|
||||||
return unless draft? && expired?
|
|
||||||
update!(status: "expired")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Increment payment attempt counter
|
|
||||||
def increment_payment_attempt!
|
|
||||||
update!(
|
|
||||||
payment_attempts: payment_attempts + 1,
|
|
||||||
last_payment_attempt_at: Time.current
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if draft is about to expire (within 5 minutes)
|
|
||||||
def expiring_soon?
|
|
||||||
return false unless draft? && expires_at.present?
|
|
||||||
expires_at <= 5.minutes.from_now
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mark order as paid and activate all tickets
|
|
||||||
def mark_as_paid!
|
|
||||||
transaction do
|
|
||||||
update!(status: "paid")
|
|
||||||
tickets.update_all(status: "active")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Calculate total from tickets
|
|
||||||
def calculate_total!
|
|
||||||
update!(total_amount_cents: tickets.sum(:price_cents))
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_expiry
|
|
||||||
return unless status == "draft"
|
|
||||||
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
|
|
||||||
end
|
|
||||||
|
|
||||||
def draft?
|
|
||||||
status == "draft"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Update Ticket Model
|
|
||||||
|
|
||||||
**File: `app/models/ticket.rb`**
|
|
||||||
```ruby
|
|
||||||
class Ticket < ApplicationRecord
|
|
||||||
# === Constants ===
|
|
||||||
DRAFT_EXPIRY_TIME = 30.minutes
|
|
||||||
MAX_PAYMENT_ATTEMPTS = 3
|
|
||||||
|
|
||||||
# === Associations ===
|
|
||||||
belongs_to :order # Changed from belongs_to :user
|
|
||||||
belongs_to :ticket_type
|
|
||||||
has_one :user, through: :order # Access user through order
|
|
||||||
has_one :event, through: :ticket_type
|
|
||||||
|
|
||||||
# === Validations ===
|
|
||||||
validates :qr_code, presence: true, uniqueness: true
|
|
||||||
validates :order_id, presence: true # Changed from user_id
|
|
||||||
validates :ticket_type_id, presence: true
|
|
||||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
|
||||||
validates :status, presence: true,
|
|
||||||
inclusion: { in: %w[draft active used expired refunded] }
|
|
||||||
validates :first_name, presence: true
|
|
||||||
validates :last_name, presence: true
|
|
||||||
# Removed payment_attempts validation (now handled by Order)
|
|
||||||
|
|
||||||
# === Scopes ===
|
|
||||||
scope :draft, -> { where(status: "draft") }
|
|
||||||
scope :active, -> { where(status: "active") }
|
|
||||||
# Removed payment-related scopes (now in Order)
|
|
||||||
|
|
||||||
before_validation :set_price_from_ticket_type, on: :create
|
|
||||||
before_validation :generate_qr_code, on: :create
|
|
||||||
# Removed set_draft_expiry (now in Order)
|
|
||||||
|
|
||||||
# === Instance Methods ===
|
|
||||||
|
|
||||||
# Price in euros (formatted)
|
|
||||||
def price_euros
|
|
||||||
price_cents / 100.0
|
|
||||||
end
|
|
||||||
|
|
||||||
# Delegate payment methods to order
|
|
||||||
def can_retry_payment?
|
|
||||||
order.can_retry_payment?
|
|
||||||
end
|
|
||||||
|
|
||||||
def expired?
|
|
||||||
order.expired?
|
|
||||||
end
|
|
||||||
|
|
||||||
def expiring_soon?
|
|
||||||
order.expiring_soon?
|
|
||||||
end
|
|
||||||
|
|
||||||
# Mark ticket as expired if it's past expiry time
|
|
||||||
def expire_if_overdue!
|
|
||||||
return unless draft? && expired?
|
|
||||||
update!(status: "expired")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate PDF ticket
|
|
||||||
def to_pdf
|
|
||||||
TicketPdfGenerator.new(self).generate
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def set_price_from_ticket_type
|
|
||||||
return unless ticket_type
|
|
||||||
self.price_cents = ticket_type.price_cents
|
|
||||||
end
|
|
||||||
|
|
||||||
def generate_qr_code
|
|
||||||
return if qr_code.present?
|
|
||||||
loop do
|
|
||||||
self.qr_code = SecureRandom.uuid
|
|
||||||
break unless Ticket.exists?(qr_code: qr_code)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def draft?
|
|
||||||
status == "draft"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Update Controllers
|
|
||||||
|
|
||||||
### TicketsController Changes
|
|
||||||
|
|
||||||
**File: `app/controllers/tickets_controller.rb`**
|
|
||||||
|
|
||||||
Key changes needed:
|
|
||||||
1. Create orders instead of individual tickets
|
|
||||||
2. Update session management to track `order_id` instead of `draft_ticket_ids`
|
|
||||||
3. Modify checkout logic to work with orders
|
|
||||||
4. Update payment success/cancel handling
|
|
||||||
|
|
||||||
**Main changes in `create` action:**
|
|
||||||
```ruby
|
|
||||||
# OLD: Create individual tickets
|
|
||||||
@event = Event.includes(:ticket_types).find(params[:id])
|
|
||||||
@cart_data = session[:pending_cart] || {}
|
|
||||||
# ... create individual tickets
|
|
||||||
|
|
||||||
# NEW: Create order with tickets
|
|
||||||
@event = Event.includes(:ticket_types).find(params[:id])
|
|
||||||
@cart_data = session[:pending_cart] || {}
|
|
||||||
|
|
||||||
ActiveRecord::Base.transaction do
|
|
||||||
@order = current_user.orders.create!(event: @event, status: "draft")
|
|
||||||
|
|
||||||
ticket_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
|
||||||
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
|
||||||
|
|
||||||
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
|
||||||
@order.tickets.create!(
|
|
||||||
ticket_type: ticket_type,
|
|
||||||
first_name: ticket_attrs[:first_name],
|
|
||||||
last_name: ticket_attrs[:last_name],
|
|
||||||
status: "draft"
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
if @order.tickets.present?
|
|
||||||
@order.calculate_total!
|
|
||||||
session[:draft_order_id] = @order.id
|
|
||||||
redirect_to order_checkout_path(@order)
|
|
||||||
else
|
|
||||||
@order.destroy
|
|
||||||
# ... handle error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
**Updated `checkout` action:**
|
|
||||||
```ruby
|
|
||||||
def checkout
|
|
||||||
@order = current_user.orders.includes(tickets: :ticket_type)
|
|
||||||
.find_by(id: params[:id], status: "draft")
|
|
||||||
|
|
||||||
return redirect_to event_path(@order.event.slug, @order.event),
|
|
||||||
alert: "Commande introuvable" unless @order
|
|
||||||
|
|
||||||
# Handle expired orders
|
|
||||||
if @order.expired?
|
|
||||||
@order.expire_if_overdue!
|
|
||||||
return redirect_to event_path(@order.event.slug, @order.event),
|
|
||||||
alert: "Votre commande a expiré"
|
|
||||||
end
|
|
||||||
|
|
||||||
@tickets = @order.tickets
|
|
||||||
@total_amount = @order.total_amount_cents
|
|
||||||
@expiring_soon = @order.expiring_soon?
|
|
||||||
|
|
||||||
# Create Stripe session if configured
|
|
||||||
if Rails.application.config.stripe[:secret_key].present?
|
|
||||||
begin
|
|
||||||
@checkout_session = create_stripe_session
|
|
||||||
@order.increment_payment_attempt!
|
|
||||||
rescue => e
|
|
||||||
# handle error
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Update Routes
|
|
||||||
|
|
||||||
**File: `config/routes.rb`**
|
|
||||||
```ruby
|
|
||||||
# Add order routes
|
|
||||||
resources :orders, only: [:show] do
|
|
||||||
member do
|
|
||||||
get :checkout
|
|
||||||
post :retry_payment
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Update existing ticket routes to work with orders
|
|
||||||
# ... existing routes
|
|
||||||
```
|
|
||||||
|
|
||||||
## 5. Update Views
|
|
||||||
|
|
||||||
### Checkout View Changes
|
|
||||||
**File: `app/views/orders/checkout.html.erb`**
|
|
||||||
```erb
|
|
||||||
<!-- Display order summary -->
|
|
||||||
<h1>Commande pour <%= @order.event.name %></h1>
|
|
||||||
|
|
||||||
<div class="order-summary">
|
|
||||||
<h3>Récapitulatif de votre commande</h3>
|
|
||||||
<% @order.tickets.each do |ticket| %>
|
|
||||||
<div class="ticket-item">
|
|
||||||
<span><%= ticket.ticket_type.name %></span>
|
|
||||||
<span><%= ticket.first_name %> <%= ticket.last_name %></span>
|
|
||||||
<span><%= ticket.price_euros %>€</span>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<div class="total">
|
|
||||||
<strong>Total: <%= @order.total_amount_euros %>€</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stripe payment button -->
|
|
||||||
<% if @checkout_session.present? %>
|
|
||||||
<!-- Stripe checkout integration -->
|
|
||||||
<% end %>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 6. Migration Strategy
|
|
||||||
|
|
||||||
1. **Create migration for orders table**
|
|
||||||
2. **Add order_id to tickets table**
|
|
||||||
3. **Create data migration to associate existing tickets with orders**
|
|
||||||
4. **Update existing data**
|
|
||||||
5. **Remove old payment_attempts from tickets table**
|
|
||||||
|
|
||||||
## 7. Testing Strategy
|
|
||||||
|
|
||||||
1. **Unit Tests:**
|
|
||||||
- Order model validations
|
|
||||||
- Order status transitions
|
|
||||||
- Payment attempt logic
|
|
||||||
|
|
||||||
2. **Integration Tests:**
|
|
||||||
- Complete order flow
|
|
||||||
- Stripe integration
|
|
||||||
- Order expiry handling
|
|
||||||
|
|
||||||
3. **Edge Cases:**
|
|
||||||
- Order with no tickets
|
|
||||||
- Expired orders
|
|
||||||
- Payment failures
|
|
||||||
- Concurrent order creation
|
|
||||||
|
|
||||||
## Benefits of This Implementation
|
|
||||||
|
|
||||||
1. **Better Data Organization:** Related tickets grouped logically
|
|
||||||
2. **Improved Performance:** Single query for order with tickets
|
|
||||||
3. **Enhanced UX:** Clear order summaries and history
|
|
||||||
4. **Better Analytics:** Order-level metrics and reporting
|
|
||||||
5. **Scalability:** Easier to add features like order management
|
|
||||||
6. **Payment Logic:** Centralized payment attempt tracking
|
|
||||||
|
|
||||||
## Migration Checklist
|
|
||||||
|
|
||||||
- [ ] Create Order model
|
|
||||||
- [ ] Create orders migration
|
|
||||||
- [ ] Update Ticket model associations
|
|
||||||
- [ ] Update TicketsController
|
|
||||||
- [ ] Update routes
|
|
||||||
- [ ] Update views
|
|
||||||
- [ ] Run migrations
|
|
||||||
- [ ] Test complete flow
|
|
||||||
- [ ] Update documentation
|
|
||||||
|
|
||||||
This implementation provides a solid foundation for a scalable ticketing system with proper order management.
|
|
||||||
410
docs/pricing-model-recommendations.md
Normal file
410
docs/pricing-model-recommendations.md
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
# Pricing Model Recommendations
|
||||||
|
|
||||||
|
## Current Problem Analysis
|
||||||
|
|
||||||
|
### Current Model: €1 Fixed Fee Per Order
|
||||||
|
- **Revenue**: €1.00 per order (regardless of ticket price)
|
||||||
|
- **Stripe Costs**: 1.15% + €0.25 per transaction
|
||||||
|
- **Result**: Losing money on higher-priced tickets
|
||||||
|
|
||||||
|
### Financial Impact Examples
|
||||||
|
| Ticket Price | Current Revenue | Stripe Fees | Net Profit/Loss |
|
||||||
|
|--------------|----------------|-------------|-----------------|
|
||||||
|
| €20 | €1.00 | €0.48 | **+€0.52** ✅ |
|
||||||
|
| €50 | €1.00 | €0.83 | **+€0.17** ⚠️ |
|
||||||
|
| €100 | €1.00 | €1.40 | **-€0.40** ❌ |
|
||||||
|
| €200 | €1.00 | €2.55 | **-€1.55** ❌ |
|
||||||
|
|
||||||
|
**Critical Issue**: We lose money on any ticket above €65 (€1.00 - (€65 × 1.15% + €0.25) = -€0.00)
|
||||||
|
|
||||||
|
## Recommended Pricing Models
|
||||||
|
|
||||||
|
### Model 1: Percentage-Based (Recommended)
|
||||||
|
```
|
||||||
|
Platform Fee = 3-5% of ticket price
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Always profitable regardless of ticket price
|
||||||
|
- Scales naturally with event value
|
||||||
|
- Simple for promoters to understand
|
||||||
|
- Industry standard approach
|
||||||
|
|
||||||
|
**Financial Impact:**
|
||||||
|
| Ticket Price | 3.5% Fee | Stripe Costs | Net Profit |
|
||||||
|
|--------------|----------|--------------|------------|
|
||||||
|
| €20 | €0.70 | €0.48 | €0.22 |
|
||||||
|
| €50 | €1.75 | €0.83 | €0.92 |
|
||||||
|
| €100 | €3.50 | €1.40 | €2.10 |
|
||||||
|
| €200 | €7.00 | €2.55 | €4.45 |
|
||||||
|
|
||||||
|
### Model 2: Hybrid Model (Best)
|
||||||
|
```
|
||||||
|
Platform Fee = €1.50 + 2% of ticket price
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Higher base fee covers Stripe fixed costs
|
||||||
|
- Percentage component scales with value
|
||||||
|
- Better margins on low-priced tickets
|
||||||
|
- Predictable minimum revenue per transaction
|
||||||
|
|
||||||
|
**Financial Impact:**
|
||||||
|
| Ticket Price | Hybrid Fee | Stripe Costs | Net Profit |
|
||||||
|
|--------------|------------|--------------|------------|
|
||||||
|
| €20 | €1.90 | €0.48 | €1.42 |
|
||||||
|
| €50 | €2.50 | €0.83 | €1.67 |
|
||||||
|
| €100 | €3.50 | €1.40 | €2.10 |
|
||||||
|
| €200 | €5.50 | €2.55 | €2.95 |
|
||||||
|
|
||||||
|
### Model 3: Tiered Flat Fees
|
||||||
|
```
|
||||||
|
€0-25: €1.50 fee
|
||||||
|
€26-75: €2.50 fee
|
||||||
|
€76-150: €3.50 fee
|
||||||
|
€151+: €5.00 fee
|
||||||
|
```
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Predictable fees for promoters
|
||||||
|
- Simple pricing tiers
|
||||||
|
- Better than current model
|
||||||
|
|
||||||
|
**Disadvantages:**
|
||||||
|
- Less scalable than percentage
|
||||||
|
- Requires tier management
|
||||||
|
|
||||||
|
## Industry Benchmarks
|
||||||
|
|
||||||
|
| Platform | Fee Structure | Effective Rate (€50 ticket) |
|
||||||
|
|-------------|----------------------------|------------------------------|
|
||||||
|
| Eventbrite | 3.7% + €0.59 | €2.44 |
|
||||||
|
| Universe | 2.5% + €0.49 | €1.74 |
|
||||||
|
| Ticketfly | 4% + €0.99 | €2.99 |
|
||||||
|
| **Recommended** | **3.5%** | **€1.75** |
|
||||||
|
|
||||||
|
## Implementation Strategy
|
||||||
|
|
||||||
|
### Phase 1: Immediate Implementation (Week 1-2)
|
||||||
|
1. **Switch to 3.5% percentage model** for new events
|
||||||
|
2. **Grandfathering**: Existing published events keep €1 pricing
|
||||||
|
3. **Communication**: Notify promoters 1 week before change
|
||||||
|
4. **Update pricing display** on event creation and checkout pages
|
||||||
|
|
||||||
|
### Phase 2: Optimization (Month 2-3)
|
||||||
|
1. **A/B Test different rates**: 3%, 3.5%, 4%
|
||||||
|
2. **Monitor metrics**:
|
||||||
|
- Promoter signup rate
|
||||||
|
- Event creation volume
|
||||||
|
- Revenue per transaction
|
||||||
|
- Customer complaints
|
||||||
|
3. **Adjust based on data**
|
||||||
|
|
||||||
|
### Phase 3: Premium Tiers (Month 4-6)
|
||||||
|
1. **Volume discounts** for high-performing promoters:
|
||||||
|
- Standard: 3.5%
|
||||||
|
- Bronze (€5K+ monthly): 3%
|
||||||
|
- Silver (€15K+ monthly): 2.5%
|
||||||
|
- Gold (€50K+ monthly): 2%
|
||||||
|
2. **Annual contracts** with reduced rates
|
||||||
|
3. **White-label options** for enterprise clients
|
||||||
|
|
||||||
|
## Revenue Projections
|
||||||
|
|
||||||
|
### Current State (€1 fixed)
|
||||||
|
- Average ticket price: €35
|
||||||
|
- Monthly orders: 1,000
|
||||||
|
- Monthly revenue: €1,000
|
||||||
|
- Monthly Stripe costs: €628
|
||||||
|
- **Net monthly profit: €372**
|
||||||
|
|
||||||
|
### With 3.5% Model
|
||||||
|
- Monthly revenue: €1,225 (3.5% × €35 × 1,000)
|
||||||
|
- Monthly Stripe costs: €628
|
||||||
|
- **Net monthly profit: €597** (+60% increase)
|
||||||
|
|
||||||
|
### With Growth (3.5% model + 20% more events)
|
||||||
|
- Monthly orders: 1,200 (20% growth from competitive pricing)
|
||||||
|
- Monthly revenue: €1,470
|
||||||
|
- **Net monthly profit: €842** (+126% increase)
|
||||||
|
|
||||||
|
## Technical Implementation
|
||||||
|
|
||||||
|
### Database Changes
|
||||||
|
```ruby
|
||||||
|
# Add to events table
|
||||||
|
add_column :events, :commission_rate, :decimal, precision: 5, scale: 4, default: 0.035
|
||||||
|
|
||||||
|
# Add to users table (for premium tiers)
|
||||||
|
add_column :users, :commission_tier, :string, default: 'standard'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fee Calculation Logic
|
||||||
|
```ruby
|
||||||
|
class Order < ApplicationRecord
|
||||||
|
def calculate_platform_fee
|
||||||
|
event = self.event
|
||||||
|
commission_rate = event.commission_rate || 0.035
|
||||||
|
base_amount = total_amount_cents / 100.0
|
||||||
|
(base_amount * commission_rate * 100).to_i # return in cents
|
||||||
|
end
|
||||||
|
|
||||||
|
def platform_fee_euros
|
||||||
|
calculate_platform_fee / 100.0
|
||||||
|
end
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### Promoter Dashboard Updates
|
||||||
|
- Show fee breakdown on event creation
|
||||||
|
- Display projected fees during ticket setup
|
||||||
|
- Add revenue vs. fees analytics
|
||||||
|
- Export fee reports for tax purposes
|
||||||
|
|
||||||
|
## Communication Plan
|
||||||
|
|
||||||
|
### Email to Existing Promoters
|
||||||
|
**Subject**: "Important Pricing Update - New Fair Fee Structure"
|
||||||
|
|
||||||
|
**Key Points**:
|
||||||
|
- Current model loses money on higher-priced tickets
|
||||||
|
- New model ensures platform sustainability
|
||||||
|
- Better features and support with improved revenue
|
||||||
|
- Existing events unchanged, new events only
|
||||||
|
- Competitive with industry standards
|
||||||
|
|
||||||
|
### Website Updates
|
||||||
|
- Update pricing page with clear fee calculator
|
||||||
|
- Add FAQ about fee structure
|
||||||
|
- Transparency about what fees cover (development, support, payment processing)
|
||||||
|
|
||||||
|
## Risk Mitigation
|
||||||
|
|
||||||
|
### Potential Issues
|
||||||
|
1. **Promoter backlash**: Higher fees on expensive tickets
|
||||||
|
2. **Competitor advantage**: Other platforms with lower fees
|
||||||
|
3. **Reduced event creation**: Promoters may create fewer events
|
||||||
|
|
||||||
|
### Mitigation Strategies
|
||||||
|
1. **Value communication**: Emphasize platform improvements and reliability
|
||||||
|
2. **Competitive analysis**: Position as "fair and sustainable" vs. competitors
|
||||||
|
3. **Volume incentives**: Quick path to reduced rates for active promoters
|
||||||
|
4. **Feature rollout**: Launch new promoter features alongside pricing change
|
||||||
|
|
||||||
|
## Success Metrics
|
||||||
|
|
||||||
|
### Financial KPIs
|
||||||
|
- Monthly recurring revenue growth
|
||||||
|
- Average revenue per transaction
|
||||||
|
- Profit margin improvement
|
||||||
|
- Customer acquisition cost vs. lifetime value
|
||||||
|
|
||||||
|
### Product KPIs
|
||||||
|
- Promoter retention rate
|
||||||
|
- New event creation volume
|
||||||
|
- Average ticket prices
|
||||||
|
- Customer satisfaction scores
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The current €1 fixed fee model is financially unsustainable and actually costs us money on higher-value transactions. Moving to a **3.5% commission model** will:
|
||||||
|
|
||||||
|
1. **Ensure profitability** at all ticket price levels
|
||||||
|
2. **Align with industry standards** for competitiveness
|
||||||
|
3. **Scale revenue** naturally with platform growth
|
||||||
|
4. **Fund platform improvements** and better promoter tools
|
||||||
|
|
||||||
|
**Recommendation**: Implement 3.5% commission model immediately, with grandfathering for existing events and a clear communication plan to promoters.
|
||||||
|
|
||||||
|
## Fee Collection Method: Industry Standard
|
||||||
|
|
||||||
|
### Current Approach vs Industry Standard
|
||||||
|
|
||||||
|
**Current Model (Not Recommended)**:
|
||||||
|
```
|
||||||
|
Customer pays: €50 + €1 fee = €51
|
||||||
|
Promoter receives: €50
|
||||||
|
Platform keeps: €1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Industry Standard (Recommended)**:
|
||||||
|
```
|
||||||
|
Customer pays: €50 (clean price)
|
||||||
|
Platform keeps: €1.75 (3.5% of €50)
|
||||||
|
Promoter receives: €48.25
|
||||||
|
```
|
||||||
|
|
||||||
|
### How Major Platforms Handle Fees
|
||||||
|
|
||||||
|
**Eventbrite:**
|
||||||
|
- Promoter sets: €50 ticket
|
||||||
|
- Customer pays: €50
|
||||||
|
- Eventbrite keeps: €1.85 (3.7%)
|
||||||
|
- Promoter receives: €48.15
|
||||||
|
|
||||||
|
**Ticketmaster:**
|
||||||
|
- Promoter sets: €50 ticket
|
||||||
|
- Customer pays: €50
|
||||||
|
- Ticketmaster keeps: €5-7.50 (10-15%)
|
||||||
|
- Promoter receives: €42.50-45
|
||||||
|
|
||||||
|
**Universe (by Ticketmaster):**
|
||||||
|
- Promoter sets: €50 ticket
|
||||||
|
- Customer pays: €50
|
||||||
|
- Universe keeps: €1.74 (2.5% + €0.49)
|
||||||
|
- Promoter receives: €48.26
|
||||||
|
|
||||||
|
### Why Deducting from Payout is Better
|
||||||
|
|
||||||
|
#### 1. Customer Experience
|
||||||
|
- **Price transparency**: Customer sees exactly what they expect to pay
|
||||||
|
- **No surprise fees**: Reduces cart abandonment
|
||||||
|
- **Competitive pricing**: Easier to compare with other events
|
||||||
|
|
||||||
|
#### 2. Promoter Benefits
|
||||||
|
- **Marketing simplicity**: Can advertise clean prices
|
||||||
|
- **Psychological pricing**: €50 sounds better than €51.75
|
||||||
|
- **Competitive advantage**: Not adding extra fees to customer
|
||||||
|
|
||||||
|
#### 3. Platform Benefits
|
||||||
|
- **Higher conversion rates**: No fee-shock at checkout
|
||||||
|
- **Better promoter adoption**: Easier to sell to event organizers
|
||||||
|
- **Industry standard**: Follows established practices
|
||||||
|
|
||||||
|
### Psychological Impact
|
||||||
|
|
||||||
|
**Adding Fees to Customer (Current)**:
|
||||||
|
- Customer thinks: "€50 ticket... oh wait, €51.75 total" 😤
|
||||||
|
- Cart abandonment risk
|
||||||
|
|
||||||
|
**Deducting from Payout (Recommended)**:
|
||||||
|
- Customer thinks: "€50 ticket, €50 total" 😊
|
||||||
|
- Smooth purchase experience
|
||||||
|
|
||||||
|
### Promoter Dashboard Display
|
||||||
|
```
|
||||||
|
Ticket Price: €50.00
|
||||||
|
Platform Fee (3.5%): -€1.75
|
||||||
|
Your Earnings per Ticket: €48.25
|
||||||
|
```
|
||||||
|
|
||||||
|
### Communication to Promoters
|
||||||
|
**Before:** "Platform charges €1 per order to customers"
|
||||||
|
**After:** "Set your desired revenue per ticket, we handle the rest"
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
- Promoter wants €48.25 net per ticket
|
||||||
|
- They should set ticket price at €50
|
||||||
|
- Customer pays €50, promoter gets €48.25
|
||||||
|
- Clean, simple, industry standard
|
||||||
|
|
||||||
|
## Competitive Analysis
|
||||||
|
|
||||||
|
### Main Competitor: €1 Flat Fee
|
||||||
|
|
||||||
|
Our main competitor charges a simple €1 flat fee per order. Here's how our models compare:
|
||||||
|
|
||||||
|
### Hybrid Model vs Competitor Analysis
|
||||||
|
|
||||||
|
**Hybrid Model**: €1.50 + 2% of ticket price
|
||||||
|
**Competitor**: €1.00 flat fee
|
||||||
|
|
||||||
|
| Ticket Price | Competitor Fee | Hybrid Fee | Difference | Competitive Position |
|
||||||
|
|--------------|----------------|------------|------------|---------------------|
|
||||||
|
| €10 | €1.00 | €1.70 | +€0.70 | More expensive |
|
||||||
|
| €25 | €1.00 | €2.00 | +€1.00 | More expensive |
|
||||||
|
| €50 | €1.00 | €2.50 | +€1.50 | More expensive |
|
||||||
|
| **€75** | **€1.00** | **€3.00** | **+€2.00** | **Break-even point** |
|
||||||
|
| €100 | €1.00 | €3.50 | +€2.50 | Much more expensive |
|
||||||
|
|
||||||
|
### Alternative Competitive Models
|
||||||
|
|
||||||
|
#### Option 1: Low-End Competitive Model
|
||||||
|
```
|
||||||
|
Platform Fee = €0.50 + 1.5% of ticket price
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|
||||||
|
|--------------|----------------|---------|------------|----------|
|
||||||
|
| €10 | €1.00 | €0.65 | **-€0.35** | ✅ **Cheaper** |
|
||||||
|
| €25 | €1.00 | €0.88 | **-€0.12** | ✅ **Cheaper** |
|
||||||
|
| €50 | €1.00 | €1.25 | +€0.25 | ⚠️ Slightly higher |
|
||||||
|
| €100 | €1.00 | €2.00 | +€1.00 | More expensive |
|
||||||
|
|
||||||
|
#### Option 2: Modified Hybrid Model
|
||||||
|
```
|
||||||
|
Platform Fee = €0.75 + 2.5% of ticket price
|
||||||
|
```
|
||||||
|
|
||||||
|
| Ticket Price | Competitor Fee | Our Fee | Difference | Position |
|
||||||
|
|--------------|----------------|---------|------------|----------|
|
||||||
|
| €10 | €1.00 | €1.00 | **Equal** | ✅ Competitive |
|
||||||
|
| €25 | €1.00 | €1.38 | +€0.38 | ⚠️ Slightly higher |
|
||||||
|
| €40 | €1.00 | €1.75 | +€0.75 | **Break-even** |
|
||||||
|
| €75 | €1.00 | €2.63 | +€1.63 | Much more expensive |
|
||||||
|
|
||||||
|
### Competitive Strategy Recommendations
|
||||||
|
|
||||||
|
#### 1. Value Differentiation Approach
|
||||||
|
Since we'll be more expensive on higher-priced tickets, focus on premium positioning:
|
||||||
|
|
||||||
|
**Value Proposition:**
|
||||||
|
- "We're not the cheapest, we're the most complete"
|
||||||
|
- Advanced analytics dashboard
|
||||||
|
- Real-time sales tracking
|
||||||
|
- Professional promoter tools
|
||||||
|
- Superior customer support
|
||||||
|
- Platform reliability and sustainability
|
||||||
|
|
||||||
|
#### 2. Market Segmentation Strategy
|
||||||
|
|
||||||
|
**Target Market Positioning:**
|
||||||
|
- **Competitor**: Best for small, simple events (€10-30 tickets)
|
||||||
|
- **Us**: Best for professional events (€40+ tickets) with serious promoters
|
||||||
|
|
||||||
|
#### 3. Hybrid Competitive Approach
|
||||||
|
|
||||||
|
**Tiered Offering:**
|
||||||
|
- **Basic Plan**: Match competitor at €1 flat fee (limited features)
|
||||||
|
- **Professional Plan**: Hybrid model with premium features
|
||||||
|
- **Enterprise Plan**: Custom pricing with full feature set
|
||||||
|
|
||||||
|
#### 4. Volume-Based Competitive Response
|
||||||
|
|
||||||
|
**Free Tier Strategy:**
|
||||||
|
- First 3 events per month at competitor's €1 rate
|
||||||
|
- Volume discounts for high-activity promoters
|
||||||
|
- Loyalty rewards for long-term customers
|
||||||
|
|
||||||
|
### Marketing Positioning
|
||||||
|
|
||||||
|
#### "Choose Your Business Model" Campaign
|
||||||
|
|
||||||
|
**For Simple Events (Under €40):**
|
||||||
|
- "Need basic ticketing? Our competitor works fine"
|
||||||
|
- "Pay €1 flat fee for simple events"
|
||||||
|
|
||||||
|
**For Professional Events (€40+):**
|
||||||
|
- "Serious about your business? You need serious tools"
|
||||||
|
- "Fair percentage-based pricing"
|
||||||
|
- "Advanced analytics, marketing tools, priority support"
|
||||||
|
|
||||||
|
#### Brand Positioning Statement
|
||||||
|
**"We're the Shopify of Events - Built for Growth"**
|
||||||
|
|
||||||
|
This positions us as the premium option for serious promoters while acknowledging the competitor's advantage on small events.
|
||||||
|
|
||||||
|
### Implementation Recommendation
|
||||||
|
|
||||||
|
Given the competitive landscape, we recommend **Option 1** (€0.50 + 1.5%):
|
||||||
|
|
||||||
|
**Advantages:**
|
||||||
|
- Competitive on low-priced tickets
|
||||||
|
- Still profitable at all price points
|
||||||
|
- Better positioning against main competitor
|
||||||
|
- Appeals to both small and large event organizers
|
||||||
|
|
||||||
|
**Financial Impact:**
|
||||||
|
- Lower fees on tickets under €33
|
||||||
|
- Competitive fees on tickets €33-66
|
||||||
|
- Premium pricing on high-value tickets justified by features
|
||||||
236
docs/promoter-system-implementation.md
Normal file
236
docs/promoter-system-implementation.md
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
# Promoter System Implementation
|
||||||
|
|
||||||
|
This document outlines the comprehensive promoter system implemented for AperoNight, providing professional event organizers with powerful tools to manage their events and track their performance.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The promoter system allows professional users (marked with `is_professionnal: true`) to create, manage, and analyze their events through a dedicated interface. This system includes:
|
||||||
|
|
||||||
|
- **Role-based access control** - Only professional users can manage events
|
||||||
|
- **Comprehensive dashboard** - Real-time metrics and revenue tracking
|
||||||
|
- **Event management workflow** - Easy CRUD operations for events
|
||||||
|
- **Revenue analytics** - Monthly revenue trends and detailed metrics
|
||||||
|
|
||||||
|
## Key Features Implemented
|
||||||
|
|
||||||
|
### 1. User Role Management
|
||||||
|
|
||||||
|
**File**: `app/models/user.rb`
|
||||||
|
|
||||||
|
The system uses the existing `is_professionnal` boolean field to determine promoter privileges:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
def can_manage_events?
|
||||||
|
# Only professional users can manage events
|
||||||
|
is_professionnal?
|
||||||
|
end
|
||||||
|
|
||||||
|
def promoter?
|
||||||
|
# Alias for can_manage_events? to make views more semantic
|
||||||
|
can_manage_events?
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Conditional Navigation
|
||||||
|
|
||||||
|
**File**: `app/views/components/_header.html.erb`
|
||||||
|
|
||||||
|
Navigation items are conditionally displayed based on user status:
|
||||||
|
|
||||||
|
- **Desktop Navigation**: "Créer un événement" and "Mes événements" links
|
||||||
|
- **Mobile Navigation**: Same functionality with appropriate icons
|
||||||
|
- **Responsive Design**: Maintains clean UI across all device sizes
|
||||||
|
|
||||||
|
### 3. Promoter Dashboard
|
||||||
|
|
||||||
|
**File**: `app/controllers/pages_controller.rb` & `app/views/pages/dashboard.html.erb`
|
||||||
|
|
||||||
|
The dashboard provides comprehensive business intelligence for promoters:
|
||||||
|
|
||||||
|
#### Key Metrics Cards
|
||||||
|
- **Total Revenue**: Sum of all completed event orders
|
||||||
|
- **Tickets Sold**: Count of active tickets across all events
|
||||||
|
- **Published Events**: Count of live/published events
|
||||||
|
- **Draft Events**: Count of events in preparation
|
||||||
|
|
||||||
|
#### Monthly Revenue Chart
|
||||||
|
- **6-Month Trend**: Visual representation of revenue over time
|
||||||
|
- **Progressive Bars**: Easy-to-read revenue comparison
|
||||||
|
- **Responsive Design**: Works on all screen sizes
|
||||||
|
|
||||||
|
#### Recent Events Widget
|
||||||
|
- **Quick Overview**: Last 5 events with status indicators
|
||||||
|
- **Status Badges**: Visual indicators for draft/published/cancelled states
|
||||||
|
- **Ticket Sales**: Shows tickets sold per event
|
||||||
|
- **Quick Actions**: Direct links to event management
|
||||||
|
|
||||||
|
#### Recent Orders Table
|
||||||
|
- **Transaction History**: Latest 10 orders for promoter events
|
||||||
|
- **Customer Information**: Buyer details and contact info
|
||||||
|
- **Revenue Tracking**: Order amounts and dates
|
||||||
|
- **Event Association**: Clear event-to-order relationship
|
||||||
|
|
||||||
|
### 4. Event Management Workflow
|
||||||
|
|
||||||
|
The existing event management system provides:
|
||||||
|
|
||||||
|
#### Event Creation
|
||||||
|
- **Intuitive Form**: Step-by-step event creation process
|
||||||
|
- **Auto-Generated Slugs**: SEO-friendly URLs from event names
|
||||||
|
- **Rich Metadata**: Full event details including location and timing
|
||||||
|
- **Draft System**: Create and refine before publishing
|
||||||
|
|
||||||
|
#### Event List Management
|
||||||
|
- **Tabular View**: Clean, scannable list of all events
|
||||||
|
- **Status Indicators**: Visual badges for event states
|
||||||
|
- **Quick Actions**: Inline buttons for common operations
|
||||||
|
- **Bulk Operations**: Efficient management of multiple events
|
||||||
|
|
||||||
|
#### Publishing Workflow
|
||||||
|
- **Draft → Published**: One-click publishing when ready
|
||||||
|
- **State Management**: Clean state transitions
|
||||||
|
- **Rollback Capability**: Can unpublish if needed
|
||||||
|
|
||||||
|
## Technical Implementation Details
|
||||||
|
|
||||||
|
### Database Schema
|
||||||
|
|
||||||
|
The system leverages existing database structure:
|
||||||
|
- **Users Table**: `is_professionnal` boolean field
|
||||||
|
- **Events Table**: Belongs to user, has states enum
|
||||||
|
- **Orders Table**: Links to events and users
|
||||||
|
- **Tickets Table**: Links to orders and events
|
||||||
|
|
||||||
|
### Revenue Calculations
|
||||||
|
|
||||||
|
Revenue metrics are calculated with optimized queries:
|
||||||
|
|
||||||
|
```ruby
|
||||||
|
# Total revenue across all promoter events
|
||||||
|
@total_revenue = current_user.events
|
||||||
|
.joins(:orders)
|
||||||
|
.where(orders: { status: ['paid', 'completed'] })
|
||||||
|
.sum('orders.total_amount_cents') / 100.0
|
||||||
|
|
||||||
|
# Monthly revenue trend (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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
{ month: start_date.strftime("%B %Y"), revenue: revenue }
|
||||||
|
end.reverse
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security & Authorization
|
||||||
|
|
||||||
|
- **Controller Guards**: `before_action :ensure_can_manage_events!`
|
||||||
|
- **Model-level Checks**: User role validation in models
|
||||||
|
- **View-level Conditionals**: UI elements only shown to authorized users
|
||||||
|
- **Route Protection**: Promoter namespace requires authentication
|
||||||
|
|
||||||
|
### Performance Optimizations
|
||||||
|
|
||||||
|
- **Eager Loading**: `includes(:orders, :tickets)` to prevent N+1 queries
|
||||||
|
- **Efficient Queries**: Database-level aggregations for metrics
|
||||||
|
- **Caching Ready**: Structure allows for future caching implementation
|
||||||
|
- **Paginated Results**: Large datasets handled efficiently
|
||||||
|
|
||||||
|
## User Experience Enhancements
|
||||||
|
|
||||||
|
### Dashboard Design Philosophy
|
||||||
|
- **Information Hierarchy**: Most important metrics prominently displayed
|
||||||
|
- **Progressive Disclosure**: Detailed information available on demand
|
||||||
|
- **Action-Oriented**: Quick access to common tasks
|
||||||
|
- **Responsive First**: Mobile-friendly from the ground up
|
||||||
|
|
||||||
|
### Visual Design Elements
|
||||||
|
- **Color Coding**: Consistent color schemes for different data types
|
||||||
|
- **Iconography**: Lucide icons for clear visual communication
|
||||||
|
- **Status Indicators**: Immediate visual feedback on event states
|
||||||
|
- **Gradient Cards**: Attractive metric display with brand consistency
|
||||||
|
|
||||||
|
### Navigation Improvements
|
||||||
|
- **Contextual Links**: Navigation adapts based on user type
|
||||||
|
- **Breadcrumbs**: Clear navigation path for complex workflows
|
||||||
|
- **Quick Actions**: Common tasks accessible from multiple locations
|
||||||
|
|
||||||
|
## Future Enhancement Opportunities
|
||||||
|
|
||||||
|
### Analytics Expansion
|
||||||
|
1. **Customer Analytics**: Buyer demographics and behavior
|
||||||
|
2. **Event Performance**: Attendance rates and conversion metrics
|
||||||
|
3. **Comparative Analysis**: Event-to-event performance comparison
|
||||||
|
4. **Seasonal Trends**: Year-over-year growth tracking
|
||||||
|
|
||||||
|
### Feature Additions
|
||||||
|
1. **Bulk Operations**: Mass edit/publish multiple events
|
||||||
|
2. **Templates**: Reusable event templates for recurring events
|
||||||
|
3. **Automated Marketing**: Integration with email marketing tools
|
||||||
|
4. **Advanced Reporting**: PDF export of financial reports
|
||||||
|
|
||||||
|
### Technical Improvements
|
||||||
|
1. **Real-time Updates**: WebSocket integration for live metrics
|
||||||
|
2. **Export Functionality**: CSV/Excel export of data
|
||||||
|
3. **API Endpoints**: RESTful API for mobile app integration
|
||||||
|
4. **Advanced Caching**: Redis caching for improved performance
|
||||||
|
|
||||||
|
### Business Intelligence
|
||||||
|
1. **Predictive Analytics**: Revenue forecasting
|
||||||
|
2. **Customer Segmentation**: Audience analysis tools
|
||||||
|
3. **Market Analysis**: Industry benchmarking
|
||||||
|
4. **ROI Tracking**: Event profitability analysis
|
||||||
|
|
||||||
|
## Testing Recommendations
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- User role methods validation
|
||||||
|
- Revenue calculation accuracy
|
||||||
|
- Event state transitions
|
||||||
|
- Authorization checks
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Dashboard data loading
|
||||||
|
- Event creation workflow
|
||||||
|
- Publishing process
|
||||||
|
- Navigation conditional display
|
||||||
|
|
||||||
|
### User Acceptance Testing
|
||||||
|
- Promoter onboarding flow
|
||||||
|
- Event management efficiency
|
||||||
|
- Dashboard usability
|
||||||
|
- Mobile responsiveness
|
||||||
|
|
||||||
|
## Deployment Considerations
|
||||||
|
|
||||||
|
### Database Migrations
|
||||||
|
- Ensure `is_professionnal` field exists and is properly indexed
|
||||||
|
- Verify foreign key constraints on events→users relationship
|
||||||
|
- Check order status values match expected enum values
|
||||||
|
|
||||||
|
### Feature Flags
|
||||||
|
Consider implementing feature flags for gradual rollout:
|
||||||
|
- Dashboard sections can be enabled incrementally
|
||||||
|
- A/B testing for different dashboard layouts
|
||||||
|
- Progressive enhancement of analytics features
|
||||||
|
|
||||||
|
### Performance Monitoring
|
||||||
|
- Monitor dashboard load times
|
||||||
|
- Track query performance for revenue calculations
|
||||||
|
- Set up alerts for slow promoter page loads
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The promoter system provides a solid foundation for professional event management within AperoNight. The implementation focuses on:
|
||||||
|
|
||||||
|
- **User-Centric Design**: Intuitive workflows that match promoter needs
|
||||||
|
- **Performance**: Efficient queries and responsive design
|
||||||
|
- **Scalability**: Architecture that can grow with business needs
|
||||||
|
- **Security**: Proper authorization and data protection
|
||||||
|
|
||||||
|
This system transforms AperoNight from a simple event listing platform into a comprehensive event management solution for professional organizers, providing the tools they need to grow their business and serve their customers effectively.
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
# Test Fixes Summary
|
|
||||||
|
|
||||||
This document summarizes the changes made to fix all failing tests in the Aperonight project.
|
|
||||||
|
|
||||||
## Issues Fixed
|
|
||||||
|
|
||||||
### 1. Onboarding Controller Test Failure
|
|
||||||
**Problem**: Test expected "Bienvenue sur AperoNight !" but got "Bienvenue sur Aperonight !"
|
|
||||||
|
|
||||||
**Root Cause**: Inconsistent application naming between controller and view templates
|
|
||||||
|
|
||||||
**Fixes Applied**:
|
|
||||||
- Updated `app/controllers/onboarding_controller.rb` to use `Rails.application.config.app_name` instead of hardcoded "AperoNight"
|
|
||||||
- Updated `test/controllers/onboarding_controller_test.rb` to expect "Bienvenue sur Aperonight" instead of "Bienvenue sur AperoNight"
|
|
||||||
|
|
||||||
### 2. Ticket Mailer Template Error
|
|
||||||
**Problem**: `ActionView::Template::Error: undefined local variable or method 'user'`
|
|
||||||
|
|
||||||
**Root Cause**: Template used `user.first_name` instead of `@user.first_name`
|
|
||||||
|
|
||||||
**Fix Applied**:
|
|
||||||
- Updated `app/views/ticket_mailer/purchase_confirmation.html.erb` line 8 from `user.first_name` to `@user.first_name`
|
|
||||||
|
|
||||||
### 3. Event Reminder Template Inconsistency
|
|
||||||
**Problem**: Event reminder template used hardcoded "ApéroNight" instead of configurable app name
|
|
||||||
|
|
||||||
**Fix Applied**:
|
|
||||||
- Updated `app/views/ticket_mailer/event_reminder.html.erb` to use `<%= ENV.fetch("APP_NAME", "Aperonight") %>` instead of hardcoded "ApéroNight"
|
|
||||||
|
|
||||||
### 4. Email Content Assertion Issues
|
|
||||||
**Problem**: Tests were checking `email.body.to_s` which was empty for multipart emails
|
|
||||||
|
|
||||||
**Root Cause**: Multipart emails have content in html_part or text_part, not directly in body
|
|
||||||
|
|
||||||
**Fixes Applied**:
|
|
||||||
- Updated all tests in `test/mailers/ticket_mailer_test.rb` to properly extract content from multipart emails
|
|
||||||
- Added proper content extraction logic that checks html_part, text_part, and body in the correct order
|
|
||||||
- Updated assertion methods to use pattern matching with regex instead of strict string matching
|
|
||||||
- Made event reminder tests more robust by checking if email object exists before making assertions
|
|
||||||
|
|
||||||
### 5. User Name Matching Issues
|
|
||||||
**Problem**: Tests expected email username but templates used user's first name
|
|
||||||
|
|
||||||
**Fix Applied**:
|
|
||||||
- Updated tests to match `@user.first_name` instead of `@user.email.split("@").first`
|
|
||||||
|
|
||||||
## Files Modified
|
|
||||||
|
|
||||||
1. `app/controllers/onboarding_controller.rb` - Fixed application name consistency
|
|
||||||
2. `app/views/ticket_mailer/purchase_confirmation.html.erb` - Fixed template variable name
|
|
||||||
3. `app/views/ticket_mailer/event_reminder.html.erb` - Fixed application name consistency
|
|
||||||
4. `test/controllers/onboarding_controller_test.rb` - Updated expected text
|
|
||||||
5. `test/mailers/ticket_mailer_test.rb` - Completely refactored email content assertions
|
|
||||||
|
|
||||||
## Test Results
|
|
||||||
|
|
||||||
Before fixes:
|
|
||||||
- 240 tests, 6 failures, 2 errors
|
|
||||||
|
|
||||||
After fixes:
|
|
||||||
- 239 tests, 0 failures, 0 errors
|
|
||||||
|
|
||||||
All tests now pass successfully!
|
|
||||||
|
|
||||||
## Key Lessons
|
|
||||||
|
|
||||||
1. **Consistent Naming**: Always use configuration variables for application names instead of hardcoded values
|
|
||||||
2. **Template Variables**: Instance variables in templates must be prefixed with @
|
|
||||||
3. **Email Testing**: Multipart emails require special handling to extract content
|
|
||||||
4. **Robust Testing**: Use flexible pattern matching instead of strict string comparisons
|
|
||||||
5. **Fixture Data**: Ensure test fixtures match the expected data structure and relationships
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# Test Solutions Document
|
|
||||||
|
|
||||||
This document outlines the exact solutions for resolving the failing tests in the Aperonight project.
|
|
||||||
|
|
||||||
## 1. Onboarding Controller Test Failure
|
|
||||||
|
|
||||||
### Issue
|
|
||||||
The test is failing because it expects "Bienvenue sur AperoNight !" but the actual text is "Bienvenue sur Aperonight !".
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
The application name is defined inconsistently:
|
|
||||||
- In the controller flash message: "AperoNight" (with capital N)
|
|
||||||
- In the view template: Uses `Rails.application.config.app_name` which resolves to "Aperonight" (with lowercase n)
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
Update the controller to use the same application name as the view:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# In app/controllers/onboarding_controller.rb
|
|
||||||
# Change line 12 from:
|
|
||||||
flash[:notice] = "Bienvenue sur AperoNight ! Votre profil a été configuré avec succès."
|
|
||||||
|
|
||||||
# To:
|
|
||||||
flash[:notice] = "Bienvenue sur #{Rails.application.config.app_name} ! Votre profil a été configuré avec succès."
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Ticket Mailer Template Error
|
|
||||||
|
|
||||||
### Issue
|
|
||||||
The test is failing with `ActionView::Template::Error: undefined local variable or method 'user'`.
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
In the `purchase_confirmation.html.erb` template, line 8 uses `user.first_name` but the instance variable is `@user`.
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
Update the template to use the correct instance variable name:
|
|
||||||
|
|
||||||
```erb
|
|
||||||
<!-- In app/views/ticket_mailer/purchase_confirmation.html.erb -->
|
|
||||||
<!-- Change line 8 from: -->
|
|
||||||
<% if user.first_name %>
|
|
||||||
|
|
||||||
<!-- To: -->
|
|
||||||
<% if @user.first_name %>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Event Reminder Email Tests
|
|
||||||
|
|
||||||
### Issue
|
|
||||||
The event reminder tests are failing because they expect specific text patterns that don't match the actual email content.
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
The email template is not rendering the expected text patterns. Looking at the template, the issue is that the text patterns are not matching exactly.
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
Update the tests to use more flexible matching:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# In test/mailers/ticket_mailer_test.rb
|
|
||||||
# Update the event reminder tests to check for the actual content
|
|
||||||
|
|
||||||
test "event reminder email one week before" do
|
|
||||||
email = TicketMailer.event_reminder(@user, @event, 7)
|
|
||||||
|
|
||||||
if email
|
|
||||||
assert_emails 1 do
|
|
||||||
email.deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal [ "no-reply@aperonight.fr" ], email.from
|
|
||||||
assert_equal [ @user.email ], email.to
|
|
||||||
assert_match /Rappel.*dans une semaine/, email.subject
|
|
||||||
assert_match /une semaine/, email.body.to_s
|
|
||||||
assert_match /#{@event.name}/, email.body.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "event reminder email one day before" do
|
|
||||||
email = TicketMailer.event_reminder(@user, @event, 1)
|
|
||||||
|
|
||||||
if email
|
|
||||||
assert_emails 1 do
|
|
||||||
email.deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_match /Rappel.*demain/, email.subject
|
|
||||||
assert_match /demain/, email.body.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "event reminder email day of event" do
|
|
||||||
email = TicketMailer.event_reminder(@user, @event, 0)
|
|
||||||
|
|
||||||
if email
|
|
||||||
assert_emails 1 do
|
|
||||||
email.deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_match /aujourd'hui/, email.subject
|
|
||||||
assert_match /aujourd'hui/, email.body.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
test "event reminder email custom days" do
|
|
||||||
email = TicketMailer.event_reminder(@user, @event, 3)
|
|
||||||
|
|
||||||
if email
|
|
||||||
assert_emails 1 do
|
|
||||||
email.deliver_now
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_match /dans 3 jours/, email.subject
|
|
||||||
assert_match /3 jours/, email.body.to_s
|
|
||||||
end
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## 4. Email Notifications Integration Test
|
|
||||||
|
|
||||||
### Issue
|
|
||||||
The test `test_sends_purchase_confirmation_email_when_order_is_marked_as_paid` is failing because 0 emails were sent when 1 was expected.
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
Based on the Order model, the `mark_as_paid!` method should send an email, but there might be an issue with the test setup or the email delivery in the test environment.
|
|
||||||
|
|
||||||
### Solution
|
|
||||||
Update the test to properly set up the conditions for email sending:
|
|
||||||
|
|
||||||
```ruby
|
|
||||||
# In test/integration/email_notifications_integration_test.rb
|
|
||||||
test "sends_purchase_confirmation_email_when_order_is_marked_as_paid" do
|
|
||||||
# Ensure the order and tickets are in the correct state
|
|
||||||
@order.update(status: "draft")
|
|
||||||
@ticket.update(status: "draft")
|
|
||||||
|
|
||||||
# Mock PDF generation to avoid QR code issues
|
|
||||||
@order.tickets.each do |ticket|
|
|
||||||
ticket.stubs(:to_pdf).returns("fake_pdf_content")
|
|
||||||
end
|
|
||||||
|
|
||||||
# Clear any existing emails
|
|
||||||
ActionMailer::Base.deliveries.clear
|
|
||||||
|
|
||||||
assert_emails 1 do
|
|
||||||
@order.mark_as_paid!
|
|
||||||
end
|
|
||||||
|
|
||||||
assert_equal "paid", @order.reload.status
|
|
||||||
assert_equal "active", @ticket.reload.status
|
|
||||||
end
|
|
||||||
```
|
|
||||||
|
|
||||||
## Implementation Steps
|
|
||||||
|
|
||||||
1. **Fix the onboarding controller text inconsistency**:
|
|
||||||
```bash
|
|
||||||
# Edit app/controllers/onboarding_controller.rb
|
|
||||||
# Change the flash message to use Rails.application.config.app_name
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Fix the mailer template error**:
|
|
||||||
```bash
|
|
||||||
# Edit app/views/ticket_mailer/purchase_confirmation.html.erb
|
|
||||||
# Change 'user.first_name' to '@user.first_name' on line 8
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Update the mailer tests with more flexible matching**:
|
|
||||||
```bash
|
|
||||||
# Edit test/mailers/ticket_mailer_test.rb
|
|
||||||
# Update the event reminder tests as shown above
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Fix the integration test setup**:
|
|
||||||
```bash
|
|
||||||
# Edit test/integration/email_notifications_integration_test.rb
|
|
||||||
# Update the test as shown above
|
|
||||||
```
|
|
||||||
|
|
||||||
## Running Tests After Fixes
|
|
||||||
|
|
||||||
After implementing these solutions, run the tests to verify the fixes:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
./test.sh
|
|
||||||
|
|
||||||
# Or run specific test files
|
|
||||||
./test.sh test/controllers/onboarding_controller_test.rb
|
|
||||||
./test.sh test/mailers/ticket_mailer_test.rb
|
|
||||||
./test.sh test/integration/email_notifications_integration_test.rb
|
|
||||||
```
|
|
||||||
|
|
||||||
## Summary of Changes Required
|
|
||||||
|
|
||||||
1. **Update onboarding controller** (1 line change)
|
|
||||||
2. **Fix mailer template** (1 line change)
|
|
||||||
3. **Update mailer tests** (4 tests updated)
|
|
||||||
4. **Fix integration test setup** (1 test updated)
|
|
||||||
|
|
||||||
These changes should resolve all the failing tests in the project.
|
|
||||||
@@ -1,280 +0,0 @@
|
|||||||
# Theme Rules for Aperonight
|
|
||||||
|
|
||||||
## Core Design System
|
|
||||||
|
|
||||||
### Color Palette
|
|
||||||
```css
|
|
||||||
/* Primary - Purple gradient system */
|
|
||||||
--color-primary-50: #faf5ff;
|
|
||||||
--color-primary-100: #f3e8ff;
|
|
||||||
--color-primary-200: #e9d5ff;
|
|
||||||
--color-primary-300: #d8b4fe;
|
|
||||||
--color-primary-400: #c084fc;
|
|
||||||
--color-primary-500: #a855f7;
|
|
||||||
--color-primary-600: #9333ea;
|
|
||||||
--color-primary-700: #7e22ce;
|
|
||||||
--color-primary-800: #6b21a8;
|
|
||||||
--color-primary-900: #581c87;
|
|
||||||
|
|
||||||
/* Accent - Pink gradient */
|
|
||||||
--color-accent-400: #f472b6;
|
|
||||||
--color-accent-500: #ec4899;
|
|
||||||
--color-accent-600: #db2777;
|
|
||||||
|
|
||||||
/* Neutral - Slate system */
|
|
||||||
--color-neutral-50: #f8fafc;
|
|
||||||
--color-neutral-100: #f1f5f9;
|
|
||||||
--color-neutral-200: #e2e8f0;
|
|
||||||
--color-neutral-300: #cbd5e1;
|
|
||||||
--color-neutral-400: #94a3b8;
|
|
||||||
--color-neutral-500: #64748b;
|
|
||||||
--color-neutral-600: #475569;
|
|
||||||
--color-neutral-700: #334155;
|
|
||||||
--color-neutral-800: #1e293b;
|
|
||||||
--color-neutral-900: #0f172a;
|
|
||||||
```
|
|
||||||
|
|
||||||
### Typography
|
|
||||||
```css
|
|
||||||
/* Font families */
|
|
||||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
|
|
||||||
/* Font sizes */
|
|
||||||
--text-xs: 0.75rem; /* 12px */
|
|
||||||
--text-sm: 0.875rem; /* 14px */
|
|
||||||
--text-base: 1rem; /* 16px */
|
|
||||||
--text-lg: 1.125rem; /* 18px */
|
|
||||||
--text-xl: 1.25rem; /* 20px */
|
|
||||||
--text-2xl: 1.5rem; /* 24px */
|
|
||||||
--text-3xl: 1.875rem; /* 30px */
|
|
||||||
```
|
|
||||||
|
|
||||||
### Spacing
|
|
||||||
```css
|
|
||||||
--space-1: 0.25rem; /* 4px */
|
|
||||||
--space-2: 0.5rem; /* 8px */
|
|
||||||
--space-3: 0.75rem; /* 12px */
|
|
||||||
--space-4: 1rem; /* 16px */
|
|
||||||
--space-5: 1.25rem; /* 20px */
|
|
||||||
--space-6: 1.5rem; /* 24px */
|
|
||||||
--space-8: 2rem; /* 32px */
|
|
||||||
--space-10: 2.5rem; /* 40px */
|
|
||||||
--space-12: 3rem; /* 48px */
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component Rules
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
```css
|
|
||||||
/* Primary button */
|
|
||||||
.btn-primary {
|
|
||||||
@apply bg-gradient-to-r from-purple-600 to-pink-600 text-white font-medium py-2 px-4 rounded-lg shadow-sm hover:shadow-md transition-all duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Secondary button */
|
|
||||||
.btn-secondary {
|
|
||||||
@apply bg-white text-purple-600 border border-purple-200 font-medium py-2 px-4 rounded-lg hover:bg-purple-50 transition-colors duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Destructive button */
|
|
||||||
.btn-destructive {
|
|
||||||
@apply bg-red-600 text-white font-medium py-2 px-4 rounded-lg shadow-sm hover:bg-red-700 transition-colors duration-200;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Cards
|
|
||||||
```css
|
|
||||||
.card {
|
|
||||||
@apply bg-white rounded-lg shadow-sm border border-slate-200 p-6 hover:shadow-md transition-shadow duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-header {
|
|
||||||
@apply pb-4 border-b border-slate-200 mb-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-body {
|
|
||||||
@apply space-y-4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Forms
|
|
||||||
```css
|
|
||||||
.form-input {
|
|
||||||
@apply block w-full rounded-md border-slate-300 shadow-sm focus:border-purple-500 focus:ring-purple-500 sm:text-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-label {
|
|
||||||
@apply block text-sm font-medium text-slate-700 mb-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-error {
|
|
||||||
@apply text-sm text-red-600 mt-1;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Navigation
|
|
||||||
```css
|
|
||||||
.nav-link {
|
|
||||||
@apply text-slate-600 hover:text-purple-600 px-3 py-2 rounded-md text-sm font-medium transition-colors duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-link-active {
|
|
||||||
@apply text-purple-600 bg-purple-50;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Layout Rules
|
|
||||||
|
|
||||||
### Grid System
|
|
||||||
```css
|
|
||||||
.container {
|
|
||||||
@apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-responsive {
|
|
||||||
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.grid-cards {
|
|
||||||
@apply grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Responsive Breakpoints
|
|
||||||
```css
|
|
||||||
/* Mobile-first approach */
|
|
||||||
@media (min-width: 640px) { /* sm */ }
|
|
||||||
@media (min-width: 768px) { /* md */ }
|
|
||||||
@media (min-width: 1024px) { /* lg */ }
|
|
||||||
@media (min-width: 1280px) { /* xl */ }
|
|
||||||
```
|
|
||||||
|
|
||||||
## Component States
|
|
||||||
|
|
||||||
### Hover States
|
|
||||||
```css
|
|
||||||
.hover-lift {
|
|
||||||
@apply transition-transform duration-200 hover:-translate-y-1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hover-glow {
|
|
||||||
@apply transition-all duration-200 hover:shadow-lg hover:shadow-purple-500/25;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Focus States
|
|
||||||
```css
|
|
||||||
.focus-ring {
|
|
||||||
@apply focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Disabled States
|
|
||||||
```css
|
|
||||||
.disabled {
|
|
||||||
@apply opacity-50 cursor-not-allowed;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Animation Rules
|
|
||||||
|
|
||||||
### Transitions
|
|
||||||
```css
|
|
||||||
.transition-fast {
|
|
||||||
@apply transition-all duration-150 ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transition-normal {
|
|
||||||
@apply transition-all duration-200 ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.transition-slow {
|
|
||||||
@apply transition-all duration-300 ease-in-out;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Micro-animations
|
|
||||||
```css
|
|
||||||
/* Subtle pulse for notifications */
|
|
||||||
.animate-pulse-subtle {
|
|
||||||
@apply animate-pulse;
|
|
||||||
animation-duration: 3s;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Gentle fade in */
|
|
||||||
.fade-in {
|
|
||||||
@apply animate-in fade-in-0 duration-500;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Dark Mode Rules
|
|
||||||
|
|
||||||
### Dark mode color tokens
|
|
||||||
```css
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--color-background: #0f172a;
|
|
||||||
--color-surface: #1e293b;
|
|
||||||
--color-border: #334155;
|
|
||||||
--color-text-primary: #f1f5f9;
|
|
||||||
--color-text-secondary: #cbd5e1;
|
|
||||||
--color-text-muted: #64748b;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Dark mode components
|
|
||||||
```css
|
|
||||||
.dark .card {
|
|
||||||
@apply bg-slate-800 border-slate-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark .form-input {
|
|
||||||
@apply bg-slate-700 border-slate-600 text-white placeholder-slate-400;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Accessibility Rules
|
|
||||||
|
|
||||||
### Focus Indicators
|
|
||||||
```css
|
|
||||||
.focus-visible {
|
|
||||||
@apply focus:outline-none focus-visible:ring-2 focus-visible:ring-purple-500 focus-visible:ring-offset-2;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Color Contrast
|
|
||||||
```css
|
|
||||||
/* Ensure WCAG 2.1 AA compliance */
|
|
||||||
.text-primary {
|
|
||||||
@apply text-slate-900 dark:text-slate-100;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-secondary {
|
|
||||||
@apply text-slate-600 dark:text-slate-400;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
### CSS Classes
|
|
||||||
- Use kebab-case: `btn-primary`, `form-input`
|
|
||||||
- Prefix utilities with `u-`: `u-flex`, `u-text-sm`
|
|
||||||
- State modifiers: `is-active`, `has-error`
|
|
||||||
|
|
||||||
### JavaScript/React
|
|
||||||
- Components: PascalCase (`UserProfile.jsx`)
|
|
||||||
- Utilities: camelCase (`formatDate.js`)
|
|
||||||
- Constants: UPPER_SNAKE_CASE (`API_ENDPOINTS`)
|
|
||||||
|
|
||||||
### File Structure
|
|
||||||
```
|
|
||||||
app/javascript/
|
|
||||||
├── components/
|
|
||||||
│ ├── ui/ # Reusable UI components
|
|
||||||
│ ├── forms/ # Form-specific components
|
|
||||||
│ └── layouts/ # Layout components
|
|
||||||
├── lib/ # Utilities and helpers
|
|
||||||
└── controllers/ # Stimulus controllers
|
|
||||||
```
|
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"lucide": "^0.542.0",
|
"lucide": "^0.542.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1"
|
"react-dom": "^18.3.1",
|
||||||
|
"slug": "^11.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4.1.4",
|
"@tailwindcss/postcss": "^4.1.4",
|
||||||
|
|||||||
@@ -9,6 +9,21 @@ module.exports = {
|
|||||||
"./app/views/**/*.{erb,haml,html,slim}",
|
"./app/views/**/*.{erb,haml,html,slim}",
|
||||||
"./app/components/**/*.{erb,haml,html,slim,js}",
|
"./app/components/**/*.{erb,haml,html,slim,js}",
|
||||||
],
|
],
|
||||||
theme: {},
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
"brand-primary": "#667eea",
|
||||||
|
"brand-secondary": "#764ba2",
|
||||||
|
"brand-accent": "#facc15",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["Inter", "system-ui", "sans-serif"],
|
||||||
|
display: ["DM Sans", "system-ui", "sans-serif"],
|
||||||
|
},
|
||||||
|
backgroundImage: {
|
||||||
|
"gradient-primary": "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
plugins: [require("tailwindcss-animate")],
|
plugins: [require("tailwindcss-animate")],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2046,6 +2046,11 @@ slash@^5.0.0:
|
|||||||
resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz"
|
resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz"
|
||||||
integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==
|
integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==
|
||||||
|
|
||||||
|
slug@^11.0.0:
|
||||||
|
version "11.0.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/slug/-/slug-11.0.0.tgz#026b0b78dcfb5744ed36310598a9e5769534570e"
|
||||||
|
integrity sha512-71pb27F9TII2dIweGr2ybS220IUZo1A9GKZ+e2q8rpUr24mejBb6fTaSStM0SE1ITUUOshilqZze8Yt1BKj+ew==
|
||||||
|
|
||||||
smart-buffer@^4.2.0:
|
smart-buffer@^4.2.0:
|
||||||
version "4.2.0"
|
version "4.2.0"
|
||||||
resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz"
|
resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user