feat: Implement SEO-friendly slug generation and improve geocoding UX
- Add Rails parameterize for server-side slug generation (name-venue-city format) - Configure client-side slug library with RFC3986 mode for consistency - Remove slug field from edit forms to prevent URL changes after publication - Enable image_processing gem for Active Storage variants - Make geocoding notifications visible indefinitely on promoter event forms - Add server-side slug generation fallback with uniqueness validation - Update promoter controller to allow slug only for new events 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2
Gemfile
2
Gemfile
@@ -40,7 +40,7 @@ gem "kamal", require: false
|
|||||||
gem "thruster", require: false
|
gem "thruster", require: false
|
||||||
|
|
||||||
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
|
# Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images]
|
||||||
# gem "image_processing", "~> 1.2"
|
gem "image_processing", "~> 1.2"
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
# See https://guides.rubyonrails.org/debugging_rails_applications.html#debugging-with-the-debug-gem
|
||||||
|
|||||||
16
Gemfile.lock
16
Gemfile.lock
@@ -122,6 +122,13 @@ GEM
|
|||||||
erubi (1.13.1)
|
erubi (1.13.1)
|
||||||
et-orbi (1.3.0)
|
et-orbi (1.3.0)
|
||||||
tzinfo
|
tzinfo
|
||||||
|
ffi (1.17.2-aarch64-linux-gnu)
|
||||||
|
ffi (1.17.2-aarch64-linux-musl)
|
||||||
|
ffi (1.17.2-arm-linux-gnu)
|
||||||
|
ffi (1.17.2-arm-linux-musl)
|
||||||
|
ffi (1.17.2-x86_64-darwin)
|
||||||
|
ffi (1.17.2-x86_64-linux-gnu)
|
||||||
|
ffi (1.17.2-x86_64-linux-musl)
|
||||||
fugit (1.11.2)
|
fugit (1.11.2)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
@@ -129,6 +136,9 @@ GEM
|
|||||||
activesupport (>= 6.1)
|
activesupport (>= 6.1)
|
||||||
i18n (1.14.7)
|
i18n (1.14.7)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
|
image_processing (1.14.0)
|
||||||
|
mini_magick (>= 4.9.5, < 6)
|
||||||
|
ruby-vips (>= 2.0.17, < 3)
|
||||||
io-console (0.8.1)
|
io-console (0.8.1)
|
||||||
irb (1.15.2)
|
irb (1.15.2)
|
||||||
pp (>= 0.6.0)
|
pp (>= 0.6.0)
|
||||||
@@ -177,6 +187,8 @@ GEM
|
|||||||
net-smtp
|
net-smtp
|
||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
matrix (0.4.3)
|
matrix (0.4.3)
|
||||||
|
mini_magick (5.3.1)
|
||||||
|
logger
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (5.25.5)
|
minitest (5.25.5)
|
||||||
minitest-reporters (1.7.1)
|
minitest-reporters (1.7.1)
|
||||||
@@ -333,6 +345,9 @@ GEM
|
|||||||
rubocop-performance (>= 1.24)
|
rubocop-performance (>= 1.24)
|
||||||
rubocop-rails (>= 2.30)
|
rubocop-rails (>= 2.30)
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
|
ruby-vips (2.2.5)
|
||||||
|
ffi (~> 1.12)
|
||||||
|
logger
|
||||||
ruby2_keywords (0.0.5)
|
ruby2_keywords (0.0.5)
|
||||||
rubyzip (3.0.2)
|
rubyzip (3.0.2)
|
||||||
securerandom (0.4.1)
|
securerandom (0.4.1)
|
||||||
@@ -429,6 +444,7 @@ DEPENDENCIES
|
|||||||
debug
|
debug
|
||||||
devise (~> 4.9)
|
devise (~> 4.9)
|
||||||
dotenv-rails
|
dotenv-rails
|
||||||
|
image_processing (~> 1.2)
|
||||||
jbuilder
|
jbuilder
|
||||||
jsbundling-rails
|
jsbundling-rails
|
||||||
kamal
|
kamal
|
||||||
|
|||||||
@@ -134,10 +134,18 @@ class Promoter::EventsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def event_params
|
def event_params
|
||||||
params.require(:event).permit(
|
if action_name == 'create'
|
||||||
:name, :slug, :description, :image,
|
params.require(:event).permit(
|
||||||
:venue_name, :venue_address, :latitude, :longitude,
|
:name, :slug, :description, :image,
|
||||||
:start_time, :end_time, :featured, :allow_booking_during_event
|
:venue_name, :venue_address, :latitude, :longitude,
|
||||||
)
|
:start_time, :end_time, :featured, :allow_booking_during_event
|
||||||
|
)
|
||||||
|
else
|
||||||
|
params.require(:event).permit(
|
||||||
|
:name, :description, :image,
|
||||||
|
:venue_name, :venue_address, :latitude, :longitude,
|
||||||
|
:start_time, :end_time, :featured, :allow_booking_during_event
|
||||||
|
)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
import slug from 'slug'
|
import slug from 'slug'
|
||||||
|
|
||||||
|
// Configure slug to match Rails parameterize behavior
|
||||||
|
slug.defaults.mode = 'rfc3986'
|
||||||
|
|
||||||
export default class extends Controller {
|
export default class extends Controller {
|
||||||
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
|
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer", "venueName"]
|
||||||
static values = {
|
static values = {
|
||||||
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
||||||
}
|
}
|
||||||
@@ -27,15 +30,65 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate slug from name
|
// Generate slug from name, venue name, and city for better SEO
|
||||||
generateSlug() {
|
generateSlug() {
|
||||||
const name = this.nameTarget.value
|
const name = this.nameTarget.value
|
||||||
|
const venueName = this.hasVenueNameTarget ? this.venueNameTarget.value : ""
|
||||||
|
const address = this.hasAddressTarget ? this.addressTarget.value : ""
|
||||||
|
|
||||||
this.slugTarget.value = slug(name)
|
// Extract city from address
|
||||||
|
const city = this.extractCity(address)
|
||||||
|
|
||||||
|
// Build SEO-friendly slug: name-venue-city
|
||||||
|
let slugParts = []
|
||||||
|
|
||||||
|
if (name) slugParts.push(name)
|
||||||
|
if (venueName) slugParts.push(venueName)
|
||||||
|
if (city) slugParts.push(city)
|
||||||
|
|
||||||
|
let slugValue = slugParts.join('-')
|
||||||
|
|
||||||
|
// If no slug parts, generate a fallback slug
|
||||||
|
if (!slugValue) {
|
||||||
|
slugValue = `event-${Date.now()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate slug with proper character handling (matches Rails parameterize)
|
||||||
|
this.slugTarget.value = slug(slugValue, { lower: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract city from address
|
||||||
|
extractCity(address) {
|
||||||
|
if (!address) return ""
|
||||||
|
|
||||||
|
// Look for French postal code pattern (5 digits) + city
|
||||||
|
const match = address.match(/(\d{5})\s+([^,]+)/)
|
||||||
|
if (match) {
|
||||||
|
return match[2].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract last part after comma (assume it's city)
|
||||||
|
const parts = address.split(',')
|
||||||
|
if (parts.length > 1) {
|
||||||
|
return parts[parts.length - 1].trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another fallback: look for common French city indicators
|
||||||
|
const cityIndicators = ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"]
|
||||||
|
for (const city of cityIndicators) {
|
||||||
|
if (address.toLowerCase().includes(city.toLowerCase())) {
|
||||||
|
return city
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle address changes with debounced geocoding
|
// Handle address changes with debounced geocoding
|
||||||
addressChanged() {
|
addressChanged() {
|
||||||
|
// Regenerate slug when address changes
|
||||||
|
this.generateSlug()
|
||||||
|
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (this.geocodeTimeout) {
|
if (this.geocodeTimeout) {
|
||||||
clearTimeout(this.geocodeTimeout)
|
clearTimeout(this.geocodeTimeout)
|
||||||
@@ -68,6 +121,11 @@ export default class extends Controller {
|
|||||||
}, this.geocodeDelayValue)
|
}, this.geocodeDelayValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle venue name changes to regenerate slug
|
||||||
|
venueNameChanged() {
|
||||||
|
this.generateSlug()
|
||||||
|
}
|
||||||
|
|
||||||
// Get user's current location and reverse geocode to address
|
// Get user's current location and reverse geocode to address
|
||||||
async getCurrentLocation() {
|
async getCurrentLocation() {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
@@ -516,14 +574,16 @@ export default class extends Controller {
|
|||||||
showLocationSuccess(message) {
|
showLocationSuccess(message) {
|
||||||
this.hideAllLocationMessages()
|
this.hideAllLocationMessages()
|
||||||
this.showMessage("location-success", message, "success")
|
this.showMessage("location-success", message, "success")
|
||||||
setTimeout(() => this.hideMessage("location-success"), 4000)
|
// Keep notification visible indefinitely
|
||||||
|
// setTimeout(() => this.hideMessage("location-success"), 4000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error message
|
// Show error message
|
||||||
showLocationError(message) {
|
showLocationError(message) {
|
||||||
this.hideAllLocationMessages()
|
this.hideAllLocationMessages()
|
||||||
this.showMessage("location-error", message, "error")
|
this.showMessage("location-error", message, "error")
|
||||||
setTimeout(() => this.hideMessage("location-error"), 6000)
|
// Keep notification visible indefinitely
|
||||||
|
// setTimeout(() => this.hideMessage("location-error"), 6000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show geocoding warning (less intrusive than error)
|
// Show geocoding warning (less intrusive than error)
|
||||||
@@ -531,7 +591,8 @@ export default class extends Controller {
|
|||||||
this.hideMessage("geocoding-warning")
|
this.hideMessage("geocoding-warning")
|
||||||
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
|
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
|
||||||
this.showMessage("geocoding-warning", message, "warning")
|
this.showMessage("geocoding-warning", message, "warning")
|
||||||
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
|
// Keep notification visible indefinitely
|
||||||
|
// setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show info about approximate location
|
// Show info about approximate location
|
||||||
@@ -539,7 +600,8 @@ export default class extends Controller {
|
|||||||
this.hideMessage("approximate-location-info")
|
this.hideMessage("approximate-location-info")
|
||||||
const message = `Localisation approximative trouvée: ${foundLocation}`
|
const message = `Localisation approximative trouvée: ${foundLocation}`
|
||||||
this.showMessage("approximate-location-info", message, "info")
|
this.showMessage("approximate-location-info", message, "info")
|
||||||
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
|
// Keep notification visible indefinitely
|
||||||
|
// setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show geocoding success with location details
|
// Show geocoding success with location details
|
||||||
@@ -547,7 +609,8 @@ export default class extends Controller {
|
|||||||
this.hideMessage("geocoding-success")
|
this.hideMessage("geocoding-success")
|
||||||
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
||||||
this.showMessage("geocoding-success", message, "success")
|
this.showMessage("geocoding-success", message, "success")
|
||||||
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
|
// Keep notification visible indefinitely
|
||||||
|
// setTimeout(() => this.hideMessage("geocoding-success"), 5000)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show geocoding progress with strategy info
|
// Show geocoding progress with strategy info
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class Event < ApplicationRecord
|
|||||||
|
|
||||||
|
|
||||||
# === Callbacks ===
|
# === Callbacks ===
|
||||||
|
before_validation :generate_slug, if: :should_generate_slug?
|
||||||
before_validation :geocode_address, if: :should_geocode_address?
|
before_validation :geocode_address, if: :should_geocode_address?
|
||||||
before_update :handle_image_replacement, if: :image_attached?
|
before_update :handle_image_replacement, if: :image_attached?
|
||||||
|
|
||||||
@@ -62,6 +63,71 @@ class Event < ApplicationRecord
|
|||||||
|
|
||||||
# === Instance Methods ===
|
# === Instance Methods ===
|
||||||
|
|
||||||
|
# Generate SEO-friendly slug from name, venue name, and city
|
||||||
|
def generate_slug
|
||||||
|
return if name.blank? && venue_name.blank? && venue_address.blank?
|
||||||
|
|
||||||
|
# Extract city from venue address
|
||||||
|
city = extract_city_from_address(venue_address)
|
||||||
|
|
||||||
|
# Build slug parts
|
||||||
|
slug_parts = []
|
||||||
|
slug_parts << name if name.present?
|
||||||
|
slug_parts << venue_name if venue_name.present?
|
||||||
|
slug_parts << city if city.present?
|
||||||
|
|
||||||
|
# Generate slug using Rails' parameterize
|
||||||
|
slug_value = slug_parts.join('-').parameterize
|
||||||
|
|
||||||
|
# Ensure minimum length
|
||||||
|
if slug_value.length < 3
|
||||||
|
slug_value = "event-#{Time.current.to_i}".parameterize
|
||||||
|
end
|
||||||
|
|
||||||
|
# Make sure slug is unique
|
||||||
|
base_slug = slug_value
|
||||||
|
counter = 1
|
||||||
|
while Event.where.not(id: id).where(slug: slug_value).exists?
|
||||||
|
slug_value = "#{base_slug}-#{counter}".parameterize
|
||||||
|
counter += 1
|
||||||
|
end
|
||||||
|
|
||||||
|
self.slug = slug_value
|
||||||
|
end
|
||||||
|
|
||||||
|
# Check if slug should be generated
|
||||||
|
def should_generate_slug?
|
||||||
|
# Generate slug if it's blank or if it's a new record
|
||||||
|
slug.blank? || new_record?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract city from address
|
||||||
|
def extract_city_from_address(address)
|
||||||
|
return "" if address.blank?
|
||||||
|
|
||||||
|
# Look for French postal code pattern (5 digits) + city
|
||||||
|
match = address.match(/(\d{5})\s+([^,]+)/)
|
||||||
|
if match
|
||||||
|
return match[2].strip
|
||||||
|
end
|
||||||
|
|
||||||
|
# Fallback: extract last part after comma (assume it's city)
|
||||||
|
parts = address.split(',')
|
||||||
|
if parts.length > 1
|
||||||
|
return parts[parts.length - 1].strip
|
||||||
|
end
|
||||||
|
|
||||||
|
# Another fallback: look for common French city indicators
|
||||||
|
city_indicators = ["Paris", "Lyon", "Marseille", "Toulouse", "Nice", "Nantes", "Strasbourg", "Montpellier", "Bordeaux", "Lille"]
|
||||||
|
for city in city_indicators
|
||||||
|
if address.downcase.include?(city.downcase)
|
||||||
|
return city
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
""
|
||||||
|
end
|
||||||
|
|
||||||
# Get image URL prioritizing old image field if it exists
|
# Get image URL prioritizing old image field if it exists
|
||||||
def display_image_url
|
def display_image_url
|
||||||
# First check if old image field exists and has a value
|
# First check if old image field exists and has a value
|
||||||
|
|||||||
@@ -41,24 +41,9 @@
|
|||||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div>
|
||||||
<div>
|
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||||
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture" %>
|
||||||
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
|
||||||
<%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">
|
|
||||||
<% if @event.published? %>
|
|
||||||
<i data-lucide="alert-triangle" class="w-4 h-4 inline text-yellow-500"></i>
|
|
||||||
Attention: Modifier le slug d'un événement publié peut casser les liens existants.
|
|
||||||
<% else %>
|
|
||||||
Utilisé dans l'URL de l'événement
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
|
|||||||
@@ -41,17 +41,14 @@
|
|||||||
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
<div class="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">Informations générales</h3>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.label :name, "Nom de l'événement", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||||
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
|
<%= form.text_field :name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Soirée d'ouverture", data: { "event-form-target": "name", action: "input->event-form#generateSlug" } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<!-- Hidden slug field (auto-generated) -->
|
||||||
<%= form.label :slug, "Slug (URL)", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.hidden_field :slug, data: { "event-form-target": "slug" } %>
|
||||||
<%= form.text_field :slug, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "soiree-ouverture", data: { "event-form-target": "slug" } %>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">Utilisé dans l'URL de l'événement</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6">
|
<div class="mt-6">
|
||||||
@@ -122,7 +119,7 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
<%= form.label :venue_name, "Nom du lieu", class: "block text-sm font-medium text-gray-700 mb-2" %>
|
||||||
<%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex" %>
|
<%= form.text_field :venue_name, class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", placeholder: "Ex: Le Grand Rex", data: { "event-form-target": "venueName", action: "input->event-form#venueNameChanged" } %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user