14 Commits

Author SHA1 Message Date
kbe
87ccebf229 feat(promotion code): Promotion code system done
I added the features for users to use promotion code
and for promoters to create on their events.
May be rewrite to discount code?
2025-09-29 15:25:52 +02:00
kbe
72d54e02ab feat: Update promotion code forms to display amounts in euros
- Changed discount amount input from cents to euros in new and edit forms
- Added decimal support with step="0.01" for precise euro amounts
- Updated form labels and help text to reflect euro display
- Added value conversion from stored cents to euros for editing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 14:46:39 +02:00
kbe
a4a427ad5c feat(migrations): Add promotion code migrations 2025-09-29 11:59:48 +02:00
kbe
1fc3311ca9 feat: Add Bizouk events to seeds for development
Add 3 events fetched from Bizouk:
- Konpa With Bev - Cours De Konpa Gouyad (Oct 3)
- La Plus Grosse Soirée Caribbean Groove (Oct 3-4)
- LA BELLE ÉPOQUE PAR SISLEY ÉVENTS - OCTOBRE (Oct 4-5)

Each event includes appropriate ticket types with pricing and age restrictions.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 11:59:01 +02:00
kbe
ae1ae89a97 Add a compose for production 2025-09-29 01:17:26 +02:00
kbe
0b7dd55530 Optimize Dockerfile.production for better performance and security
- Fixed libvips package name from libvips-16t64 to libvips42 for proper installation
- Moved gem install rails to build stage where build tools are available
- Changed to ruby slim base image for smaller final image size
- Improved layer caching by organizing dependency installation
- Enhanced security with non-root user
- Added jemalloc configuration for better memory management
- Cleaned up build artifacts to reduce image size

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2025-09-29 01:08:11 +02:00
kbe
a69ddb4012 feat: Add promotion code functionality to ticket orders 2025-09-28 20:20:22 +02:00
kbe
e5ed1a34dd refactor: migrate payment success views to orders system and add promoter seed data
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m48s
2025-09-16 21:32:42 +02:00
3e0a354a58 Merge pull request 'feat/free-ticket' (#4) from feat/free-ticket into develop
All checks were successful
Ruby on Rails Test / rails-test (push) Successful in 1m31s
Reviewed-on: #4
2025-09-16 15:27:51 +00:00
kbe
b5c1846f2c fix(mailers): Include TicketsHelper in TicketMailer to make format_ticket_price available
- Add helper :tickets to TicketMailer to make format_ticket_price method available in mailer templates
- Fixes undefined method 'format_ticket_price' error in purchase confirmation emails
- Required after recent changes to support free tickets
2025-09-16 17:25:14 +02:00
kbe
04393add14 fix(tests): Remove service fee expectation from Stripe invoice test and fix duplicated keys in event view
- Update StripeInvoiceServiceTest to match the implementation that no longer adds service fees to customer invoices
- Remove duplicated Stimulus data attributes in events/show.html.erb that were causing warnings
- Align tests with the hybrid fee model where fees are deducted from promoter payouts
2025-09-16 17:22:00 +02:00
kbe
5279ebe1a4 feat(event available/sold out): Promoter can mark event as sold out or available
On the event page, promoter can choose to mark the event as "sold out" using the status field or as "published". Only published event can be marked as sold out if promoter thinks he cannot handle all the people available.
2025-09-16 17:15:09 +02:00
kbe
329ba89eaa chore: Better description details for ticket types 2025-09-16 17:00:04 +02:00
kbe
9c56b2e1e5 refactor: prepare for free ticket 2025-09-16 16:36:39 +02:00
47 changed files with 1522 additions and 1227 deletions

View File

@@ -1,767 +0,0 @@
When asked to design UI & frontend interface
When asked to design UI & frontend interface
# Role
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
Your goal is to help user generate amazing design using code
# Instructions
- Use the available tools when needed to help with file operations and code analysis
- When creating design file:
- Build one single html page of just one screen to build a design based on users' feedback/task
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
## Styling
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
3. superdesign MUST generate responsive designs.
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
8. Example theme patterns:
Ney-brutalism style that feels like 90s web design
<neo-brutalism-style>
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</neo-brutalism-style>
Modern dark mode style like vercel, linear
<modern-dark-mode-style>
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.1450 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1450 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1450 0 0);
--primary: oklch(0.2050 0 0);
--primary-foreground: oklch(0.9850 0 0);
--secondary: oklch(0.9700 0 0);
--secondary-foreground: oklch(0.2050 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.5560 0 0);
--accent: oklch(0.9700 0 0);
--accent-foreground: oklch(0.2050 0 0);
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9220 0 0);
--input: oklch(0.9220 0 0);
--ring: oklch(0.7080 0 0);
--chart-1: oklch(0.8100 0.1000 252);
--chart-2: oklch(0.6200 0.1900 260);
--chart-3: oklch(0.5500 0.2200 263);
--chart-4: oklch(0.4900 0.2200 264);
--chart-5: oklch(0.4200 0.1800 266);
--sidebar: oklch(0.9850 0 0);
--sidebar-foreground: oklch(0.1450 0 0);
--sidebar-primary: oklch(0.2050 0 0);
--sidebar-primary-foreground: oklch(0.9850 0 0);
--sidebar-accent: oklch(0.9700 0 0);
--sidebar-accent-foreground: oklch(0.2050 0 0);
--sidebar-border: oklch(0.9220 0 0);
--sidebar-ring: oklch(0.7080 0 0);
--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-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</modern-dark-mode-style>
## Images & icons
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
## Script
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
## Workflow
You should always follow workflow below unless user explicitly ask you to do something else:
1. Layout design
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
3. Core Animation design
4. Generate a singlehtml file for the UI
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
### 1. Layout design
Output type: Just text
Think through how should the layout of interface look like, what are different UI components
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
### 2. Theme design
Output type: Tool call
Think through what are the colors, fonts, spacing, etc.
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
### 3. Animation design
Output type: Just text
Think through what are the animations, transitions, etc.
### 4. Generate html file for each UI component and then combine them together to form a single html file
Output type: Tool call
Generate html file for each UI component and then combine them together to form a single html file
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
<example>
<user>design an AI chat UI</user>
<assistant>
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
## Core UI Components
**Header Area**
- Settings/menu button (Top left)
- Chat title/AI name (Top left)
- Conversation controls (new chat, clear, etc.) (Top right)
**Main Chat Area**
- Message container (scrollable) (Full width & height)
- User messages (typically right-aligned)
- AI messages (typically left-aligned)
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
**Input Area**
- Text input field (Full width left)
- Send button (Embed within the input field,Bottom right side of the screen)
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
## Layout Structure Options
┌─────────────────────────────────────┐
│ ☰ HEADER BAR + │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ User Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ [CHAT AREA] │
│ │
├─────────────────────────────────────┤
│ [Text Input Field] [Send] │
└─────────────────────────────────────┘
When hamburger (☰) is clicked, sidebar slides out:
┌──────────────┬─────────────────────────────────────┐
│ SIDEBAR │ ☰ HEADER BAR + │
│ ┌──────────┐ ├─────────────────────────────────────┤
│ │ Chat 1 │ │ │
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
│ │ Chat 3 │ │ │ AI Message Bubble │ │
│ │ + New │ │ └─────────────────────────────┘ │
│ └──────────┘ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ User Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ AI Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ [CHAT AREA] │
│ │ │
│ ├─────────────────────────────────────┤
│ │ [Text Input Field] [Send] │
└──────────────┘─────────────────────────────────────┘
Would you like to go ahead with this layout & UI interaction or needs any modification?
</assistant>
<user>
They looks good
</user>
<assistant>
Great, next let's design the color & font theme,
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1448 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1448 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1448 0 0);
--primary: oklch(0.5555 0 0);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9702 0 0);
--accent-foreground: oklch(0.2046 0 0);
--destructive: oklch(0.5830 0.2387 28.4765);
--destructive-foreground: oklch(0.9702 0 0);
--border: oklch(0.9219 0 0);
--input: oklch(0.9219 0 0);
--ring: oklch(0.7090 0 0);
--chart-1: oklch(0.5555 0 0);
--chart-2: oklch(0.5555 0 0);
--chart-3: oklch(0.5555 0 0);
--chart-4: oklch(0.5555 0 0);
--chart-5: oklch(0.5555 0 0);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.1448 0 0);
--sidebar-primary: oklch(0.2046 0 0);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.9702 0 0);
--sidebar-accent-foreground: oklch(0.2046 0 0);
--sidebar-border: oklch(0.9219 0 0);
--sidebar-ring: oklch(0.7090 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--radius: 0rem;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}')
<user>
I like the vintage style
</user>
<assistant>
Great, next let's design the animation:
# CHAT UI ANIMATIONS - MICRO-SYNTAX
## Core Message Flow
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
status: 300ms ease-out [α0.6→1, S1→1.05→1]
## Interface Transitions
sidebar: 350ms ease-out [X-280→0, α0→1]
overlay: 300ms [α0→1, blur0→4px]
input: 200ms [S1→1.01, shadow+ring] focus
input: 150ms [S1.01→1, shadow-ring] blur
## Button Interactions
sendBtn: 150ms [S1→0.95→1, R±2°] press
sendBtn: 200ms [S1→1.05, shadow↗] hover
ripple: 400ms [S0→2, α1→0]
## Loading States
chatLoad: 500ms ease-out [Y+40→0, α0→1]
skeleton: 2000ms ∞ [bg: muted↔accent]
spinner: 1000ms ∞ linear [R360°]
## Micro Interactions
msgHover: 200ms [Y0→-2, shadow↗]
msgSelect: 200ms [bg→accent, S1→1.02]
error: 400ms [X±5] shake
success: 600ms bounce [S0→1.2→1, R360°]
## Scroll & Navigation
autoScroll: 400ms smooth
scrollHint: 800ms ∞×3 [Y±5]
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
</assistant>
<user>
This looks great, lets do it
</user>
<assistant>
Great, I will generate the html file for each UI component and then combine them together to form a single html file
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
I've created the html design, please reveiw and let me know if you need any changes
</example>
IMPORTANT RULES:
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
2. You MUST confirm the layout, and then theme style, and then animation
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
4. You MUST create follow the workflow above
# Available Tools
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
- **write**: Write content to files in the workspace (creates parent directories automatically)
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
- **generateTheme**: Generate a theme for the design
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)
When asked to design UI & frontend interface
When asked to design UI & frontend interface
# Role
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
Your goal is to help user generate amazing design using code
# Instructions
- Use the available tools when needed to help with file operations and code analysis
- When creating design file:
- Build one single html page of just one screen to build a design based on users' feedback/task
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
## Styling
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
3. superdesign MUST generate responsive designs.
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
8. Example theme patterns:
Ney-brutalism style that feels like 90s web design
<neo-brutalism-style>
:root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0 0 0);
--primary: oklch(0.6489 0.2370 26.9728);
--primary-foreground: oklch(1.0000 0 0);
--secondary: oklch(0.9680 0.2110 109.7692);
--secondary-foreground: oklch(0 0 0);
--muted: oklch(0.9551 0 0);
--muted-foreground: oklch(0.3211 0 0);
--accent: oklch(0.5635 0.2408 260.8178);
--accent-foreground: oklch(1.0000 0 0);
--destructive: oklch(0 0 0);
--destructive-foreground: oklch(1.0000 0 0);
--border: oklch(0 0 0);
--input: oklch(0 0 0);
--ring: oklch(0.6489 0.2370 26.9728);
--chart-1: oklch(0.6489 0.2370 26.9728);
--chart-2: oklch(0.9680 0.2110 109.7692);
--chart-3: oklch(0.5635 0.2408 260.8178);
--chart-4: oklch(0.7323 0.2492 142.4953);
--chart-5: oklch(0.5931 0.2726 328.3634);
--sidebar: oklch(0.9551 0 0);
--sidebar-foreground: oklch(0 0 0);
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
--sidebar-primary-foreground: oklch(1.0000 0 0);
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
--sidebar-accent-foreground: oklch(1.0000 0 0);
--sidebar-border: oklch(0 0 0);
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
--font-sans: DM Sans, sans-serif;
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: Space Mono, monospace;
--radius: 0px;
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</neo-brutalism-style>
Modern dark mode style like vercel, linear
<modern-dark-mode-style>
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.1450 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.1450 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.1450 0 0);
--primary: oklch(0.2050 0 0);
--primary-foreground: oklch(0.9850 0 0);
--secondary: oklch(0.9700 0 0);
--secondary-foreground: oklch(0.2050 0 0);
--muted: oklch(0.9700 0 0);
--muted-foreground: oklch(0.5560 0 0);
--accent: oklch(0.9700 0 0);
--accent-foreground: oklch(0.2050 0 0);
--destructive: oklch(0.5770 0.2450 27.3250);
--destructive-foreground: oklch(1 0 0);
--border: oklch(0.9220 0 0);
--input: oklch(0.9220 0 0);
--ring: oklch(0.7080 0 0);
--chart-1: oklch(0.8100 0.1000 252);
--chart-2: oklch(0.6200 0.1900 260);
--chart-3: oklch(0.5500 0.2200 263);
--chart-4: oklch(0.4900 0.2200 264);
--chart-5: oklch(0.4200 0.1800 266);
--sidebar: oklch(0.9850 0 0);
--sidebar-foreground: oklch(0.1450 0 0);
--sidebar-primary: oklch(0.2050 0 0);
--sidebar-primary-foreground: oklch(0.9850 0 0);
--sidebar-accent: oklch(0.9700 0 0);
--sidebar-accent-foreground: oklch(0.2050 0 0);
--sidebar-border: oklch(0.9220 0 0);
--sidebar-ring: oklch(0.7080 0 0);
--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-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
--radius: 0.625rem;
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
--tracking-normal: 0em;
--spacing: 0.25rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
</modern-dark-mode-style>
## Images & icons
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
## Script
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
## Workflow
You should always follow workflow below unless user explicitly ask you to do something else:
1. Layout design
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
3. Core Animation design
4. Generate a singlehtml file for the UI
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
### 1. Layout design
Output type: Just text
Think through how should the layout of interface look like, what are different UI components
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
### 2. Theme design
Output type: Tool call
Think through what are the colors, fonts, spacing, etc.
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
### 3. Animation design
Output type: Just text
Think through what are the animations, transitions, etc.
### 4. Generate html file for each UI component and then combine them together to form a single html file
Output type: Tool call
Generate html file for each UI component and then combine them together to form a single html file
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
<example>
<user>design an AI chat UI</user>
<assistant>
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
## Core UI Components
**Header Area**
- Settings/menu button (Top left)
- Chat title/AI name (Top left)
- Conversation controls (new chat, clear, etc.) (Top right)
**Main Chat Area**
- Message container (scrollable) (Full width & height)
- User messages (typically right-aligned)
- AI messages (typically left-aligned)
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
**Input Area**
- Text input field (Full width left)
- Send button (Embed within the input field,Bottom right side of the screen)
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
## Layout Structure Options
┌─────────────────────────────────────┐
│ ☰ HEADER BAR + │
├─────────────────────────────────────┤
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ User Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ ┌─────────────────────────────┐ │
│ │ AI Message Bubble │ │
│ └─────────────────────────────┘ │
│ │
│ [CHAT AREA] │
│ │
├─────────────────────────────────────┤
│ [Text Input Field] [Send] │
└─────────────────────────────────────┘
When hamburger (☰) is clicked, sidebar slides out:
┌──────────────┬─────────────────────────────────────┐
│ SIDEBAR │ ☰ HEADER BAR + │
│ ┌──────────┐ ├─────────────────────────────────────┤
│ │ Chat 1 │ │ │
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
│ │ Chat 3 │ │ │ AI Message Bubble │ │
│ │ + New │ │ └─────────────────────────────┘ │
│ └──────────┘ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ User Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ ┌─────────────────────────────┐ │
│ │ │ AI Message Bubble │ │
│ │ └─────────────────────────────┘ │
│ │ │
│ │ [CHAT AREA] │
│ │ │
│ ├─────────────────────────────────────┤
│ │ [Text Input Field] [Send] │
└──────────────┘─────────────────────────────────────┘
Would you like to go ahead with this layout & UI interaction or needs any modification?
</assistant>
<user>
They looks good
</user>
<assistant>
Great, next let's design the color & font theme,
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
--background: oklch(1.0000 0 0);
--foreground: oklch(0.1448 0 0);
--card: oklch(1.0000 0 0);
--card-foreground: oklch(0.1448 0 0);
--popover: oklch(1.0000 0 0);
--popover-foreground: oklch(0.1448 0 0);
--primary: oklch(0.5555 0 0);
--primary-foreground: oklch(0.9851 0 0);
--secondary: oklch(0.9702 0 0);
--secondary-foreground: oklch(0.2046 0 0);
--muted: oklch(0.9702 0 0);
--muted-foreground: oklch(0.5486 0 0);
--accent: oklch(0.9702 0 0);
--accent-foreground: oklch(0.2046 0 0);
--destructive: oklch(0.5830 0.2387 28.4765);
--destructive-foreground: oklch(0.9702 0 0);
--border: oklch(0.9219 0 0);
--input: oklch(0.9219 0 0);
--ring: oklch(0.7090 0 0);
--chart-1: oklch(0.5555 0 0);
--chart-2: oklch(0.5555 0 0);
--chart-3: oklch(0.5555 0 0);
--chart-4: oklch(0.5555 0 0);
--chart-5: oklch(0.5555 0 0);
--sidebar: oklch(0.9851 0 0);
--sidebar-foreground: oklch(0.1448 0 0);
--sidebar-primary: oklch(0.2046 0 0);
--sidebar-primary-foreground: oklch(0.9851 0 0);
--sidebar-accent: oklch(0.9702 0 0);
--sidebar-accent-foreground: oklch(0.2046 0 0);
--sidebar-border: oklch(0.9219 0 0);
--sidebar-ring: oklch(0.7090 0 0);
--font-sans: Geist Mono, monospace;
--font-serif: Geist Mono, monospace;
--font-mono: Geist Mono, monospace;
--radius: 0rem;
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
--tracking-normal: 0em;
--spacing: 0.25rem;
}')
<user>
I like the vintage style
</user>
<assistant>
Great, next let's design the animation:
# CHAT UI ANIMATIONS - MICRO-SYNTAX
## Core Message Flow
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
status: 300ms ease-out [α0.6→1, S1→1.05→1]
## Interface Transitions
sidebar: 350ms ease-out [X-280→0, α0→1]
overlay: 300ms [α0→1, blur0→4px]
input: 200ms [S1→1.01, shadow+ring] focus
input: 150ms [S1.01→1, shadow-ring] blur
## Button Interactions
sendBtn: 150ms [S1→0.95→1, R±2°] press
sendBtn: 200ms [S1→1.05, shadow↗] hover
ripple: 400ms [S0→2, α1→0]
## Loading States
chatLoad: 500ms ease-out [Y+40→0, α0→1]
skeleton: 2000ms ∞ [bg: muted↔accent]
spinner: 1000ms ∞ linear [R360°]
## Micro Interactions
msgHover: 200ms [Y0→-2, shadow↗]
msgSelect: 200ms [bg→accent, S1→1.02]
error: 400ms [X±5] shake
success: 600ms bounce [S0→1.2→1, R360°]
## Scroll & Navigation
autoScroll: 400ms smooth
scrollHint: 800ms ∞×3 [Y±5]
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
</assistant>
<user>
This looks great, lets do it
</user>
<assistant>
Great, I will generate the html file for each UI component and then combine them together to form a single html file
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
I've created the html design, please reveiw and let me know if you need any changes
</example>
IMPORTANT RULES:
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
2. You MUST confirm the layout, and then theme style, and then animation
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
4. You MUST create follow the workflow above
# Available Tools
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
- **write**: Write content to files in the workspace (creates parent directories automatically)
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
- **generateTheme**: Generate a theme for the design
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)

View File

View File

@@ -53,6 +53,7 @@
## 🚧 Doing ## 🚧 Doing
- [ ] feat: Promotion code on ticket
- [ ] feat: Page to display all tickets for an event - [ ] feat: Page to display all tickets for an event
- [ ] feat: Add a link into notification email to order page that display all tickets - [ ] feat: Add a link into notification email to order page that display all tickets

View File

@@ -8,7 +8,7 @@
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html # For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version # Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.1 ARG RUBY_VERSION=3.4.4
FROM docker.io/library/ruby:$RUBY_VERSION AS base FROM docker.io/library/ruby:$RUBY_VERSION AS base
# Rails app lives here # Rails app lives here
@@ -20,10 +20,10 @@ RUN apt-get update -qq && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Set production environment # Set production environment
ENV RAILS_ENV="production" \ ENV RAILS_ENV="development" \
BUNDLE_DEPLOYMENT="1" \ BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \ BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development" BUNDLE_WITHOUT=""
# Throw-away build stage to reduce size of final image # Throw-away build stage to reduce size of final image
FROM base AS build FROM base AS build

99
Dockerfile.production Normal file
View File

@@ -0,0 +1,99 @@
# syntax=docker/dockerfile:1
# check=error=true
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
# docker build -t myapp .
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name myapp myapp
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.4
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
# Rails app lives here
WORKDIR /rails
# Set production environment
ENV RAILS_ENV="production" \
BUNDLE_DEPLOYMENT="1" \
BUNDLE_PATH="/usr/local/bundle" \
BUNDLE_WITHOUT="development"
# Create non-root user early for security and to allow correct permissions in build stage
RUN groupadd --system --gid 1000 rails && \
useradd --system --uid 1000 --gid 1000 --create-home --shell /bin/bash rails
# Install base packages (runtime only in base image)
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libjemalloc2 libvips42 mariadb-client && \
rm -rf /var/lib/apt/lists/*
# Throw-away build stage to reduce size of final image
FROM base AS build
# Install packages needed to build gems and node modules
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 libmariadb-dev && \
rm -rf /var/lib/apt/lists/*
# Install rails gem in the build stage where build tools are available
RUN gem install rails
# Install JavaScript dependencies
ARG NODE_VERSION=18.19.0
ARG YARN_VERSION=latest
ENV NODE_PATH=/usr/local/node
ENV PATH=/usr/local/node/bin:$PATH
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
rm -rf /tmp/node-build-master
RUN corepack enable && yarn set version $YARN_VERSION
# Copy dependency files first (better caching)
COPY Gemfile Gemfile.lock ./
RUN bundle config set --local frozen 'true' && \
bundle install && \
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
bundle exec bootsnap precompile --gemfile
# Install node modules
COPY package.json yarn.lock ./
RUN yarn install --immutable && \
yarn cache clean --all
# Copy application code
COPY . .
# Precompile bootsnap code for faster boot times
RUN bundle exec bootsnap precompile app/ lib/
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
# Clean up build-time dependencies and files
RUN rm -rf node_modules tmp/cache
# Final stage for app image
FROM base
# Copy built artifacts: gems and application
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
COPY --from=build /rails /rails
COPY --from=build /usr/local/node /usr/local/node
# Ensure proper permissions for runtime directories
RUN mkdir -p /rails/db /rails/log /rails/storage /rails/tmp && \
chown -R rails:rails /rails/db /rails/log /rails/storage /rails/tmp
USER rails
# Configure jemalloc for better memory management
ENV LD_PRELOAD=libjemalloc.so.2
# Entrypoint prepares the database.
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
# Start server via Thruster by default, this can be overwritten at runtime
EXPOSE 80
CMD ["./bin/thrust", "./bin/rails", "server", "-b", "0.0.0.0"]

View File

@@ -126,6 +126,29 @@ class OrdersController < ApplicationController
@total_amount = @order.total_amount_cents @total_amount = @order.total_amount_cents
@expiring_soon = @order.expiring_soon? @expiring_soon = @order.expiring_soon?
# Handle promotion code application
if params[:promotion_code].present?
promotion_code = PromotionCode.valid.find_by(code: params[:promotion_code].upcase)
if promotion_code
# Apply the promotion code to the order
@order.promotion_codes << promotion_code
@order.calculate_total!
@total_amount = @order.total_amount_cents
flash.now[:notice] = "Code promotionnel appliqué: #{promotion_code.code}"
else
flash.now[:alert] = "Code promotionnel invalide"
end
end
# For free orders, automatically mark as paid and redirect to success
if @order.free?
@order.mark_as_paid!
session.delete(:pending_cart)
session.delete(:ticket_names)
session.delete(:draft_order_id)
return redirect_to order_path(@order), notice: "Vos billets gratuits ont été confirmés !"
end
# Create Stripe checkout session if Stripe is configured # Create Stripe checkout session if Stripe is configured
if Rails.application.config.stripe[:secret_key].present? if Rails.application.config.stripe[:secret_key].present?
begin begin

View File

@@ -5,7 +5,7 @@
class Promoter::EventsController < ApplicationController class Promoter::EventsController < ApplicationController
before_action :authenticate_user! before_action :authenticate_user!
before_action :ensure_can_manage_events! before_action :ensure_can_manage_events!
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ] before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :mark_available, :duplicate ]
# Display all events for the current promoter # Display all events for the current promoter
def index def index
@@ -93,6 +93,16 @@ class Promoter::EventsController < ApplicationController
end end
end end
# Mark event as available again
def mark_available
if @event.sold_out?
@event.update(state: :published)
redirect_to promoter_event_path(@event), notice: "Event marqué comme disponible!"
else
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme disponible."
end
end
# Duplicate an event and all its ticket types # Duplicate an event and all its ticket types
def duplicate def duplicate
clone_ticket_types = params[:clone_ticket_types] == "true" clone_ticket_types = params[:clone_ticket_types] == "true"

View File

@@ -0,0 +1,82 @@
class Promoter::PromotionCodesController < ApplicationController
before_action :authenticate_user!
before_action :set_event
before_action :set_promotion_code, only: [ :edit, :update, :destroy ]
# GET /promoter/events/:event_id/promotion_codes
# Display all promotion codes for a specific event
def index
@promotion_codes = @event.promotion_codes.includes(:user)
end
# GET /promoter/events/:event_id/promotion_codes/new
# Show form to create a new promotion code
def new
@promotion_code = @event.promotion_codes.new
end
# GET /promoter/events/:event_id/promotion_codes/:id/edit
# Show form to edit an existing promotion code
def edit
end
# POST /promoter/events/:event_id/promotion_codes
# Create a new promotion code for the event
def create
@promotion_code = @event.promotion_codes.new(promotion_code_params_with_conversion)
@promotion_code.user = current_user
if @promotion_code.save
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully created."
else
render :new, status: :unprocessable_entity
end
end
# PATCH/PUT /promoter/events/:event_id/promotion_codes/:id
# Update an existing promotion code
def update
if @promotion_code.update(promotion_code_params_with_conversion)
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully updated."
else
render :edit, status: :unprocessable_entity
end
end
# DELETE /promoter/events/:event_id/promotion_codes/:id
# Delete a promotion code
def destroy
@promotion_code.destroy
redirect_to promoter_event_promotion_codes_path(@event), notice: "Promotion code was successfully destroyed."
end
private
# Find the event based on the URL parameter
def set_event
@event = Event.find(params[:event_id])
end
# Find the promotion code based on the URL parameter
def set_promotion_code
@promotion_code = @event.promotion_codes.find(params[:id])
end
# Strong parameters for promotion code form (accepts euros for display)
def promotion_code_params
params.require(:promotion_code).permit(:code, :discount_amount_euros, :expires_at, :active, :usage_limit)
end
# Convert euros to cents for database storage
# The form displays euros for user convenience, but the database stores cents
def promotion_code_params_with_conversion
params = promotion_code_params
if params[:discount_amount_euros].present?
# Convert euros to cents (e.g., 20.50 -> 2050)
params[:discount_amount_cents] = (params[:discount_amount_euros].to_f * 100).to_i
params.delete(:discount_amount_euros) # Remove the temporary euro parameter
end
params
end
end

View File

@@ -1,2 +1,9 @@
module TicketsHelper module TicketsHelper
def format_ticket_price(price_cents)
if price_cents == 0
"Gratuit"
else
number_to_currency(price_cents / 100.0, unit: "")
end
end
end end

View File

@@ -1,4 +1,6 @@
class TicketMailer < ApplicationMailer class TicketMailer < ApplicationMailer
helper :tickets
def purchase_confirmation_order(order) def purchase_confirmation_order(order)
@order = order @order = order
@user = order.user @user = order.user

View File

@@ -21,6 +21,7 @@ class Event < ApplicationRecord
has_many :ticket_types has_many :ticket_types
has_many :tickets, through: :ticket_types has_many :tickets, through: :ticket_types
has_many :orders has_many :orders
has_many :promotion_codes
# === Callbacks === # === Callbacks ===
before_validation :geocode_address, if: :should_geocode_address? before_validation :geocode_address, if: :should_geocode_address?
@@ -55,7 +56,6 @@ class Event < ApplicationRecord
# Scope for published events ordered by start time # Scope for published events ordered by start time
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) } scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
# === Instance Methods === # === Instance Methods ===
# Check if coordinates were successfully geocoded or are fallback coordinates # Check if coordinates were successfully geocoded or are fallback coordinates

View File

@@ -7,6 +7,8 @@ class Order < ApplicationRecord
belongs_to :user belongs_to :user
belongs_to :event belongs_to :event
has_many :tickets, dependent: :destroy has_many :tickets, dependent: :destroy
has_many :order_promotion_codes, dependent: :destroy
has_many :promotion_codes, through: :order_promotion_codes
# === Validations === # === Validations ===
validates :user_id, presence: true validates :user_id, presence: true
@@ -88,10 +90,34 @@ class Order < ApplicationRecord
end end
end end
# Calculate total from ticket prices only (platform fee deducted from promoter payout) # Calculate total from ticket prices minus promotion code discounts
def calculate_total! def calculate_total!
ticket_total = tickets.sum(:price_cents) ticket_total = tickets.sum(:price_cents)
update!(total_amount_cents: ticket_total) discount_total = promotion_codes.sum(:discount_amount_cents)
# Ensure total doesn't go below zero
final_total = [ticket_total - discount_total, 0].max
update!(total_amount_cents: final_total)
end
# Subtotal amount before discounts
def subtotal_amount_cents
tickets.sum(:price_cents)
end
# Subtotal amount in euros
def subtotal_amount_euros
subtotal_amount_cents / 100.0
end
# Total discount amount from all promotion codes
def discount_amount_cents
promotion_codes.sum(:discount_amount_cents)
end
# Discount amount in euros
def discount_amount_euros
discount_amount_cents / 100.0
end end
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket # Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
@@ -116,6 +142,11 @@ class Order < ApplicationRecord
promoter_payout_cents / 100.0 promoter_payout_cents / 100.0
end end
# Check if order contains only free tickets
def free?
total_amount_cents == 0
end
# Create Stripe invoice for accounting records # Create Stripe invoice for accounting records
# #
# This method creates a post-payment invoice in Stripe for accounting purposes # This method creates a post-payment invoice in Stripe for accounting purposes

View File

@@ -0,0 +1,26 @@
class OrderPromotionCode < ApplicationRecord
# Associations
belongs_to :order
belongs_to :promotion_code
# Validations
validates :order, presence: true
validates :promotion_code, presence: true
# Callbacks
after_create :apply_discount
after_create :increment_promotion_code_uses
private
def apply_discount
# Apply the discount to the order
discount_amount = promotion_code.discount_amount_cents
order.update!(total_amount_cents: [ order.total_amount_cents - discount_amount, 0 ].max)
end
def increment_promotion_code_uses
# Increment the uses count on the promotion code
promotion_code.increment!(:uses_count)
end
end

View File

@@ -0,0 +1,42 @@
class PromotionCode < ApplicationRecord
# Validations
validates :code, presence: true, uniqueness: true
validates :discount_amount_cents, numericality: { greater_than_or_equal_to: 0 }
# Scopes
scope :active, -> { where(active: true) }
scope :expired, -> { where("expires_at < ? OR active = ?", Time.current, false) }
scope :valid, -> { active.where("expires_at > ? OR expires_at IS NULL", Time.current) }
# Callbacks
before_create :increment_uses_count
# Associations
belongs_to :user
belongs_to :event
has_many :order_promotion_codes
has_many :orders, through: :order_promotion_codes
# Instance methods
def discount_amount_euros
discount_amount_cents / 100.0
end
def active?
active && (expires_at.nil? || expires_at > Time.current)
end
def expired?
expires_at.present? && expires_at < Time.current
end
def can_be_used?
active? && (usage_limit.nil? || uses_count < usage_limit)
end
private
def increment_uses_count
self.uses_count ||= 0
end
end

View File

@@ -9,7 +9,7 @@ class Ticket < ApplicationRecord
validates :qr_code, presence: true, uniqueness: true validates :qr_code, presence: true, uniqueness: true
validates :order_id, presence: true validates :order_id, presence: true
validates :ticket_type_id, presence: true validates :ticket_type_id, presence: true
validates :price_cents, presence: true, numericality: { greater_than: 0 } validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] } validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
validates :first_name, presence: true validates :first_name, presence: true
validates :last_name, presence: true validates :last_name, presence: true

View File

@@ -6,7 +6,7 @@ class TicketType < ApplicationRecord
# Validations # Validations
validates :name, presence: true, length: { minimum: 3, maximum: 50 } validates :name, presence: true, length: { minimum: 3, maximum: 50 }
validates :description, presence: true, length: { minimum: 10, maximum: 500 } validates :description, presence: true, length: { minimum: 10, maximum: 500 }
validates :price_cents, presence: true, numericality: { greater_than: 0 } validates :price_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 } validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :sale_start_at, presence: true validates :sale_start_at, presence: true
validates :sale_end_at, presence: true validates :sale_end_at, presence: true
@@ -48,6 +48,10 @@ class TicketType < ApplicationRecord
[ quantity - tickets.count, 0 ].max [ quantity - tickets.count, 0 ].max
end end
def free?
price_cents == 0
end
def sales_status def sales_status
return :draft if sale_start_at.nil? || sale_end_at.nil? return :draft if sale_start_at.nil? || sale_end_at.nil?
return :expired if sale_end_at < Time.current return :expired if sale_end_at < Time.current

View File

@@ -7,7 +7,7 @@
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>"> <p class="text-xl font-bold text-purple-700 <%= "text-gray-400" if sold_out %>">
<%= number_to_currency(price_cents / 100.0, unit: "€") %> <%= format_ticket_price(price_cents) %>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -1,150 +0,0 @@
<div class="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-12 px-4 sm:px-6">
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-purple-600 to-indigo-700 px-6 py-8 text-center">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<h1 class="text-3xl font-bold text-white mb-2">Paiement réussi !</h1>
<p class="text-purple-100">Félicitations pour votre achat</p>
</div>
<!-- Content -->
<div class="p-6 sm:p-8">
<div class="text-center mb-8">
<p class="text-xl text-gray-700">
Vos billets pour <span class="font-bold text-purple-700"><%= @event.name %></span> ont été achetés avec succès.
</p>
<p class="text-gray-500 mt-2">
Un email de confirmation avec vos billets a été envoyé à <span class="font-medium"><%= current_user.email %></span>
</p>
</div>
<!-- Event Details -->
<div class="bg-gray-50 rounded-xl p-6 mb-8">
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
Détails de l'événement
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center p-3 bg-white rounded-lg">
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<div>
<p class="text-xs text-gray-500">Lieu</p>
<p class="font-medium"><%= @event.venue_name %></p>
</div>
</div>
<div class="flex items-center p-3 bg-white rounded-lg">
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<p class="text-xs text-gray-500">Date & Heure</p>
<p class="font-medium"><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></p>
</div>
</div>
</div>
</div>
<!-- Tickets -->
<div class="mb-8">
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
</svg>
Vos billets
</h2>
<div class="space-y-4">
<% @tickets.each do |ticket| %>
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-100 p-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex-1">
<div class="flex items-center">
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center mr-4">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
</svg>
</div>
<div>
<h3 class="font-bold text-gray-900"><%= ticket.ticket_type.name %></h3>
<p class="text-sm text-gray-600">Prix: <span class="font-medium"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></span></p>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<%= link_to ticket_download_path(ticket.qr_code, format: :pdf),
class: "inline-flex items-center px-4 py-2 btn btn-primary rounded-lg transition-all duration-200 text-sm font-medium shadow-sm" do %>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Télécharger PDF
<% end %>
</div>
</div>
<div class="mt-4 pt-4 border-t border-purple-100 flex items-center justify-between">
<div class="flex items-center">
<svg class="w-4 h-4 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<span class="text-xs text-gray-500">Code QR: <%= ticket.qr_code[0..7] %></span>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Actif
</span>
</div>
</div>
<% end %>
</div>
</div>
<!-- Important Notice -->
<div class="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-8">
<div class="flex">
<svg class="w-5 h-5 text-blue-500 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold text-blue-800 mb-1">Important</h3>
<p class="text-sm text-blue-700">
Veuillez télécharger et sauvegarder vos billets. Présentez-les à l'entrée du lieu pour accéder à l'événement.
Un email de confirmation avec vos billets a été envoyé à votre adresse email.
</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<%= link_to dashboard_path,
class: "inline-flex items-center justify-center px-6 py-3 btn btn-primary rounded-xl transition-all duration-200 font-medium shadow-sm" do %>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Tableau de bord
<% end %>
<%= link_to events_path,
class: "inline-flex items-center justify-center px-6 py-3 bg-white text-gray-700 rounded-xl border border-gray-300 hover:bg-gray-50 transition-all duration-200 font-medium shadow-sm" do %>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
Voir plus d'événements
<% end %>
</div>
</div>
</div>
</div>
</div>

View File

@@ -135,11 +135,9 @@
controller: "ticket-selection", controller: "ticket-selection",
ticket_selection_target: "form", ticket_selection_target: "form",
ticket_selection_event_slug_value: @event.slug, ticket_selection_event_slug_value: @event.slug,
ticket_selection_event_id_value: @event.id, ticket_selection_event_id_value: @event.id,
ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id), ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
ticket_selection_store_cart_url_value: api_v1_store_cart_path, ticket_selection_store_cart_url_value: api_v1_store_cart_path
ticket_selection_order_new_url_value: event_order_new_path(@event.slug, @event.id),
ticket_selection_store_cart_url_value: api_v1_store_cart_path
} do |form| %> } do |form| %>
<div class="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border border-purple-100 p-6 shadow-sm"> <div class="bg-gradient-to-br from-purple-50 to-indigo-50 rounded-2xl border border-purple-100 p-6 shadow-sm">

View File

@@ -1,30 +1,12 @@
<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8"> <div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 py-8">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8"> <div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb --> <!-- Breadcrumb -->
<nav class="inline-flex items-center gap-2 bg-white px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-8" aria-label="Breadcrumb"> <%= render 'components/breadcrumb', crumbs: [
<div class="inline-flex items-center text-sm font-medium"> { name: 'Accueil', path: root_path },
<%= link_to root_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %> { name: 'Événements', path: events_path },
<i data-lucide="home" class="w-4 h-4 mr-2"></i> { name: @order.event.name, path: event_path(@order.event.slug, @order.event) },
Accueil { name: "Commande ##{@order.id}", path: nil }
<% end %> ] %>
</div>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<div class="inline-flex items-center text-sm font-medium">
<%= link_to events_path, class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
Événements
<% end %>
</div>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<div class="inline-flex items-center text-sm font-medium">
<%= link_to event_path(@order.event.slug, @order.event), class: "text-gray-700 hover:text-purple-600 transition-colors" do %>
<%= @order.event.name %>
<% end %>
</div>
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400"></i>
<div class="text-sm font-medium text-purple-600">
Commande #<%= @order.id %>
</div>
</nav>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Order Summary --> <!-- Order Summary -->
@@ -77,8 +59,8 @@
</div> </div>
<!-- Order Items --> <!-- Order Items -->
<div class="space-y-4 mb-6"> <div class="space-y-4 mb-6 border-b border-gray-200 pb-6 mb-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Récapitulatif de votre commande</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4 ">Récapitulatif de votre commande</h3>
<% @tickets.each do |ticket| %> <% @tickets.each do |ticket| %>
<div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0"> <div class="flex items-center justify-between py-3 border-b border-gray-100 last:border-b-0">
@@ -99,12 +81,46 @@
<% end %> <% end %>
</div> </div>
<!-- Promotion Code Discount -->
<% if @order.promotion_codes.any? %>
<div class="space-y-2 mb-6 pb-6 border-b border-gray-200">
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-green-600">
<i data-lucide="tag" class="w-4 h-4 mr-1"></i>
Code: <%= promo_code.code %>
</span>
<span class="text-sm font-semibold text-green-600">-<%= promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
<% end %>
<!-- Order Total --> <!-- Order Total -->
<div class=" pt-12"> <div class=" pt-12">
<div class="space-y-2"> <div class="space-y-2">
<!-- Subtotal -->
<div class="flex items-center justify-between">
<span class="text-sm text-gray-600">Sous-total</span>
<span class="text-sm font-medium text-gray-600"><%= @order.subtotal_amount_euros %>€</span>
</div>
<!-- Discount -->
<% if @order.discount_amount_cents > 0 %>
<div class="flex items-center justify-between">
<span class="text-sm text-green-600">Réduction</span>
<span class="text-sm font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
</div>
<% end %>
<!-- Total -->
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200"> <div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200">
<span class="font-medium text-gray-900">Total</span> <span class="font-medium text-gray-900">Total</span>
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span> <% if @order.total_amount_cents == 0 %>
<span class="font-bold text-2xl text-green-600">GRATUIT</span>
<% else %>
<span class="font-bold text-2xl text-purple-600"><%= @order.total_amount_euros %>€</span>
<% end %>
</div> </div>
</div> </div>
<p class="text-xs text-gray-500 mt-2">TVA incluse</p> <p class="text-xs text-gray-500 mt-2">TVA incluse</p>
@@ -118,6 +134,16 @@
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p> <p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
</div> </div>
<!-- Promotion Code Section -->
<%= form_tag checkout_order_path(@order), method: :get, class: "mb-6" do %>
<div class="flex items-center bg-gray-50 border border-gray-200 rounded-lg p-3">
<%= text_field_tag :promotion_code, params[:promotion_code], class: "flex-1 border-none bg-transparent focus:ring-0 text-sm", placeholder: "Code promotionnel (optionnel)" %>
<%= button_tag type: "submit", class: "ml-2 btn btn-secondary py-2 px-4 text-sm" do %>
Appliquer
<% end %>
</div>
<% end %>
<% if @checkout_session.present? %> <% if @checkout_session.present? %>
<!-- Stripe Checkout --> <!-- Stripe Checkout -->
<div class="space-y-6"> <div class="space-y-6">
@@ -131,16 +157,20 @@
</div> </div>
</div> </div>
<button <button
id="checkout-button" id="checkout-button"
data-order-id="<%= @order.id %>" data-order-id="<%= @order.id %>"
data-increment-url="/api/v1/orders/<%= @order.id %>/increment_payment_attempt" data-increment-url="/api/v1/orders/<%= @order.id %>/increment_payment_attempt"
data-session-id="<%= @checkout_session.id if @checkout_session.present? %>" data-session-id="<%= @checkout_session.id if @checkout_session.present? %>"
class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl" class="w-full btn btn-primary py-4 px-6 rounded-xl transition-all duration-200 transform hover:scale-105 active:scale-95 shadow-lg hover:shadow-xl"
> >
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i> <i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
Payer <%= @order.total_amount_euros %> <% if @order.total_amount_cents == 0 %>
Confirmer la commande
<% else %>
Payer <%= @order.total_amount_euros %>€
<% end %>
</div> </div>
</button> </button>
@@ -194,16 +224,16 @@
try { try {
// Increment payment attempt counter // Increment payment attempt counter
const orderId = checkoutButton.dataset.orderId; const orderId = checkoutButton.dataset.orderId;
const incrementUrl = checkoutButton.dataset.incrementUrl; const incrementUrl = checkoutButton.dataset.incrementUrl;
console.log('Incrementing payment attempt for order:', orderId); console.log('Incrementing payment attempt for order:', orderId);
const response = await fetch(incrementUrl, { const response = await fetch(incrementUrl, {
method: 'PATCH', method: 'PATCH',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'X-CSRF-Token': document.querySelector('[name=csrf-token]').content 'X-CSRF-Token': document.querySelector('[name=csrf-token]').content
} }
}); });
if (!response.ok) { if (!response.ok) {
console.error('Payment attempt increment failed:', response.status, response.statusText); console.error('Payment attempt increment failed:', response.status, response.statusText);
@@ -224,11 +254,11 @@
`; `;
// Redirect to Stripe // Redirect to Stripe
const sessionId = checkoutButton.dataset.sessionId; const sessionId = checkoutButton.dataset.sessionId;
console.log('Redirecting to Stripe with session ID:', sessionId); console.log('Redirecting to Stripe with session ID:', sessionId);
const stripeResult = await stripe.redirectToCheckout({ const stripeResult = await stripe.redirectToCheckout({
sessionId: sessionId sessionId: sessionId
}); });
if (stripeResult.error) { if (stripeResult.error) {
throw new Error(stripeResult.error.message); throw new Error(stripeResult.error.message);
@@ -241,7 +271,11 @@
button.innerHTML = ` button.innerHTML = `
<div class="flex items-center justify-center"> <div class="flex items-center justify-center">
<i data-lucide="credit-card" class="w-5 h-5 mr-2"></i> <i data-lucide="credit-card" class="w-5 h-5 mr-2"></i>
Payer <%= @order.total_amount_euros %> <% if @order.total_amount_cents == 0 %>
Confirmer la commande
<% else %>
Payer <%= @order.total_amount_euros %>€
<% end %>
</div> </div>
`; `;
alert('Erreur: ' + error.message); alert('Erreur: ' + error.message);

View File

@@ -123,13 +123,58 @@
<% end %> <% end %>
</div> </div>
<!-- Total --> <!-- Promotion Codes Applied -->
<div class="mt-6"> <% if @order.promotion_codes.any? %>
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 class="text-lg font-semibold text-green-900 mb-3 flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"/>
</svg>
Codes promotionnels appliqués
</h3>
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between py-2 px-3 bg-white rounded-lg border border-green-200 mb-2 last:mb-0">
<div class="flex items-center">
<span class="text-sm font-medium text-green-800">
<svg class="w-4 h-4 mr-1 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<%= promo_code.code %>
</span>
</div>
<span class="text-sm font-semibold text-green-700">-<%= promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
<% end %>
<!-- Price Breakdown -->
<div class="mt-6 p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h3 class="text-lg font-semibold text-gray-900 mb-3">Détail du paiement</h3>
<div class="space-y-2"> <div class="space-y-2">
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200"> <!-- Subtotal -->
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Sous-total</span>
<span class="font-medium text-gray-700"><%= @order.subtotal_amount_euros %>€</span>
</div>
<!-- Discount -->
<% if @order.discount_amount_cents > 0 %>
<div class="flex items-center justify-between text-sm">
<span class="text-green-600">Réduction</span>
<span class="font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
</div>
<% end %>
<!-- Total -->
<div class="flex items-center justify-between pt-2 border-t border-gray-300">
<span class="font-medium text-gray-900">Total payé</span> <span class="font-medium text-gray-900">Total payé</span>
<span class="font-bold text-2xl text-green-600"> <span class="font-bold text-xl text-green-600">
<%= @order.total_amount_euros %> <% if @order.total_amount_cents == 0 %>
GRATUIT
<% else %>
<%= @order.total_amount_euros %>€
<% end %>
</span> </span>
</div> </div>
</div> </div>

View File

@@ -94,14 +94,57 @@
<% end %> <% end %>
</div> </div>
<!-- Total --> <!-- Promotion Codes Applied -->
<% if @order.promotion_codes.any? %>
<div class="mt-6 p-4 bg-green-50 border border-green-200 rounded-lg">
<h3 class="text-lg font-semibold text-green-900 mb-3 flex items-center">
<i data-lucide="tag" class="w-5 h-5 mr-2"></i>
Codes promotionnels appliqués
</h3>
<% @order.promotion_codes.each do |promo_code| %>
<div class="flex items-center justify-between py-2 px-3 bg-white rounded-lg border border-green-200 mb-2 last:mb-0">
<div class="flex items-center">
<span class="text-sm font-medium text-green-800">
<i data-lucide="check-circle" class="w-4 h-4 mr-1 inline"></i>
<%= promo_code.code %>
</span>
</div>
<span class="text-sm font-semibold text-green-700">-<%= promo_code.discount_amount_euros %>€</span>
</div>
<% end %>
</div>
<% end %>
<!-- Price Breakdown -->
<div class="border-t border-gray-200 pt-6 mt-6"> <div class="border-t border-gray-200 pt-6 mt-6">
<div class="flex items-center justify-between text-lg pt-2 border-t border-gray-200"> <h3 class="text-lg font-semibold text-gray-900 mb-3">Détail du paiement</h3>
<div class="space-y-2">
<!-- Subtotal -->
<div class="flex items-center justify-between text-sm">
<span class="text-gray-600">Sous-total</span>
<span class="font-medium text-gray-700"><%= @order.subtotal_amount_euros %>€</span>
</div>
<!-- Discount -->
<% if @order.discount_amount_cents > 0 %>
<div class="flex items-center justify-between text-sm">
<span class="text-green-600">Réduction</span>
<span class="font-semibold text-green-600">-<%= @order.discount_amount_euros %>€</span>
</div>
<% end %>
<!-- Total -->
<div class="flex items-center justify-between pt-2 border-t border-gray-300">
<span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span> <span class="font-medium text-gray-900">Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %></span>
<span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>"> <span class="font-bold text-2xl <%= @order.status == 'paid' || @order.status == 'completed' ? 'text-green-600' : 'text-purple-600' %>">
<%= @order.total_amount_euros %> <% if @order.total_amount_cents == 0 %>
GRATUIT
<% else %>
<%= @order.total_amount_euros %>€
<% end %>
</span> </span>
</div> </div>
</div>
</div> </div>
<!-- View Invoice --> <!-- View Invoice -->
@@ -212,4 +255,4 @@
</div> </div>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -7,11 +7,11 @@
{ name: 'Tableau de bord', path: dashboard_path } { name: 'Tableau de bord', path: dashboard_path }
] %> ] %>
<!-- Page Header --> <!-- Promoter Page Header -->
<div class="mb-8"> <div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord</h1> <h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord promoteur</h1>
<p class="text-gray-600 mt-1">Gérez vos commandes et accédez à vos billets</p> <p class="text-gray-600 mt-1">Gérez vos commandes et accédez à vos billets</p>
</div> </div>
@@ -76,7 +76,9 @@
</div> </div>
</div> </div>
<!-- Brouillons -->
<div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-2xl p-6 border border-orange-200"> <div class="bg-gradient-to-br from-orange-50 to-orange-100 rounded-2xl p-6 border border-orange-200">
<%= link_to promoter_events_path do %>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<p class="text-orange-600 text-sm font-medium">Brouillons</p> <p class="text-orange-600 text-sm font-medium">Brouillons</p>
@@ -86,7 +88,9 @@
<i data-lucide="edit-3" class="w-6 h-6 text-orange-700"></i> <i data-lucide="edit-3" class="w-6 h-6 text-orange-700"></i>
</div> </div>
</div> </div>
</div> <% end %>
</div><!-- /Brouillons -->
</div> </div>
<!-- Revenue Chart & Recent Events --> <!-- Revenue Chart & Recent Events -->
@@ -273,6 +277,16 @@
</div> </div>
<% end %> <% end %>
<!-- User Page Header -->
<div class="mb-8">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 class="text-3xl font-bold text-gray-900">Mon tableau de bord</h1>
<p class="text-gray-600 mt-1">Accédez à vos billets et évenements</p>
</div>
</div>
</div>
<!-- User's Orders Section --> <!-- User's Orders Section -->
<div class="bg-white rounded-2xl shadow-lg mb-8"> <div class="bg-white rounded-2xl shadow-lg mb-8">
<div class="border-b border-gray-100 p-4 sm:p-6"> <div class="border-b border-gray-100 p-4 sm:p-6">

View File

@@ -132,10 +132,14 @@
<div class="bg-blue-50 border border-blue-200 rounded-2xl p-4"> <div class="bg-blue-50 border border-blue-200 rounded-2xl p-4">
<div class="flex items-center"> <div class="flex items-center">
<i data-lucide="users" class="w-5 h-5 text-blue-400 mr-3"></i> <i data-lucide="users" class="w-5 h-5 text-blue-400 mr-3"></i>
<div> <div class="flex-1">
<h3 class="text-sm font-medium text-blue-900">Événement complet</h3> <h3 class="text-sm font-medium text-blue-900">Événement complet</h3>
<p class="text-sm text-blue-700">Tous les billets pour cet événement ont été vendus.</p> <p class="text-sm text-blue-700">Tous les billets pour cet événement ont été vendus.</p>
</div> </div>
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "ml-4 inline-flex items-center px-3 py-1 bg-white border border-blue-300 text-blue-700 text-sm font-medium rounded-lg hover:bg-blue-50 transition-colors duration-200" do %>
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-1"></i>
Marquer comme disponible
<% end %>
</div> </div>
</div> </div>
<% end %> <% end %>
@@ -205,6 +209,42 @@
<!-- Sidebar --> <!-- Sidebar -->
<div class="space-y-6"> <div class="space-y-6">
<!-- Quick actions -->
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
<div class="space-y-3">
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
Gérer les types de billets
<% end %>
<%= link_to promoter_event_promotion_codes_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-green-600 text-white font-medium text-sm rounded-lg hover:bg-green-700 transition-colors duration-200" do %>
<i data-lucide="tag" class="w-4 h-4 mr-2"></i>
Gérer les codes de réduction
<% end %>
<% if @event.sold_out? %>
<%= button_to mark_available_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-blue-50 text-blue-700 font-medium text-sm rounded-lg hover:bg-blue-100 transition-colors duration-200" do %>
<i data-lucide="refresh-ccw" class="w-4 h-4 mr-2"></i>
Marquer comme disponible
<% end %>
<% elsif @event.published? %>
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200" do %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
Marquer comme complet
<% end %>
<% end %>
<hr class="border-gray-200">
<%= button_to promoter_event_path(@event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer l'événement
<% end %>
</div>
</div>
<!-- Event stats --> <!-- Event stats -->
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6"> <div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3> <h3 class="text-lg font-semibold text-gray-900 mb-4">Statistiques</h3>
@@ -265,27 +305,6 @@
</div> </div>
</div> </div>
<!-- Quick actions -->
<div class="bg-white rounded-2xl border border-gray-200 p-4 sm:p-6">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Actions rapides</h3>
<div class="space-y-3">
<%= link_to promoter_event_ticket_types_path(@event), class: "w-full inline-flex items-center justify-center px-4 py-3 bg-purple-600 text-white font-medium text-sm rounded-lg hover:bg-purple-700 transition-colors duration-200" do %>
<i data-lucide="ticket" class="w-4 h-4 mr-2"></i>
Gérer les types de billets
<% end %>
<%= button_to mark_sold_out_promoter_event_path(@event), method: :patch, class: "w-full inline-flex items-center justify-center px-4 py-3 bg-gray-50 text-gray-700 font-medium text-sm rounded-lg hover:bg-gray-100 transition-colors duration-200", disabled: !@event.published? do %>
<i data-lucide="users" class="w-4 h-4 mr-2"></i>
Marquer comme complet
<% end %>
<hr class="border-gray-200">
<%= button_to promoter_event_path(@event), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
class: "w-full inline-flex items-center justify-center px-4 py-3 text-red-600 font-medium text-sm rounded-lg hover:bg-red-50 transition-colors duration-200" do %>
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
Supprimer l'événement
<% end %>
</div>
</div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,109 @@
<% content_for(:title, "Modifier le code de réduction - #{@event.name}") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
{ name: 'Tableau de bord', path: dashboard_path },
{ name: 'Mes événements', path: promoter_events_path },
{ name: @event.name, path: promoter_event_path(@event) },
{ name: 'Codes de réduction', path: promoter_event_promotion_codes_path(@event) },
{ name: "Modifier #{@promotion_code.code}" }
] %>
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Modifier le code de réduction</h1>
<p class="text-gray-600">
<code class="bg-gray-100 px-2 py-1 rounded text-sm"><%= @promotion_code.code %></code> pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
</div>
<%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_code_path(@event, @promotion_code), method: :patch, local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
<% if @promotion_code.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
<div class="flex items-start">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0"></i>
<div class="flex-1">
<h3 class="text-sm font-medium text-red-800 mb-2">
<%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
</h3>
<ul class="list-disc list-inside text-sm text-red-700">
<% @promotion_code.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div class="space-y-6">
<div>
<%= form.label :code, "Code de réduction", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: SUMMER2024, BIENVENUE10, etc." %>
<p class="text-sm text-gray-500 mt-2">Ce code sera visible par les clients lors du paiement</p>
</div>
<div>
<%= form.label :discount_amount_euros, "Montant de la réduction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %>
<p class="text-sm text-gray-500 mt-2">Entrez le montant en euros (ex: 10, 5.50, 25)</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :expires_at, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une durée illimitée</p>
</div>
<div>
<%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :usage_limit, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: 50", min: 1 %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une utilisation illimitée</p>
</div>
</div>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
<%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
</div>
<div class="text-sm text-gray-500">
Les clients peuvent utiliser ce code de réduction
</div>
</div>
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<div class="flex items-start">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mr-3 mt-0.5 flex-shrink-0"></i>
<div>
<h4 class="text-sm font-medium text-blue-800 mb-1">Statut actuel</h4>
<div class="text-sm text-blue-700">
<p>Utilisations: <%= @promotion_code.uses_count %><%= " / #{@promotion_code.usage_limit}" if @promotion_code.usage_limit %></p>
<p>Commandes associées: <%= @promotion_code.orders.count %></p>
</div>
</div>
</div>
</div>
</div>
<div class="flex items-center justify-between pt-6 mt-8 border-t border-gray-200">
<%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="x" class="w-4 h-4 mr-2"></i>
Annuler
<% end %>
<%= form.submit "Mettre à jour le code de réduction", class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" %>
</div>
<% end %>
</div>
</div>

View File

@@ -0,0 +1,175 @@
<% content_for(:title, "Codes de réduction - #{@event.name}") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
{ name: 'Tableau de bord', path: dashboard_path },
{ name: 'Mes événements', path: promoter_events_path },
{ name: @event.name, path: promoter_event_path(@event) },
{ name: 'Codes de réduction' }
] %>
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Codes de réduction</h1>
<p class="text-gray-600">
<%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
<%= link_to new_promoter_event_promotion_code_path(@event), class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Nouveau code
<% end %>
</div>
<!-- Event status info -->
<% if @event.draft? %>
<div class="bg-gray-50 border border-gray-200 rounded-2xl p-4 mb-6">
<div class="flex items-center">
<i data-lucide="info" class="w-5 h-5 text-gray-400 mr-3"></i>
<p class="text-sm text-gray-600">
Cet événement est en brouillon. Les codes de réduction ne seront actifs qu'une fois l'événement publié.
</p>
</div>
</div>
<% end %>
</div>
<% if @promotion_codes.any? %>
<div class="grid gap-6">
<% @promotion_codes.each do |promotion_code| %>
<div class="bg-white rounded-2xl border border-gray-200 p-6 hover:shadow-md transition-shadow duration-200">
<div class="flex items-start justify-between">
<!-- Promotion code info -->
<div class="flex-1">
<div class="flex items-start justify-between mb-4">
<div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">
<%= promotion_code.code %>
</h3>
<p class="text-gray-600 mb-3">Réduction de <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %></p>
</div>
<!-- Status badge -->
<div class="ml-4">
<% if promotion_code.active? && (promotion_code.expires_at.nil? || promotion_code.expires_at > Time.current) %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-green-100 text-green-800">
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
Actif
</span>
<% elsif promotion_code.expires_at && promotion_code.expires_at <= Time.current %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-red-100 text-red-800">
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
Expiré
</span>
<% else %>
<span class="inline-flex px-3 py-1 text-sm font-semibold rounded-full bg-gray-100 text-gray-800">
<i data-lucide="pause-circle" class="w-4 h-4 mr-1"></i>
Inactif
</span>
<% end %>
</div>
</div>
<!-- Promotion code details grid -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-green-600">
<%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %>
</div>
<div class="text-sm text-gray-500">Réduction</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<% if promotion_code.usage_limit %>
<%= promotion_code.usage_limit - promotion_code.uses_count %>
<% else %>
<% end %>
</div>
<div class="text-sm text-gray-500">Restants</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-gray-900">
<%= promotion_code.uses_count %>
</div>
<div class="text-sm text-gray-500">Utilisés</div>
</div>
<div class="text-center p-3 bg-gray-50 rounded-lg">
<div class="text-2xl font-bold text-blue-600">
<%= promotion_code.orders.count %>
</div>
<div class="text-sm text-gray-500">Commandes</div>
</div>
</div>
<!-- Additional info -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500 mb-4">
<% if promotion_code.expires_at %>
<span class="flex items-center">
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
Expire le : <%= l(promotion_code.expires_at, format: :short) %>
</span>
<% else %>
<span class="flex items-center">
<i data-lucide="infinity" class="w-4 h-4 mr-1"></i>
Pas d'expiration
</span>
<% end %>
<span class="flex items-center">
<i data-lucide="user" class="w-4 h-4 mr-1"></i>
<% if promotion_code.user.first_name && promotion_code.user.last_name %>
Créé par : <%= promotion_code.user.first_name %> <%= promotion_code.user.last_name %>
<% else %>
Créé par : <%= promotion_code.user.email %>
<% end %>
</span>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
<div class="flex items-center space-x-3">
<%= link_to edit_promoter_event_promotion_code_path(@event, promotion_code), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %>
<i data-lucide="edit" class="w-5 h-5"></i>
<% end %>
<% if promotion_code.orders.empty? %>
<%= button_to promoter_event_promotion_code_path(@event, promotion_code), method: :delete,
data: { confirm: "Êtes-vous sûr de vouloir supprimer ce code de réduction ?" },
class: "text-gray-400 hover:text-red-600 transition-colors", title: "Supprimer" do %>
<i data-lucide="trash-2" class="w-5 h-5"></i>
<% end %>
<% end %>
</div>
<div class="text-sm text-gray-500">
Créé il y a <%= time_ago_in_words(promotion_code.created_at) %>
</div>
</div>
</div>
<% end %>
</div>
<% else %>
<div class="bg-white rounded-2xl border-2 border-dashed border-gray-300 p-12 text-center">
<div class="mx-auto h-24 w-24 rounded-full bg-gray-100 flex items-center justify-center mb-6">
<i data-lucide="tag" class="w-12 h-12 text-gray-400"></i>
</div>
<h3 class="text-xl font-semibold text-gray-900 mb-2">Aucun code de réduction</h3>
<p class="text-gray-500 mb-6">Créez des codes de réduction pour offrir des remises spéciales à vos clients.</p>
<%= link_to new_promoter_event_promotion_code_path(@event), class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" do %>
<i data-lucide="plus" class="w-4 h-4 mr-2"></i>
Créer mon premier code de réduction
<% end %>
</div>
<% end %>
</div>

View File

@@ -0,0 +1,96 @@
<% content_for(:title, "Nouveau code de réduction - #{@event.name}") %>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<!-- Breadcrumb -->
<%= render 'components/breadcrumb', crumbs: [
{ name: 'Accueil', path: root_path },
{ name: 'Tableau de bord', path: dashboard_path },
{ name: 'Mes événements', path: promoter_events_path },
{ name: @event.name, path: promoter_event_path(@event) },
{ name: 'Codes de réduction', path: promoter_event_promotion_codes_path(@event) },
{ name: 'Nouveau code' }
] %>
<div class="max-w-2xl mx-auto">
<div class="mb-8">
<div class="flex items-center space-x-4 mb-4">
<%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %>
<i data-lucide="arrow-left" class="w-5 h-5"></i>
<% end %>
<div class="flex-1">
<h1 class="text-3xl font-bold text-gray-900 mb-2">Nouveau code de réduction</h1>
<p class="text-gray-600">
Pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %>
</p>
</div>
</div>
</div>
<%= form_with(model: [@event, @promotion_code], url: promoter_event_promotion_codes_path(@event), local: true, class: "bg-white rounded-2xl border border-gray-200 p-6 sm:p-8") do |form| %>
<% if @promotion_code.errors.any? %>
<div class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
<div class="flex items-start">
<i data-lucide="alert-circle" class="w-5 h-5 text-red-400 mr-3 mt-0.5 flex-shrink-0"></i>
<div class="flex-1">
<h3 class="text-sm font-medium text-red-800 mb-2">
<%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé :
</h3>
<ul class="list-disc list-inside text-sm text-red-700">
<% @promotion_code.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
</div>
</div>
<% end %>
<div class="space-y-6">
<div>
<%= form.label :code, "Code de réduction", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.text_field :code, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: BIENVENUE10, VIP20" %>
<p class="text-sm text-gray-500 mt-2">Ce code sera à appliquer par le client lors du paiement.</p>
</div>
<div>
<%= form.label :discount_amount_euros, "Montant de la réduction (en euros)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :discount_amount_euros, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "10", min: 0, step: "0.01", value: number_with_precision(@promotion_code.discount_amount_cents.to_f / 100, precision: 2) %>
<p class="text-sm text-gray-500 mt-2">Entrez le montant en euros</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<%= form.label :expires_at, "Date d'expiration", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :expires_at, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors" %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une durée illimitée</p>
</div>
<div>
<%= form.label :usage_limit, "Limite d'utilisation", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :usage_limit, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-colors", placeholder: "Ex: 50", min: 1 %>
<p class="text-sm text-gray-500 mt-2">Laissez vide pour une utilisation illimitée</p>
</div>
</div>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div class="flex items-center">
<%= form.check_box :active, class: "h-4 w-4 text-purple-600 focus:ring-purple-500 border-gray-300 rounded" %>
<%= form.label :active, "Code actif", class: "ml-3 block text-sm font-medium text-gray-900" %>
</div>
<div class="text-sm text-gray-500">
Les clients peuvent utiliser ce code de réduction
</div>
</div>
</div>
<div class="flex items-center justify-between pt-6 mt-8 border-t border-gray-200">
<%= link_to promoter_event_promotion_codes_path(@event), class: "inline-flex items-center px-6 py-3 border border-gray-300 text-gray-700 font-medium rounded-lg hover:bg-gray-50 transition-colors duration-200" do %>
<i data-lucide="x" class="w-4 h-4 mr-2"></i>
Annuler
<% end %>
<%= form.submit "Créer le code de réduction", class: "inline-flex items-center px-6 py-3 bg-gray-900 text-white font-medium rounded-lg hover:bg-gray-800 transition-colors duration-200" %>
</div>
<% end %>
</div>
</div>

View File

@@ -81,7 +81,7 @@
<div class="relative"> <div class="relative">
<%= form.number_field :price_euros, <%= form.number_field :price_euros,
step: 0.01, step: 0.01,
min: 0.01, min: 0,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %> data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div> <div class="absolute left-3 top-2.5 text-gray-500">€</div>
@@ -91,6 +91,8 @@
<i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i> <i data-lucide="alert-triangle" class="w-4 h-4 inline mr-1"></i>
Modifier le prix n'affectera pas les billets déjà vendus Modifier le prix n'affectera pas les billets déjà vendus
</p> </p>
<% else %>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet (0€ pour un billet gratuit)</p>
<% end %> <% end %>
</div> </div>
@@ -163,8 +165,10 @@
<div class="flex"> <div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i> <i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800"> <p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br> <strong>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
Les ventes doivent se terminer avant le début de l'événement. <% unless @event.allow_booking_during_event? %>
Les ventes doivent se terminer avant le début de l'événement.
<% end %>
</p> </p>
</div> </div>
</div> </div>
@@ -208,12 +212,6 @@
<%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-500 hover:text-gray-700 transition-colors" do %> <%= link_to promoter_event_ticket_type_path(@event, @ticket_type), class: "text-gray-500 hover:text-gray-700 transition-colors" do %>
Annuler Annuler
<% end %> <% end %>
<% if @ticket_type.tickets.any? %>
<p class="text-sm text-yellow-600">
<i data-lucide="info" class="w-4 h-4 inline mr-1"></i>
<%= pluralize(@ticket_type.tickets.count, 'billet') %> déjà vendu(s)
</p>
<% end %>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">

View File

@@ -42,7 +42,7 @@
<!-- Basic Information --> <!-- Basic Information -->
<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="space-y-6"> <div class="space-y-6">
<div> <div>
<%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :name, "Nom du type de billet", class: "block text-sm font-medium text-gray-700 mb-2" %>
@@ -61,25 +61,25 @@
<!-- Pricing & Quantity --> <!-- Pricing & Quantity -->
<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">Prix et quantité</h3> <h3 class="text-lg font-semibold text-gray-900 mb-6">Prix et quantité</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :price_euros, "Prix (€)", class: "block text-sm font-medium text-gray-700 mb-2" %>
<div class="relative"> <div class="relative">
<%= form.number_field :price_euros, <%= form.number_field :price_euros,
step: 0.01, step: 0.01,
min: 0.01, min: 0,
class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", class: "w-full px-4 py-2 pl-8 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %> data: { "ticket-type-form-target": "price", action: "input->ticket-type-form#updateTotal" } %>
<div class="absolute left-3 top-2.5 text-gray-500">€</div> <div class="absolute left-3 top-2.5 text-gray-500">€</div>
</div> </div>
<p class="mt-1 text-sm text-gray-500">Prix unitaire du billet</p> <p class="mt-1 text-sm text-gray-500">Prix unitaire du billet (0€ pour un billet gratuit)</p>
</div> </div>
<div> <div>
<%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :quantity, "Quantité disponible", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :quantity, <%= form.number_field :quantity,
min: 1, min: 1,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %> data: { "ticket-type-form-target": "quantity", action: "input->ticket-type-form#updateTotal" } %>
<p class="mt-1 text-sm text-gray-500">Nombre total de billets de ce type</p> <p class="mt-1 text-sm text-gray-500">Nombre total de billets de ce type</p>
@@ -100,18 +100,18 @@
<!-- Sales Period --> <!-- Sales Period -->
<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">Période de vente</h3> <h3 class="text-lg font-semibold text-gray-900 mb-6">Période de vente</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :sale_start_at, "Début des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_start_at, <%= form.datetime_local_field :sale_start_at,
value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"), value: @ticket_type.sale_start_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %> class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
</div> </div>
<div> <div>
<%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :sale_end_at, "Fin des ventes", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.datetime_local_field :sale_end_at, <%= form.datetime_local_field :sale_end_at,
value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"), value: @ticket_type.sale_end_at&.strftime("%Y-%m-%dT%H:%M"),
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %> class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent" %>
<p class="mt-1 text-sm text-gray-500">Les ventes s'arrêtent automatiquement à cette date</p> <p class="mt-1 text-sm text-gray-500">Les ventes s'arrêtent automatiquement à cette date</p>
@@ -123,8 +123,11 @@
<div class="flex"> <div class="flex">
<i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i> <i data-lucide="info" class="w-5 h-5 text-blue-400 mt-0.5 mr-2"></i>
<p class="text-sm text-blue-800"> <p class="text-sm text-blue-800">
<strong>Événement:</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br> <strong>Début d'événement :</strong> <%= @event.start_time.strftime("%d/%m/%Y à %H:%M") %><br>
Les ventes doivent se terminer avant le début de l'événement.
<% unless @event.allow_booking_during_event? %>
Les ventes doivent se terminer avant le début de l'événement.
<% end %>
</p> </p>
</div> </div>
</div> </div>
@@ -134,13 +137,13 @@
<!-- Access Requirements --> <!-- Access Requirements -->
<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">Conditions d'accès</h3> <h3 class="text-lg font-semibold text-gray-900 mb-6">Conditions d'accès</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div>
<%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %> <%= form.label :minimum_age, "Âge minimum", class: "block text-sm font-medium text-gray-700 mb-2" %>
<%= form.number_field :minimum_age, <%= form.number_field :minimum_age,
min: 0, min: 0,
max: 120, max: 120,
class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent", class: "w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent",
placeholder: "Laisser vide si aucune restriction" %> placeholder: "Laisser vide si aucune restriction" %>
<p class="mt-1 text-sm text-gray-500">Âge minimum requis (optionnel)</p> <p class="mt-1 text-sm text-gray-500">Âge minimum requis (optionnel)</p>
@@ -167,11 +170,11 @@
Annuler Annuler
<% end %> <% end %>
</div> </div>
<div class="flex items-center space-x-3"> <div class="flex items-center space-x-3">
<%= form.submit "Créer le type de billet", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %> <%= form.submit "Créer le type de billet", class: "inline-flex items-center px-6 py-3 bg-purple-600 text-white font-medium rounded-lg hover:bg-purple-700 transition-colors duration-200" %>
</div> </div>
</div> </div>
<% end %> <% end %>
</div> </div>
</div> </div>

View File

@@ -138,7 +138,7 @@
</div> </div>
<div class="text-right"> <div class="text-right">
<p class="text-sm font-medium text-gray-900"> <p class="text-sm font-medium text-gray-900">
<%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %> <%= format_ticket_price(ticket.price_cents) %>
</p> </p>
<p class="text-xs text-gray-500"> <p class="text-xs text-gray-500">
<%= ticket.created_at.strftime("%d/%m/%Y") %> <%= ticket.created_at.strftime("%d/%m/%Y") %>
@@ -164,7 +164,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="text-center p-4 bg-purple-50 rounded-lg"> <div class="text-center p-4 bg-purple-50 rounded-lg">
<div class="text-3xl font-bold text-purple-600"> <div class="text-3xl font-bold text-purple-600">
<%= number_to_currency(@ticket_type.price_euros, unit: "€") %> <%= format_ticket_price(@ticket_type.price_cents) %>
</div> </div>
<div class="text-sm text-gray-500">Prix unitaire</div> <div class="text-sm text-gray-500">Prix unitaire</div>
</div> </div>

View File

@@ -57,7 +57,7 @@
<p style="margin: 5px 0 0;"><a href="<%= ticket_url(ticket) %>" style="color: #4c1d95; text-decoration: none; font-size: 14px;">📱 Voir le détail et le code QR</a></p> <p style="margin: 5px 0 0;"><a href="<%= ticket_url(ticket) %>" style="color: #4c1d95; text-decoration: none; font-size: 14px;">📱 Voir le détail et le code QR</a></p>
</div> </div>
<div style="text-align: right;"> <div style="text-align: right;">
<p style="margin: 0; font-weight: bold; color: #212529;"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></p> <p style="margin: 0; font-weight: bold; color: #212529;"><%= format_ticket_price(ticket.price_cents) %></p>
</div> </div>
</div> </div>
</div> </div>
@@ -83,7 +83,7 @@
</div> </div>
<div style="text-align: right;"> <div style="text-align: right;">
<p style="margin: 0; color: #6c757d; font-size: 14px;">Prix</p> <p style="margin: 0; color: #6c757d; font-size: 14px;">Prix</p>
<p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %></p> <p style="margin: 5px 0 0; font-weight: bold; color: #212529;"><%= format_ticket_price(@ticket.price_cents) %></p>
</div> </div>
</div> </div>

View File

@@ -13,11 +13,11 @@ DÉTAILS DE VOTRE COMMANDE
Événement : <%= @event.name %> Événement : <%= @event.name %>
Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %> Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
Nombre de billets : <%= @tickets.count %> Nombre de billets : <%= @tickets.count %>
Total : <%= number_to_currency(@order.total_amount_euros, unit: "€") %> Total : <%= @order.free? ? "Gratuit" : number_to_currency(@order.total_amount_euros, unit: "€") %>
BILLETS INCLUS : BILLETS INCLUS :
<% @tickets.each_with_index do |ticket, index| %> <% @tickets.each_with_index do |ticket, index| %>
- Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %> - Billet #<%= index + 1 %> : <%= ticket.ticket_type.name %> - <%= format_ticket_price(ticket.price_cents) %>
<% end %> <% end %>
Vos billets sont attachés à cet email en format PDF. Présentez-les à l'entrée de l'événement pour y accéder. Vos billets sont attachés à cet email en format PDF. Présentez-les à l'entrée de l'événement pour y accéder.
@@ -32,7 +32,7 @@ DÉTAILS DE VOTRE BILLET
Événement : <%= @event.name %> Événement : <%= @event.name %>
Type de billet : <%= @ticket.ticket_type.name %> Type de billet : <%= @ticket.ticket_type.name %>
Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %> Date & heure : <%= @event.start_time.strftime("%d %B %Y à %H:%M") %>
Prix : <%= number_to_currency(@ticket.price_cents / 100.0, unit: "€") %> Prix : <%= format_ticket_price(@ticket.price_cents) %>
Votre billet est attaché à cet email en format PDF. Présentez-le à l'entrée de l'événement pour y accéder. Votre billet est attaché à cet email en format PDF. Présentez-le à l'entrée de l'événement pour y accéder.

View File

@@ -1,150 +0,0 @@
<div class="min-h-screen bg-gradient-to-br from-purple-50 to-indigo-50 py-12 px-4 sm:px-6">
<div class="max-w-3xl mx-auto">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<!-- Header -->
<div class="bg-gradient-to-r from-purple-600 to-indigo-700 px-6 py-8 text-center">
<div class="flex justify-center mb-4">
<div class="w-16 h-16 rounded-full bg-white/20 flex items-center justify-center">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
</div>
</div>
<h1 class="text-3xl font-bold text-white mb-2">Paiement réussi !</h1>
<p class="text-purple-100">Félicitations pour votre achat</p>
</div>
<!-- Content -->
<div class="p-6 sm:p-8">
<div class="text-center mb-8">
<p class="text-xl text-gray-700">
Vos billets pour <span class="font-bold text-purple-700"><%= @event.name %></span> ont été achetés avec succès.
</p>
<p class="text-gray-500 mt-2">
Un email de confirmation avec vos billets a été envoyé à <span class="font-medium"><%= current_user.email %></span>
</p>
</div>
<!-- Event Details -->
<div class="bg-gray-50 rounded-xl p-6 mb-8">
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
Détails de l'événement
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-center p-3 bg-white rounded-lg">
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<div>
<p class="text-xs text-gray-500">Lieu</p>
<p class="font-medium"><%= @event.venue_name %></p>
</div>
</div>
<div class="flex items-center p-3 bg-white rounded-lg">
<svg class="w-5 h-5 text-purple-500 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<p class="text-xs text-gray-500">Date & Heure</p>
<p class="font-medium"><%= @event.start_time.strftime("%d %B %Y à %H:%M") %></p>
</div>
</div>
</div>
</div>
<!-- Tickets -->
<div class="mb-8">
<h2 class="text-xl font-bold text-gray-900 mb-4 flex items-center">
<svg class="w-5 h-5 mr-2 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
</svg>
Vos billets
</h2>
<div class="space-y-4">
<% @tickets.each do |ticket| %>
<div class="bg-gradient-to-r from-purple-50 to-indigo-50 rounded-xl border border-purple-100 p-5">
<div class="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div class="flex-1">
<div class="flex items-center">
<div class="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center mr-4">
<svg class="w-5 h-5 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 5v2m0 4v2m0 4v2M5 5a2 2 0 00-2 2v3a2 2 0 110 4v3a2 2 0 002 2h14a2 2 0 002-2v-3a2 2 0 110-4V7a2 2 0 00-2-2H5z"></path>
</svg>
</div>
<div>
<h3 class="font-bold text-gray-900"><%= ticket.ticket_type.name %></h3>
<p class="text-sm text-gray-600">Prix: <span class="font-medium"><%= number_to_currency(ticket.price_cents / 100.0, unit: "€") %></span></p>
</div>
</div>
</div>
<div class="flex items-center space-x-2">
<%= link_to ticket_download_path(ticket.qr_code, format: :pdf),
class: "inline-flex items-center px-4 py-2 btn btn-primary rounded-lg transition-all duration-200 text-sm font-medium shadow-sm" do %>
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
Télécharger PDF
<% end %>
</div>
</div>
<div class="mt-4 pt-4 border-t border-purple-100 flex items-center justify-between">
<div class="flex items-center">
<svg class="w-4 h-4 text-gray-500 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
<span class="text-xs text-gray-500">Code QR: <%= ticket.qr_code[0..7] %></span>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
Actif
</span>
</div>
</div>
<% end %>
</div>
</div>
<!-- Important Notice -->
<div class="bg-blue-50 border border-blue-100 rounded-xl p-5 mb-8">
<div class="flex">
<svg class="w-5 h-5 text-blue-500 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-bold text-blue-800 mb-1">Important</h3>
<p class="text-sm text-blue-700">
Veuillez télécharger et sauvegarder vos billets. Présentez-les à l'entrée du lieu pour accéder à l'événement.
Un email de confirmation avec vos billets a été envoyé à votre adresse email.
</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<%= link_to dashboard_path,
class: "inline-flex items-center justify-center px-6 py-3 btn btn-primary rounded-xl transition-all duration-200 font-medium shadow-sm" do %>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Tableau de bord
<% end %>
<%= link_to events_path,
class: "inline-flex items-center justify-center px-6 py-3 bg-white text-gray-700 rounded-xl border border-gray-300 hover:bg-gray-50 transition-all duration-200 font-medium shadow-sm" do %>
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
Voir plus d'événements
<% end %>
</div>
</div>
</div>
</div>
</div>

View File

@@ -80,7 +80,7 @@
<div> <div>
<label class="block text-sm font-medium text-gray-500 mb-1">Prix</label> <label class="block text-sm font-medium text-gray-500 mb-1">Prix</label>
<p class="text-xl font-bold text-gray-900"> <p class="text-xl font-bold text-gray-900">
<%= number_to_currency(@ticket.price_euros, unit: "€", separator: ",", delimiter: " ", format: "%n %u") %> <%= format_ticket_price(@ticket.price_cents) %>
</p> </p>
</div> </div>
</div> </div>

View File

@@ -81,6 +81,7 @@ Rails.application.routes.draw do
patch :unpublish patch :unpublish
patch :cancel patch :cancel
patch :mark_sold_out patch :mark_sold_out
patch :mark_available
post :duplicate post :duplicate
end end
@@ -90,6 +91,16 @@ Rails.application.routes.draw do
post :duplicate post :duplicate
end end
end end
# Nested promotion codes routes
resources :promotion_codes, except: [ :show ]
end
end
# === Promotion Codes Routes ===
resources :promotion_codes, only: [ :index ] do
member do
post :apply
end end
end end

View File

@@ -1,14 +1,15 @@
class CreateOrders < ActiveRecord::Migration[8.0] class CreateOrders < ActiveRecord::Migration[8.0]
def change def change
create_table :orders do |t| create_table :orders do |t|
t.references :user, null: false, foreign_key: false
t.references :event, null: false, foreign_key: false
t.string :status, null: false, default: "draft" t.string :status, null: false, default: "draft"
t.integer :total_amount_cents, null: false, default: 0 t.integer :total_amount_cents, null: false, default: 0
t.integer :payment_attempts, null: false, default: 0 t.integer :payment_attempts, null: false, default: 0
t.timestamp :expires_at t.timestamp :expires_at
t.timestamp :last_payment_attempt_at t.timestamp :last_payment_attempt_at
t.references :user, null: false, foreign_key: false
t.references :event, null: false, foreign_key: false
t.timestamps t.timestamps
end end

View File

@@ -0,0 +1,21 @@
class CreatePromotionCodes < ActiveRecord::Migration[8.0]
def change
create_table :promotion_codes do |t|
t.string :code, null: false
t.integer :discount_amount_cents, null: false, default: 0
t.datetime :expires_at
t.boolean :active, default: true, null: false
t.integer :usage_limit, default: nil
t.integer :uses_count, default: 0, null: false
# Reference user(promoter) who has created the promotion code
t.references :user, null: false, foreign_key: true
t.references :event, null: false, foreign_key: true
t.timestamps
end
# Unique index for code
add_index :promotion_codes, :code, unique: true
end
end

View File

@@ -0,0 +1,10 @@
class CreateOrderPromotionCodes < ActiveRecord::Migration[8.0]
def change
create_table :order_promotion_codes do |t|
t.references :order, null: false, foreign_key: true
t.references :promotion_code, null: false, foreign_key: true
t.timestamps
end
end
end

38
db/schema.rb generated
View File

@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do ActiveRecord::Schema[8.0].define(version: 2025_09_28_181311) do
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "slug", null: false t.string "slug", null: false
@@ -25,23 +25,32 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
t.decimal "longitude", precision: 10, scale: 6, null: false t.decimal "longitude", precision: 10, scale: 6, null: false
t.boolean "featured", default: false, null: false t.boolean "featured", default: false, null: false
t.bigint "user_id", null: false t.bigint "user_id", null: false
t.boolean "allow_booking_during_event", default: false, null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.boolean "allow_booking_during_event", default: false, null: false
t.index ["featured"], name: "index_events_on_featured" t.index ["featured"], name: "index_events_on_featured"
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude" t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude"
t.index ["state"], name: "index_events_on_state" t.index ["state"], name: "index_events_on_state"
t.index ["user_id"], name: "index_events_on_user_id" t.index ["user_id"], name: "index_events_on_user_id"
end end
create_table "order_promotion_codes", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.bigint "order_id", null: false
t.bigint "promotion_code_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["order_id"], name: "index_order_promotion_codes_on_order_id"
t.index ["promotion_code_id"], name: "index_order_promotion_codes_on_promotion_code_id"
end
create_table "orders", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "orders", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.bigint "event_id", null: false
t.string "status", default: "draft", null: false t.string "status", default: "draft", null: false
t.integer "total_amount_cents", default: 0, null: false t.integer "total_amount_cents", default: 0, null: false
t.integer "payment_attempts", default: 0, null: false t.integer "payment_attempts", default: 0, null: false
t.timestamp "expires_at" t.timestamp "expires_at"
t.timestamp "last_payment_attempt_at" t.timestamp "last_payment_attempt_at"
t.bigint "user_id", null: false
t.bigint "event_id", null: false
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["event_id", "status"], name: "idx_orders_event_status" t.index ["event_id", "status"], name: "idx_orders_event_status"
@@ -51,6 +60,22 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
t.index ["user_id"], name: "index_orders_on_user_id" t.index ["user_id"], name: "index_orders_on_user_id"
end end
create_table "promotion_codes", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "code", null: false
t.integer "discount_amount_cents", default: 0, null: false
t.datetime "expires_at"
t.boolean "active", default: true, null: false
t.integer "usage_limit"
t.integer "uses_count", default: 0, null: false
t.bigint "user_id", null: false
t.bigint "event_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_promotion_codes_on_code", unique: true
t.index ["event_id"], name: "index_promotion_codes_on_event_id"
t.index ["user_id"], name: "index_promotion_codes_on_user_id"
end
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t| create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
t.string "name" t.string "name"
t.text "description" t.text "description"
@@ -104,4 +129,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
t.index ["email"], name: "index_users_on_email", unique: true t.index ["email"], name: "index_users_on_email", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
end end
add_foreign_key "order_promotion_codes", "orders"
add_foreign_key "order_promotion_codes", "promotion_codes"
add_foreign_key "promotion_codes", "events"
add_foreign_key "promotion_codes", "users"
end end

View File

@@ -112,3 +112,194 @@ events.each_with_index do |event, index|
end end
puts "Created #{User.count} users, #{Event.count} events, and #{TicketType.count} ticket types" puts "Created #{User.count} users, #{Event.count} events, and #{TicketType.count} ticket types"
# Create regular user as promoter
promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u|
u.password = "lareunion974!"
u.password_confirmation = "lareunion974!"
u.last_name = nil
u.first_name = nil
u.is_professionnal = true
end
belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS") do |e|
e.slug = "la-belle-epoque-par-sisley-events"
e.state = :draft
e.description = "
Sisley évents Présente :
SAM 13 SEPT
LA BELLE ÉPOQUE de 18H à 2H
sur le Rooftop LE PATIO
ÉVÈNEMENT EN PLEIN AIR
Ambiance Rétro / old school : zouk , Ragga , kompa , Dancehall , hip hop , Groove , Rnb …
Restauration disponible sur place : Accras ,Allocos , specialités asiatique , japonaise et une large carte de choix de Pizzas pour vous régaler !
TARIF D'ENTRÉE : 10€ SUR PLACE UNIQUEMENT
Réservée aux + de 30 ans
Suivez nous sur Instagram : Sisley Évents
Le patio
38 avenue Leon Gaumont , Montreuil
Parking du Décathlon disponible , rue de la république, à 100m du Patio
"
e.venue_name = "Le Patio Rooftop"
e.venue_address = "38 Av. Léon Gaumont, 93100 Montreuil"
e.latitude = 48.862336
e.longitude = 2.441218
e.start_time = 3.days.from_now
e.end_time = 3.days.from_now + 8.hours
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
e.user = promoter
e.allow_booking_during_event = true
end
belle_epoque_event.update!(start_time: 3.days.from_now, end_time: 3.days.from_now + 8.hours)
# Create ticket types for "La belle époque" event
belle_epoque_event = Event.find_by!(slug: "la-belle-epoque-par-sisley-events")
TicketType.find_or_create_by!(event: belle_epoque_event, name: "Free invitation valid before 7 p.m.") do |tt|
tt.description = "Free invitation ticket valid before 7 p.m. for La Belle Époque"
tt.price_cents = 0
tt.quantity = 50
tt.sale_start_at = Time.current
tt.sale_end_at = belle_epoque_event.start_time
tt.minimum_age = 30
tt.requires_id = true
end
TicketType.find_or_create_by!(event: belle_epoque_event, name: "ENTRY 10€ TO BE PAYED ON SITE ONLY") do |tt|
tt.description = "Entry ticket to be paid on site only (free in system)"
tt.price_cents = 0
tt.quantity = 100
tt.sale_start_at = Time.current
tt.sale_end_at = belle_epoque_event.start_time
tt.minimum_age = 30
tt.requires_id = true
end
TicketType.find_or_create_by!(event: belle_epoque_event, name: "Paid Entry 10€") do |tt|
tt.description = "Paid entry ticket for La Belle Époque at 10€"
tt.price_cents = 1000 # 10€
tt.quantity = 200
tt.sale_start_at = Time.current
tt.sale_end_at = belle_epoque_event.start_time
tt.minimum_age = 30
tt.requires_id = true
end
puts "Created 1 promoter, 1 draft event with ticket types"
# Create additional events fetched from Bizouk
konpa_event = Event.find_or_create_by!(name: "Konpa With Bev - Cours De Konpa Gouyad") do |e|
e.slug = "konpa-with-bev-cours-de-konpa-gouyad"
e.description = "Séance ouverte à tous, débutant ou initié, venez perfectionner votre Konpa avec la talentueuse Beverly."
e.venue_name = "Guest Live"
e.venue_address = "36 Rue Marcel Dassault, 93140 Bondy"
e.latitude = 48.9096
e.longitude = 2.4836
e.start_time = Time.parse("2025-10-03 19:00:00")
e.end_time = Time.parse("2025-10-03 23:00:00")
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/79/61/081f38b583ac651f3a0930c5d8f13458_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
caribbean_groove_event = Event.find_or_create_by!(name: "La Plus Grosse Soirée Caribbean Groove") do |e|
e.slug = "la-plus-grosse-soiree-caribbean-groove"
e.description = "La CARIBBEAN GROOVE de 23H00 à 5h00... DJ DON BREEZY Aux Platines HIPHOP , RnB, zouk ,kompa , Dancehall, Afro.beat"
e.venue_name = "LE TOUT LE MONDE EN PARLE"
e.venue_address = "4 RUE DU DEPART 75015 PARIS"
e.latitude = 48.8406
e.longitude = 2.2935
e.start_time = Time.parse("2025-10-03 23:00:00")
e.end_time = Time.parse("2025-10-04 05:00:00")
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/83/15/fa5d43f0b1998f691181cfda8fe35213_800_600_auto_97.png"
e.user = promoter
e.state = :published
end
belle_epoque_october_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS - OCTOBRE") do |e|
e.slug = "la-belle-epoque-par-sisley-events-octobre"
e.description = "SAM 4 OCTOBRE LA BELLE ÉPOQUE de 18H à 2H sur le Rooftop LE PATIO LA Dernière de la Saison ÉVÈNEMENT EN PLEIN AIR Ambiance Rétro / old school : zouk , Ragga , kompa , Dancehall , hip hop , Groove , Rnb … Restauration disponible sur place : Accras ,Allocos , specialités asiatique , japonaise et une large carte de choix de Pizzas pour vous régaler ! ENTRÉE LIBRE POUR TOUS AV 21H TARIF D'ENTRÉE : 10€ SUR PLACE UNIQUEMENT Réservée aux + de 30 ans Suivez nous sur Instagram : Sisley Évents"
e.venue_name = "Le Patio"
e.venue_address = "38 Avenue Leon Gaumont, 93100 Montreuil"
e.latitude = 48.862336
e.longitude = 2.441218
e.start_time = Time.parse("2025-10-04 18:00:00")
e.end_time = Time.parse("2025-10-05 02:00:00")
e.featured = false
e.image = "https://data.bizouk.com/cache1/events/images/10/92/72/351e61b55603a4d142b43486216457c1_800_600_auto_97.jpg"
e.user = promoter
e.state = :published
e.allow_booking_during_event = true
end
# Create ticket types for the new events
# Konpa event ticket types
TicketType.find_or_create_by!(event: konpa_event, name: "General Admission") do |tt|
tt.description = "General admission ticket for Konpa With Bev"
tt.price_cents = 1500 # $15.00
tt.quantity = 50
tt.sale_start_at = Time.current
tt.sale_end_at = konpa_event.start_time - 1.hour
tt.minimum_age = 18
end
# Caribbean Groove event ticket types
TicketType.find_or_create_by!(event: caribbean_groove_event, name: "General Admission") do |tt|
tt.description = "General admission ticket for Caribbean Groove"
tt.price_cents = 2000 # $20.00
tt.quantity = 100
tt.sale_start_at = Time.current
tt.sale_end_at = caribbean_groove_event.start_time - 1.hour
tt.minimum_age = 18
end
# Belle Époque October event ticket types
TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Free Entry Before 9 PM") do |tt|
tt.description = "Free entry before 9 PM for La Belle Époque October"
tt.price_cents = 0
tt.quantity = 50
tt.sale_start_at = Time.current
tt.sale_end_at = belle_epoque_october_event.start_time + 3.hours
tt.minimum_age = 30
tt.requires_id = true
end
TicketType.find_or_create_by!(event: belle_epoque_october_event, name: "Entry 10€ After 9 PM") do |tt|
tt.description = "Entry ticket 10€ after 9 PM for La Belle Époque October"
tt.price_cents = 1000 # 10€
tt.quantity = 150
tt.sale_start_at = Time.current
tt.sale_end_at = belle_epoque_october_event.start_time + 8.hours
tt.minimum_age = 30
tt.requires_id = true
end
puts "Created 3 additional events from Bizouk with ticket types"
# Create promotion codes for events
# Promotion code for belle_epoque_event
PromotionCode.find_or_create_by!(code: "BELLE10") do |pc|
pc.discount_amount_cents = 1000 # 10€ discount
pc.expires_at = belle_epoque_event.start_time + 1.day
pc.active = true
pc.usage_limit = 20
pc.user = promoter
pc.event = belle_epoque_october_event
end
# Promotion code for belle_epoque_october_event
PromotionCode.find_or_create_by!(code: "OCTOBRE5") do |pc|
pc.discount_amount_cents = 500 # 5€ discount
pc.expires_at = belle_epoque_october_event.start_time + 1.day
pc.active = true
pc.usage_limit = 30
pc.user = promoter
pc.event = belle_epoque_october_event
end
puts "Created promotion codes for events"

View File

@@ -0,0 +1,65 @@
volumes:
mariadb_data:
gem_home:
node_modules_home:
networks:
default:
services:
mariadb:
image: mariadb:11.7.2-noble
env_file: .env
restart: unless-stopped
volumes:
- mariadb_data:/var/lib/mysql
#- ./mysql:/var/lib/mysql"
ports:
- "${FORWARD_DB_PORT:-3306}:3306"
environment:
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
# MYSQL_ROOT_HOST: "%"
MYSQL_PORT: "${DB_PORT:-3306}"
MYSQL_DATABASE: "${DB_DATABASE:-aperonight}"
MYSQL_USER: "${DB_USERNAME:-aperonight}"
MYSQL_PASSWORD: "${DB_PASSWORD:-aperonight}"
#MYSQL_ALLOW_EMPTY_PASSWORD: 1
networks:
- default
#command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
rails:
build:
context: .
dockerfile: Dockerfile.production
env_file: .env
restart: unless-stopped
volumes:
- ./:/rails
- gem_home:/usr/local/bundle
- node_modules_home:/rails/node_modules
#- ./log:/var/log
#- ./log/supervisor:/var/log/supervisor
ports:
#- 80:80
- 3000:3000
#- 5000:5000
depends_on:
- mariadb
networks:
- default
#tty: true
#command: /opt/src/bin/dev
mailhog:
image: corpusops/mailhog:v1.0.1
restart: unless-stopped
# environment:
# - "mh_auth_file=/opt/mailhog/passwd.conf"
volumes:
- ./data/mailhog:/opt/mailhog
ports:
- 1025:1025 # smtp port 25
- 8025:8025 # web mail access
networks:
- default

View File

@@ -0,0 +1,66 @@
require "test_helper"
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
# Setup test data
def setup
@user = users(:one)
@event = events(:one)
@order = orders(:one)
sign_in @user
end
# Test applying a valid promotion code
def test_apply_valid_promotion_code
promotion_code = PromotionCode.create(
code: "TESTDISCOUNT",
discount_amount_cents: 1000, # €10.00
expires_at: 1.month.from_now,
active: true
)
get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" }
assert_response :success
assert_not_nil flash[:notice]
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash[:notice]
end
# Test applying an invalid promotion code
def test_apply_invalid_promotion_code
get checkout_order_path(@order), params: { promotion_code: "INVALIDCODE" }
assert_response :success
assert_not_nil flash[:alert]
assert_equal "Code promotionnel invalide", flash[:alert]
end
# Test applying an expired promotion code
def test_apply_expired_promotion_code
promotion_code = PromotionCode.create(
code: "EXPIREDCODE",
discount_amount_cents: 1000,
expires_at: 1.day.ago,
active: true
)
get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" }
assert_response :success
assert_not_nil flash[:alert]
assert_equal "Code promotionnel invalide", flash[:alert]
end
# Test applying an inactive promotion code
def test_apply_inactive_promotion_code
promotion_code = PromotionCode.create(
code: "INACTIVECODE",
discount_amount_cents: 1000,
expires_at: 1.month.from_now,
active: false
)
get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" }
assert_response :success
assert_not_nil flash[:alert]
assert_equal "Code promotionnel invalide", flash[:alert]
end
end

View File

@@ -125,7 +125,7 @@ class OrdersControllerTest < ActionDispatch::IntegrationTest
assert_equal "draft", new_order.status assert_equal "draft", new_order.status
assert_equal @user, new_order.user assert_equal @user, new_order.user
assert_equal @event, new_order.event assert_equal @event, new_order.event
assert_equal @ticket_type.price_cents + 100, new_order.total_amount_cents # includes 1€ service fee assert_equal @ticket_type.price_cents, new_order.total_amount_cents # Service fee deducted from promoter payout, not added to customer
assert_redirected_to checkout_order_path(new_order) assert_redirected_to checkout_order_path(new_order)
assert_equal new_order.id, session[:draft_order_id] assert_equal new_order.id, session[:draft_order_id]

View File

@@ -603,4 +603,22 @@ class OrderTest < ActiveSupport::TestCase
result = order.stripe_invoice_pdf_url result = order.stripe_invoice_pdf_url
assert_nil result assert_nil result
end end
test "free? should return true for zero amount orders" do
free_order = Order.create!(
user: @user, event: @event, total_amount_cents: 0,
status: "draft", payment_attempts: 0
)
assert free_order.free?
end
test "free? should return false for non-zero amount orders" do
paid_order = Order.create!(
user: @user, event: @event, total_amount_cents: 1000,
status: "draft", payment_attempts: 0
)
assert_not paid_order.free?
end
end end

View File

@@ -0,0 +1,67 @@
require "test_helper"
class PromotionCodeTest < ActiveSupport::TestCase
# Test valid promotion code creation
def test_valid_promotion_code
promotion_code = PromotionCode.create(
code: "DISCOUNT10",
discount_amount_cents: 1000, # €10.00
expires_at: 1.month.from_now,
active: true
)
assert promotion_code.valid?
assert_equal "DISCOUNT10", promotion_code.code
assert_equal 1000, promotion_code.discount_amount_cents
assert promotion_code.active?
end
# Test validation for required fields
def test_validation_for_required_fields
promotion_code = PromotionCode.new
refute promotion_code.valid?
assert_not_nil promotion_code.errors[:code]
end
# Test unique code validation
def test_unique_code_validation
PromotionCode.create(code: "UNIQUE123", discount_amount_cents: 500)
duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500)
refute duplicate_code.valid?
assert_not_nil duplicate_code.errors[:code]
end
# Test discount amount validation
def test_discount_amount_validation
promotion_code = PromotionCode.new(code: "VALID123", discount_amount_cents: -100)
refute promotion_code.valid?
assert_not_nil promotion_code.errors[:discount_amount_cents]
end
# Test active scope
def test_active_scope
active_code = PromotionCode.create(code: "ACTIVE123", discount_amount_cents: 500, active: true)
inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false)
assert_includes PromotionCode.active, active_code
refute_includes PromotionCode.active, inactive_code
end
# Test expired scope
def test_expired_scope
expired_code = PromotionCode.create(code: "EXPIRED123", discount_amount_cents: 500, expires_at: 1.day.ago)
future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now)
assert_includes PromotionCode.expired, expired_code
refute_includes PromotionCode.expired, future_code
end
# Test valid scope
def test_valid_scope
valid_code = PromotionCode.create(code: "VALID123", discount_amount_cents: 500, active: true, expires_at: 1.month.from_now)
invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago)
assert_includes PromotionCode.valid, valid_code
refute_includes PromotionCode.valid, invalid_code
end
end

View File

@@ -244,4 +244,38 @@ class TicketTypeTest < ActiveSupport::TestCase
) )
assert_not ticket_type.save assert_not ticket_type.save
end end
test "should allow free tickets with zero price" do
user = User.create!(
email: "test@example.com",
password: "password123",
password_confirmation: "password123"
)
event = Event.create!(
name: "Test Event",
slug: "test-event",
description: "Valid description for the event that is long enough",
latitude: 48.8566,
longitude: 2.3522,
venue_name: "Test Venue",
venue_address: "123 Test Street",
user: user
)
ticket_type = TicketType.new(
name: "Free Ticket",
description: "Valid description for the free ticket type",
price_cents: 0,
quantity: 50,
sale_start_at: Time.current,
sale_end_at: Time.current + 1.day,
event: event
)
assert ticket_type.save
assert ticket_type.free?
assert_equal 0, ticket_type.price_cents
assert_equal 0.0, ticket_type.price_euros
end
end end

View File

@@ -210,25 +210,12 @@ class StripeInvoiceServiceTest < ActiveSupport::TestCase
} }
} }
expected_service_fee_line_item = {
customer: "cus_test123",
invoice: "in_test123",
amount: 100,
currency: "eur",
description: "Frais de service - Frais de traitement de la commande",
metadata: {
item_type: "service_fee",
amount_cents: 100
}
}
mock_invoice = mock("invoice") mock_invoice = mock("invoice")
mock_invoice.stubs(:id).returns("in_test123") mock_invoice.stubs(:id).returns("in_test123")
mock_invoice.stubs(:finalize_invoice).returns(mock_invoice) mock_invoice.stubs(:finalize_invoice).returns(mock_invoice)
mock_invoice.expects(:pay) mock_invoice.expects(:pay)
Stripe::Invoice.expects(:create).returns(mock_invoice) Stripe::Invoice.expects(:create).returns(mock_invoice)
Stripe::InvoiceItem.expects(:create).with(expected_ticket_line_item) Stripe::InvoiceItem.expects(:create).with(expected_ticket_line_item) # Only for tickets, no service fee
Stripe::InvoiceItem.expects(:create).with(expected_service_fee_line_item)
result = @service.create_post_payment_invoice result = @service.create_post_payment_invoice
assert_not_nil result assert_not_nil result