develop #3

Merged
kbe merged 227 commits from develop into main 2025-09-16 14:35:23 +00:00
19 changed files with 552 additions and 1661 deletions
Showing only changes of commit 1a7fb818df - Show all commits

View File

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

View File

@@ -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);

View File

@@ -1,17 +1,18 @@
import { Controller } from "@hotwired/stimulus" import { Controller } from "@hotwired/stimulus"
import slug from 'slug'
export default class extends Controller { export default class extends Controller {
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"] static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"]
static values = { static values = {
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
} }
connect() { connect() {
this.geocodeTimeout = null this.geocodeTimeout = null
// Initialize map links if we have an address and coordinates already exist // Initialize map links if we have an address and coordinates already exist
if (this.hasAddressTarget && this.addressTarget.value.trim() && if (this.hasAddressTarget && this.addressTarget.value.trim() &&
this.hasLatitudeTarget && this.hasLongitudeTarget && this.hasLatitudeTarget && this.hasLongitudeTarget &&
this.latitudeTarget.value && this.longitudeTarget.value) { this.latitudeTarget.value && this.longitudeTarget.value) {
this.updateMapLinks() this.updateMapLinks()
} }
@@ -26,14 +27,8 @@ export default class extends Controller {
// Generate slug from name // Generate slug from name
generateSlug() { generateSlug() {
const name = this.nameTarget.value const name = this.nameTarget.value
const slug = name
.toLowerCase() this.slugTarget.value = slug(name)
.replace(/[^a-z0-9\s-]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.replace(/^-|-$/g, '') // Remove leading/trailing hyphens
this.slugTarget.value = slug
} }
// Handle address changes with debounced geocoding // Handle address changes with debounced geocoding
@@ -44,7 +39,7 @@ export default class extends Controller {
} }
const address = this.addressTarget.value.trim() const address = this.addressTarget.value.trim()
if (!address) { if (!address) {
this.clearCoordinates() this.clearCoordinates()
this.clearMapLinks() this.clearMapLinks()
@@ -76,27 +71,27 @@ export default class extends Controller {
const position = await this.getCurrentPositionPromise(options) const position = await this.getCurrentPositionPromise(options)
const lat = position.coords.latitude const lat = position.coords.latitude
const lng = position.coords.longitude const lng = position.coords.longitude
// Set coordinates first // Set coordinates first
this.latitudeTarget.value = lat.toFixed(6) this.latitudeTarget.value = lat.toFixed(6)
this.longitudeTarget.value = lng.toFixed(6) this.longitudeTarget.value = lng.toFixed(6)
// Then reverse geocode to get address // Then reverse geocode to get address
const address = await this.reverseGeocode(lat, lng) const address = await this.reverseGeocode(lat, lng)
if (address) { if (address) {
this.addressTarget.value = address this.addressTarget.value = address
this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!") this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
} else { } else {
this.showLocationSuccess("Position actuelle détectée!") this.showLocationSuccess("Position actuelle détectée!")
} }
this.updateMapLinks() this.updateMapLinks()
} catch (error) { } catch (error) {
this.hideLocationLoading() this.hideLocationLoading()
let message = "Erreur lors de la récupération de la localisation." let message = "Erreur lors de la récupération de la localisation."
switch(error.code) { switch(error.code) {
case error.PERMISSION_DENIED: case error.PERMISSION_DENIED:
message = "L'accès à la localisation a été refusé." message = "L'accès à la localisation a été refusé."
@@ -108,7 +103,7 @@ export default class extends Controller {
message = "La demande de localisation a expiré." message = "La demande de localisation a expiré."
break break
} }
this.showLocationError(message) this.showLocationError(message)
} }
} }
@@ -125,11 +120,11 @@ export default class extends Controller {
try { try {
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`) const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json`)
const data = await response.json() const data = await response.json()
if (data && data.display_name) { if (data && data.display_name) {
return data.display_name return data.display_name
} }
return null return null
} catch (error) { } catch (error) {
console.log("Reverse geocoding failed:", error) console.log("Reverse geocoding failed:", error)
@@ -145,7 +140,7 @@ export default class extends Controller {
} }
// If we already have coordinates, just update map links // If we already have coordinates, just update map links
if (this.hasLatitudeTarget && this.hasLongitudeTarget && if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
this.latitudeTarget.value && this.longitudeTarget.value) { this.latitudeTarget.value && this.longitudeTarget.value) {
this.updateMapLinks() this.updateMapLinks()
this.showLocationSuccess("Liens de carte mis à jour!") this.showLocationSuccess("Liens de carte mis à jour!")
@@ -163,11 +158,11 @@ export default class extends Controller {
} }
const address = this.addressTarget.value.trim() const address = this.addressTarget.value.trim()
try { try {
this.showLocationLoading() this.showLocationLoading()
const result = await this.performGeocode(address) const result = await this.performGeocode(address)
if (result) { if (result) {
this.latitudeTarget.value = result.lat this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng this.longitudeTarget.value = result.lng
@@ -187,7 +182,7 @@ export default class extends Controller {
async geocodeAddressQuiet(address) { async geocodeAddressQuiet(address) {
try { try {
const result = await this.performGeocode(address) const result = await this.performGeocode(address)
if (result) { if (result) {
this.latitudeTarget.value = result.lat this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng this.longitudeTarget.value = result.lng
@@ -207,7 +202,7 @@ export default class extends Controller {
const encodedAddress = encodeURIComponent(address) const encodedAddress = encodeURIComponent(address)
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`) const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1`)
const data = await response.json() const data = await response.json()
if (data && data.length > 0) { if (data && data.length > 0) {
const result = data[0] const result = data[0]
return { return {
@@ -215,7 +210,7 @@ export default class extends Controller {
lng: parseFloat(result.lon).toFixed(6) lng: parseFloat(result.lon).toFixed(6)
} }
} }
return null return null
} }
@@ -226,7 +221,7 @@ export default class extends Controller {
const lat = parseFloat(this.latitudeTarget.value) const lat = parseFloat(this.latitudeTarget.value)
const lng = parseFloat(this.longitudeTarget.value) const lng = parseFloat(this.longitudeTarget.value)
const address = this.hasAddressTarget ? this.addressTarget.value.trim() : "" const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
if (isNaN(lat) || isNaN(lng) || !address) { if (isNaN(lat) || isNaN(lng) || !address) {
this.clearMapLinks() this.clearMapLinks()
return return
@@ -239,7 +234,7 @@ export default class extends Controller {
// Generate map links HTML // Generate map links HTML
generateMapLinks(lat, lng, address) { generateMapLinks(lat, lng, address) {
const encodedAddress = encodeURIComponent(address) const encodedAddress = encodeURIComponent(address)
const providers = { const providers = {
openstreetmap: { openstreetmap: {
name: "OpenStreetMap", name: "OpenStreetMap",
@@ -247,7 +242,7 @@ export default class extends Controller {
icon: "🗺️" icon: "🗺️"
}, },
google: { google: {
name: "Google Maps", name: "Google Maps",
url: `https://www.google.com/maps/search/${encodedAddress}/@${lat},${lng},16z`, url: `https://www.google.com/maps/search/${encodedAddress}/@${lat},${lng},16z`,
icon: "🔍" icon: "🔍"
}, },
@@ -266,7 +261,7 @@ export default class extends Controller {
</div> </div>
<div class="flex flex-wrap gap-2"> <div class="flex flex-wrap gap-2">
${Object.entries(providers).map(([key, provider]) => ` ${Object.entries(providers).map(([key, provider]) => `
<a href="${provider.url}" target="_blank" rel="noopener" <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"> 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> <span class="mr-2">${provider.icon}</span>
${provider.name} ${provider.name}
@@ -327,7 +322,7 @@ export default class extends Controller {
showMessage(id, message, type) { showMessage(id, message, type) {
const colors = { const colors = {
info: "bg-blue-50 border-blue-200 text-blue-800", info: "bg-blue-50 border-blue-200 text-blue-800",
success: "bg-green-50 border-green-200 text-green-800", success: "bg-green-50 border-green-200 text-green-800",
error: "bg-red-50 border-red-200 text-red-800", error: "bg-red-50 border-red-200 text-red-800",
warning: "bg-yellow-50 border-yellow-200 text-yellow-800" warning: "bg-yellow-50 border-yellow-200 text-yellow-800"
} }
@@ -372,4 +367,4 @@ export default class extends Controller {
this.hideMessage("location-error") this.hideMessage("location-error")
this.hideMessage("geocoding-warning") this.hideMessage("geocoding-warning")
} }
} }

View File

@@ -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 ===

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

View File

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

View File

@@ -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) %>

View File

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

View File

@@ -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
View File

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

View File

@@ -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.

View File

@@ -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.

View 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

View File

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

View File

@@ -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.

View File

@@ -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
```

View File

@@ -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",

View File

@@ -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")],
}; };

View File

@@ -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"