feat: Implement the promoter event creation

- Promoter can now create an event in draft mode
- Place is found based on address and long/lat are automatically
  deducted from it
- Slug is forged using the *slug* npm package instead of custom code
This commit is contained in:
kbe
2025-09-10 20:46:31 +02:00
parent 9b5d8fcf97
commit 1a7fb818df
19 changed files with 552 additions and 1661 deletions

View File

@@ -18,6 +18,6 @@ function initializeLucideIcons() {
// Run on initial page load
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:frame-render', initializeLucideIcons);

View File

@@ -1,17 +1,18 @@
import { Controller } from "@hotwired/stimulus"
import slug from 'slug'
export default class extends Controller {
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer"]
static values = {
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 &&
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
this.hasLatitudeTarget && this.hasLongitudeTarget &&
this.latitudeTarget.value && this.longitudeTarget.value) {
this.updateMapLinks()
}
@@ -26,14 +27,8 @@ export default class extends Controller {
// Generate slug from name
generateSlug() {
const name = this.nameTarget.value
const slug = name
.toLowerCase()
.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
this.slugTarget.value = slug(name)
}
// Handle address changes with debounced geocoding
@@ -44,7 +39,7 @@ export default class extends Controller {
}
const address = this.addressTarget.value.trim()
if (!address) {
this.clearCoordinates()
this.clearMapLinks()
@@ -76,27 +71,27 @@ export default class extends Controller {
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é."
@@ -108,7 +103,7 @@ export default class extends Controller {
message = "La demande de localisation a expiré."
break
}
this.showLocationError(message)
}
}
@@ -125,11 +120,11 @@ export default class extends Controller {
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)
@@ -145,7 +140,7 @@ export default class extends Controller {
}
// 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.updateMapLinks()
this.showLocationSuccess("Liens de carte mis à jour!")
@@ -163,11 +158,11 @@ export default class extends Controller {
}
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
@@ -187,7 +182,7 @@ export default class extends Controller {
async geocodeAddressQuiet(address) {
try {
const result = await this.performGeocode(address)
if (result) {
this.latitudeTarget.value = result.lat
this.longitudeTarget.value = result.lng
@@ -207,7 +202,7 @@ export default class extends Controller {
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 {
@@ -215,7 +210,7 @@ export default class extends Controller {
lng: parseFloat(result.lon).toFixed(6)
}
}
return null
}
@@ -226,7 +221,7 @@ export default class extends Controller {
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
@@ -239,7 +234,7 @@ export default class extends Controller {
// Generate map links HTML
generateMapLinks(lat, lng, address) {
const encodedAddress = encodeURIComponent(address)
const providers = {
openstreetmap: {
name: "OpenStreetMap",
@@ -247,7 +242,7 @@ export default class extends Controller {
icon: "🗺️"
},
google: {
name: "Google Maps",
name: "Google Maps",
url: `https://www.google.com/maps/search/${encodedAddress}/@${lat},${lng},16z`,
icon: "🔍"
},
@@ -266,7 +261,7 @@ export default class extends Controller {
</div>
<div class="flex flex-wrap gap-2">
${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">
<span class="mr-2">${provider.icon}</span>
${provider.name}
@@ -327,7 +322,7 @@ export default class extends Controller {
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",
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"
}
@@ -372,4 +367,4 @@ export default class extends Controller {
this.hideMessage("location-error")
this.hideMessage("geocoding-warning")
}
}
}

View File

@@ -1,6 +1,6 @@
class Order < ApplicationRecord
# === Constants ===
DRAFT_EXPIRY_TIME = 30.minutes
DRAFT_EXPIRY_TIME = 15.minutes
MAX_PAYMENT_ATTEMPTS = 3
# === 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>
</div>
<% 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">
Gérez vos informations et préférences
Gérez vos informations et préférences de sécurité
</p>
</div>
<!-- Profile Form -->
<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>
<%= 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 %>
@@ -39,35 +39,6 @@
</div>
<% 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>
<%= f.label :current_password, "Mot de passe actuel", class: "block text-sm font-semibold text-gray-700 mb-2" %>
<div class="relative">
@@ -82,6 +53,35 @@
</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">
<%= 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>
@@ -91,30 +91,14 @@
<% end %>
</div>
<!-- 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">
<%= 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>
<%# render "components/delete_account" %>
<!-- Back Link -->
<div class="text-center">
<%= 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>
<%= t('devise.registrations.edit.back') %>
Retour
<% end %>
</div>
</div>
</div>
</div>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="fr">
<head>
<title><%= content_for(:title) || "Aperonight" %></title>
<meta name="viewport" content="width=device-width,initial-scale=1">
@@ -10,35 +10,11 @@
<%= yield :head %>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<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">
<!-- 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>
<!--<link rel="preconnect" href="https://fonts.googleapis.com">-->
<!--<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">-->
<!-- 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!) %>
<%#= tag.link rel: "manifest", href: pwa_manifest_path(format: :json) %>