From 1a7fb818df5133ee8e1908956ede72e2ef1eab16 Mon Sep 17 00:00:00 2001 From: kbe Date: Wed, 10 Sep 2025 20:46:31 +0200 Subject: [PATCH] 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 --- BACKLOG.md | 15 + app/javascript/application.js | 2 +- .../controllers/event_form_controller.js | 63 +- app/models/order.rb | 2 +- app/views/components/_delete_account.html.erb | 17 + app/views/devise/registrations/edit.html.erb | 86 ++- app/views/layouts/application.html.erb | 32 +- .../20250816145933_devise_create_users.rb | 11 +- db/migrate/20250823145902_create_events.rb | 4 + db/schema.rb | 9 +- docs/invoice-system-implementation.md | 572 ------------------ docs/order-system-implementation.md | 414 ------------- docs/pricing-model-recommendations.md | 410 +++++++++++++ docs/test_fixes_summary.md | 71 --- docs/test_solutions.md | 200 ------ docs/theme-rules.md | 280 --------- package.json | 3 +- tailwind.config.js | 17 +- yarn.lock | 5 + 19 files changed, 552 insertions(+), 1661 deletions(-) create mode 100644 app/views/components/_delete_account.html.erb delete mode 100644 docs/invoice-system-implementation.md delete mode 100644 docs/order-system-implementation.md create mode 100644 docs/pricing-model-recommendations.md delete mode 100644 docs/test_fixes_summary.md delete mode 100644 docs/test_solutions.md delete mode 100755 docs/theme-rules.md diff --git a/BACKLOG.md b/BACKLOG.md index e0750ac..b18ad6d 100755 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -17,6 +17,21 @@ - [ ] 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: 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 diff --git a/app/javascript/application.js b/app/javascript/application.js index 88ba9c0..00d5eb4 100755 --- a/app/javascript/application.js +++ b/app/javascript/application.js @@ -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); diff --git a/app/javascript/controllers/event_form_controller.js b/app/javascript/controllers/event_form_controller.js index 24ae3e2..e61ff29 100644 --- a/app/javascript/controllers/event_form_controller.js +++ b/app/javascript/controllers/event_form_controller.js @@ -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 {
${Object.entries(providers).map(([key, provider]) => ` - ${provider.icon} ${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") } -} \ No newline at end of file +} diff --git a/app/models/order.rb b/app/models/order.rb index 41eccb0..e3e17a9 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -1,6 +1,6 @@ class Order < ApplicationRecord # === Constants === - DRAFT_EXPIRY_TIME = 30.minutes + DRAFT_EXPIRY_TIME = 15.minutes MAX_PAYMENT_ATTEMPTS = 3 # === Associations === diff --git a/app/views/components/_delete_account.html.erb b/app/views/components/_delete_account.html.erb new file mode 100644 index 0000000..d10c39f --- /dev/null +++ b/app/views/components/_delete_account.html.erb @@ -0,0 +1,17 @@ + +
+

Supprimer mon compte

+

+ Vous êtes certain de vouloir supprimer votre compte ? Cette action est irréversible. +

+ <%= 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 %> + + Supprimer mon compte + <% end %> +
diff --git a/app/views/devise/registrations/edit.html.erb b/app/views/devise/registrations/edit.html.erb index 014ece3..66f49b3 100755 --- a/app/views/devise/registrations/edit.html.erb +++ b/app/views/devise/registrations/edit.html.erb @@ -7,16 +7,16 @@
<% end %> -

Modifier votre compte

+

Modifier vos informations de sécurité

- Gérez vos informations et préférences + Gérez vos informations et préférences de sécurité

Informations du compte

- + <%= 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 @@
<% end %> -
-
- <%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %> -
-
- -
- <%= 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" %> -
- <% if @minimum_password_length %> -

<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>

- <% end %> -
- -
- <%= f.label :password_confirmation, "Confirmer le nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %> -
-
- -
- <%= 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" %> -
-
-
-
<%= f.label :current_password, "Mot de passe actuel", class: "block text-sm font-semibold text-gray-700 mb-2" %>
@@ -82,6 +53,35 @@
+
+
+ <%= f.label :password, "Nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %> +
+
+ +
+ <%= 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" %> +
+ <% if @minimum_password_length %> +

<%= t('devise.registrations.new.minimum_password_length', count: @minimum_password_length) %>

+ <% end %> +
+ +
+ <%= f.label :password_confirmation, "Confirmer le nouveau mot de passe", class: "block text-sm font-semibold text-gray-700 mb-2" %> +
+
+ +
+ <%= 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" %> +
+
+
+
<%= 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 %> @@ -91,30 +91,14 @@ <% end %>
- -
-

Supprimer mon compte

-

- <%= t('devise.registrations.edit.unhappy') %> Cette action est irréversible. -

- <%= 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 %> - - <%= t('devise.registrations.edit.delete_account') %> - <% end %> -
+ <%# render "components/delete_account" %>
<%= link_to :back, class: "inline-flex items-center text-purple-600 hover:text-purple-500 transition-colors" do %> - <%= t('devise.registrations.edit.back') %> + Retour <% end %>
- \ No newline at end of file + diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index 67199ee..cf76369 100755 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -1,5 +1,5 @@ - + <%= content_for(:title) || "Aperonight" %> @@ -10,35 +10,11 @@ <%= yield :head %> - - - - - - - + + + - <%# 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) %> diff --git a/db/migrate/20250816145933_devise_create_users.rb b/db/migrate/20250816145933_devise_create_users.rb index bba5b16..0ce256a 100755 --- a/db/migrate/20250816145933_devise_create_users.rb +++ b/db/migrate/20250816145933_devise_create_users.rb @@ -37,11 +37,12 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0] t.string :first_name, null: true # Prénom # Company informations - t.string :company_name, null: true # Nom de la société - # t.string :company_address, null: true # Adresse de la société - # t.string :company_phone, null: true # Téléphone 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é + t.boolean :is_professionnal, default: false, null: true + t.string :company_name, null: true # Nom de la société + t.string :company_address, null: true # Adresse de la société + t.string :company_phone, null: true # Téléphone 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 # We assume user does not have a stripe account yet diff --git a/db/migrate/20250823145902_create_events.rb b/db/migrate/20250823145902_create_events.rb index e0cca4f..d91c2bf 100755 --- a/db/migrate/20250823145902_create_events.rb +++ b/db/migrate/20250823145902_create_events.rb @@ -10,8 +10,12 @@ class CreateEvents < ActiveRecord::Migration[8.0] t.string :venue_address, null: false t.datetime :start_time t.datetime :end_time + + # Latitude and longitude of the place t.decimal :latitude, 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.references :user, null: false, foreign_key: false diff --git a/db/schema.rb b/db/schema.rb index 4b736c6..2d5757b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.string "name", 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.string "last_name" t.string "first_name" + t.boolean "is_professionnal", default: false 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.boolean "onboarding_completed", default: false, null: false t.datetime "created_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 ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true end diff --git a/docs/invoice-system-implementation.md b/docs/invoice-system-implementation.md deleted file mode 100644 index d399c88..0000000 --- a/docs/invoice-system-implementation.md +++ /dev/null @@ -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 - -
-
-
-
-

Invoice

-

Invoice #<%= @invoice.invoice_number %>

-
-
-

Date: <%= @invoice.issued_at.strftime('%B %d, %Y') %>

- - <%= @invoice.status.capitalize %> - -
-
- - -
-
-

Bill To:

-

<%= @invoice.user.email %>

-
-
-

Event:

-

<%= @invoice.event.name %>

-

<%= @invoice.event.start_time.strftime('%B %d, %Y at %I:%M %p') %>

-
-
- - -
- - - - - - - - - - - <% @invoice.order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets| %> - - - - - - - <% end %> - -
DescriptionQtyUnit PriceTotal
<%= ticket_type.name %><%= tickets.count %>€<%= ticket_type.price_cents / 100.0 %>€<%= (tickets.count * ticket_type.price_cents) / 100.0 %>
-
- - -
-

- Total: €<%= @invoice.total_amount_euros %> -

-
- - -
- <%= link_to "Download PDF", download_pdf_invoice_path(@invoice), - class: "bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700" %> -
-
-
-``` - -## 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. \ No newline at end of file diff --git a/docs/order-system-implementation.md b/docs/order-system-implementation.md deleted file mode 100644 index 51dfe15..0000000 --- a/docs/order-system-implementation.md +++ /dev/null @@ -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 - -

Commande pour <%= @order.event.name %>

- -
-

Récapitulatif de votre commande

- <% @order.tickets.each do |ticket| %> -
- <%= ticket.ticket_type.name %> - <%= ticket.first_name %> <%= ticket.last_name %> - <%= ticket.price_euros %>€ -
- <% end %> - -
- Total: <%= @order.total_amount_euros %>€ -
-
- - -<% if @checkout_session.present? %> - -<% 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. \ No newline at end of file diff --git a/docs/pricing-model-recommendations.md b/docs/pricing-model-recommendations.md new file mode 100644 index 0000000..d90bee3 --- /dev/null +++ b/docs/pricing-model-recommendations.md @@ -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 \ No newline at end of file diff --git a/docs/test_fixes_summary.md b/docs/test_fixes_summary.md deleted file mode 100644 index c7f18f2..0000000 --- a/docs/test_fixes_summary.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/docs/test_solutions.md b/docs/test_solutions.md deleted file mode 100644 index 751b56f..0000000 --- a/docs/test_solutions.md +++ /dev/null @@ -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 - - -<% if user.first_name %> - - -<% 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. \ No newline at end of file diff --git a/docs/theme-rules.md b/docs/theme-rules.md deleted file mode 100755 index 3177fa1..0000000 --- a/docs/theme-rules.md +++ /dev/null @@ -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 -``` \ No newline at end of file diff --git a/package.json b/package.json index 3a4e4c4..83b031a 100755 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "lucide": "^0.542.0", "qrcode": "^1.5.4", "react": "^18.3.1", - "react-dom": "^18.3.1" + "react-dom": "^18.3.1", + "slug": "^11.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4.1.4", diff --git a/tailwind.config.js b/tailwind.config.js index 72b3a52..d07238c 100755 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -9,6 +9,21 @@ module.exports = { "./app/views/**/*.{erb,haml,html,slim}", "./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")], }; diff --git a/yarn.lock b/yarn.lock index 39556a8..df6c938 100755 --- a/yarn.lock +++ b/yarn.lock @@ -2046,6 +2046,11 @@ slash@^5.0.0: resolved "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz" 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: version "4.2.0" resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz"