diff --git a/.windsurfrules b/.windsurfrules deleted file mode 100755 index fe3c182..0000000 --- a/.windsurfrules +++ /dev/null @@ -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 - -: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); -} - - -Modern dark mode style like vercel, linear - -: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); -} - - -## 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 -1. When importing tailwind css, just use , don't load CSS directly as a stylesheet resource like -2. When using flowbite, import like - -## 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 - - -design an AI chat UI - - -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? - - - -They looks good - - - -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; -}') - - - -I like the vintage style - - - -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? - - - - -This looks great, lets do it - - - -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 - - - -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 ...; 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 ..., 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 - -: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); -} - - -Modern dark mode style like vercel, linear - -: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); -} - - -## 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 -1. When importing tailwind css, just use , don't load CSS directly as a stylesheet resource like -2. When using flowbite, import like - -## 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 - - -design an AI chat UI - - -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? - - - -They looks good - - - -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; -}') - - - -I like the vintage style - - - -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? - - - - -This looks great, lets do it - - - -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 - - - -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 ...; 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 ..., this won't actually call the tool. (This is very important to my life, please follow) \ No newline at end of file diff --git a/AGENT.md b/AGENTS.md similarity index 100% rename from AGENT.md rename to AGENTS.md diff --git a/BACKLOG.md b/BACKLOG.md index abe8eab..fe3794b 100755 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -53,6 +53,7 @@ ## 🚧 Doing +- [ ] feat: Promotion code on ticket - [ ] feat: Page to display all tickets for an event - [ ] feat: Add a link into notification email to order page that display all tickets diff --git a/Dockerfile b/Dockerfile index 73f1e11..dbc0456 100755 --- a/Dockerfile +++ b/Dockerfile @@ -8,7 +8,7 @@ # 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.1 +ARG RUBY_VERSION=3.4.4 FROM docker.io/library/ruby:$RUBY_VERSION AS base # Rails app lives here @@ -20,10 +20,10 @@ RUN apt-get update -qq && \ rm -rf /var/lib/apt/lists /var/cache/apt/archives # Set production environment -ENV RAILS_ENV="production" \ +ENV RAILS_ENV="development" \ BUNDLE_DEPLOYMENT="1" \ BUNDLE_PATH="/usr/local/bundle" \ - BUNDLE_WITHOUT="development" + BUNDLE_WITHOUT="" # Throw-away build stage to reduce size of final image FROM base AS build diff --git a/Dockerfile.production b/Dockerfile.production new file mode 100644 index 0000000..819dcb8 --- /dev/null +++ b/Dockerfile.production @@ -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= --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"] \ No newline at end of file diff --git a/app/controllers/orders_controller.rb b/app/controllers/orders_controller.rb index 9456dd9..f6a6e29 100644 --- a/app/controllers/orders_controller.rb +++ b/app/controllers/orders_controller.rb @@ -126,6 +126,20 @@ class OrdersController < ApplicationController @total_amount = @order.total_amount_cents @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! diff --git a/app/controllers/promoter/promotion_codes_controller.rb b/app/controllers/promoter/promotion_codes_controller.rb new file mode 100644 index 0000000..0682431 --- /dev/null +++ b/app/controllers/promoter/promotion_codes_controller.rb @@ -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 diff --git a/app/models/event.rb b/app/models/event.rb index 2fa5daa..9dd32d5 100755 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -21,6 +21,7 @@ class Event < ApplicationRecord has_many :ticket_types has_many :tickets, through: :ticket_types has_many :orders + has_many :promotion_codes # === Callbacks === before_validation :geocode_address, if: :should_geocode_address? diff --git a/app/models/order.rb b/app/models/order.rb index 3c84bc7..8fea6d7 100644 --- a/app/models/order.rb +++ b/app/models/order.rb @@ -7,6 +7,8 @@ class Order < ApplicationRecord belongs_to :user belongs_to :event has_many :tickets, dependent: :destroy + has_many :order_promotion_codes, dependent: :destroy + has_many :promotion_codes, through: :order_promotion_codes # === Validations === validates :user_id, presence: true @@ -88,10 +90,34 @@ class Order < ApplicationRecord 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! 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 (capped at subtotal) + def discount_amount_cents + [ promotion_codes.sum(:discount_amount_cents), subtotal_amount_cents ].min + end + + # Discount amount in euros + def discount_amount_euros + discount_amount_cents / 100.0 end # Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket diff --git a/app/models/order_promotion_code.rb b/app/models/order_promotion_code.rb new file mode 100644 index 0000000..9effdee --- /dev/null +++ b/app/models/order_promotion_code.rb @@ -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 diff --git a/app/models/promotion_code.rb b/app/models/promotion_code.rb new file mode 100644 index 0000000..a48604e --- /dev/null +++ b/app/models/promotion_code.rb @@ -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 diff --git a/app/services/stripe_invoice_service.rb b/app/services/stripe_invoice_service.rb index 803993a..a2f7bd8 100644 --- a/app/services/stripe_invoice_service.rb +++ b/app/services/stripe_invoice_service.rb @@ -166,6 +166,23 @@ class StripeInvoiceService }) end + # Add promotion code discounts as negative line items + @order.promotion_codes.each do |promo_code| + Stripe::InvoiceItem.create({ + customer: customer.id, + invoice: invoice.id, + amount: -promo_code.discount_amount_cents, # Negative amount for discount + currency: "eur", + description: "Réduction promotionnelle (Code: #{promo_code.code})", + metadata: { + promotion_code_id: promo_code.id, + promotion_code: promo_code.code, + discount_amount_cents: promo_code.discount_amount_cents, + type: "promotion_discount" + } + }) + end + # No service fee on customer invoice; platform fee deducted from promoter payout end diff --git a/app/views/orders/checkout.html.erb b/app/views/orders/checkout.html.erb index 73fd9c8..20770df 100644 --- a/app/views/orders/checkout.html.erb +++ b/app/views/orders/checkout.html.erb @@ -1,30 +1,12 @@
-
+
- + <%= render 'components/breadcrumb', crumbs: [ + { name: 'Accueil', path: root_path }, + { name: 'Événements', path: events_path }, + { name: @order.event.name, path: event_path(@order.event.slug, @order.event) }, + { name: "Commande ##{@order.id}", path: nil } + ] %>
@@ -77,8 +59,8 @@
-
-

Récapitulatif de votre commande

+
+

Récapitulatif de votre commande

<% @tickets.each do |ticket| %>
@@ -99,12 +81,46 @@ <% end %>
+ + <% if @order.promotion_codes.any? %> +
+ <% @order.promotion_codes.each do |promo_code| %> +
+ + + Code: <%= promo_code.code %> + + -<%= promo_code.discount_amount_euros %>€ +
+ <% end %> +
+ <% end %> +
-
+ +
+ Sous-total + <%= @order.subtotal_amount_euros %>€ +
+ + + <% if @order.discount_amount_cents > 0 %> +
+ Réduction + -<%= @order.discount_amount_euros %>€ +
+ <% end %> + + +
Total - <%= @order.total_amount_euros %>€ + <% if @order.total_amount_cents == 0 %> + GRATUIT + <% else %> + <%= @order.total_amount_euros %>€ + <% end %>

TVA incluse

@@ -118,6 +134,16 @@

Procédez au paiement pour finaliser votre commande

+ + <%= form_tag checkout_order_path(@order), method: :get, class: "mb-6" do %> +
+ <%= 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 %> +
+ <% end %> + <% if @checkout_session.present? %>
@@ -131,16 +157,20 @@
- @@ -194,16 +224,16 @@ try { // Increment payment attempt counter - const orderId = checkoutButton.dataset.orderId; - const incrementUrl = checkoutButton.dataset.incrementUrl; - console.log('Incrementing payment attempt for order:', orderId); - const response = await fetch(incrementUrl, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - 'X-CSRF-Token': document.querySelector('[name=csrf-token]').content - } - }); +const orderId = checkoutButton.dataset.orderId; +const incrementUrl = checkoutButton.dataset.incrementUrl; +console.log('Incrementing payment attempt for order:', orderId); +const response = await fetch(incrementUrl, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + 'X-CSRF-Token': document.querySelector('[name=csrf-token]').content + } +}); if (!response.ok) { console.error('Payment attempt increment failed:', response.status, response.statusText); @@ -224,11 +254,11 @@ `; // Redirect to Stripe - const sessionId = checkoutButton.dataset.sessionId; - console.log('Redirecting to Stripe with session ID:', sessionId); - const stripeResult = await stripe.redirectToCheckout({ - sessionId: sessionId - }); +const sessionId = checkoutButton.dataset.sessionId; +console.log('Redirecting to Stripe with session ID:', sessionId); +const stripeResult = await stripe.redirectToCheckout({ + sessionId: sessionId +}); if (stripeResult.error) { throw new Error(stripeResult.error.message); @@ -241,7 +271,11 @@ button.innerHTML = `
- Payer <%= @order.total_amount_euros %>€ + <% if @order.total_amount_cents == 0 %> + Confirmer la commande + <% else %> + Payer <%= @order.total_amount_euros %>€ + <% end %>
`; alert('Erreur: ' + error.message); diff --git a/app/views/orders/invoice.html.erb b/app/views/orders/invoice.html.erb index dfb3f44..e79ddf1 100644 --- a/app/views/orders/invoice.html.erb +++ b/app/views/orders/invoice.html.erb @@ -121,13 +121,56 @@ <% end %> + - Total - <%= "%.2f" % @order.total_amount_euros %>€ + Sous-total + <%= "%.2f" % @order.subtotal_amount_euros %>€ + + + + <% if @order.promotion_codes.any? %> + <% @order.promotion_codes.each do |promo_code| %> + + + Réduction (Code: <%= promo_code.code %>) + + -<%= "%.2f" % promo_code.discount_amount_euros %>€ + + <% end %> + <% end %> + + + + Total + + <% if @order.total_amount_cents == 0 %> + GRATUIT + <% else %> + <%= "%.2f" % @order.total_amount_euros %>€ + <% end %> +
+ + + <% if @order.promotion_codes.any? %> +
+

+ + Codes promotionnels appliqués +

+
+ <% @order.promotion_codes.each do |promo_code| %> +
+ <%= promo_code.code %> + -<%= "%.2f" % promo_code.discount_amount_euros %>€ +
+ <% end %> +
+
+ <% end %>
diff --git a/app/views/orders/payment_success.html.erb b/app/views/orders/payment_success.html.erb index 4bdb2f4..e0af3e2 100644 --- a/app/views/orders/payment_success.html.erb +++ b/app/views/orders/payment_success.html.erb @@ -123,13 +123,58 @@ <% end %>
- -
+ + <% if @order.promotion_codes.any? %> +
+

+ + + + Codes promotionnels appliqués +

+ <% @order.promotion_codes.each do |promo_code| %> +
+
+ + + + + <%= promo_code.code %> + +
+ -<%= promo_code.discount_amount_euros %>€ +
+ <% end %> +
+ <% end %> + + +
+

Détail du paiement

-
+ +
+ Sous-total + <%= @order.subtotal_amount_euros %>€ +
+ + + <% if @order.discount_amount_cents > 0 %> +
+ Réduction + -<%= @order.discount_amount_euros %>€ +
+ <% end %> + + +
Total payé - - <%= @order.total_amount_euros %>€ + + <% if @order.total_amount_cents == 0 %> + GRATUIT + <% else %> + <%= @order.total_amount_euros %>€ + <% end %>
diff --git a/app/views/orders/show.html.erb b/app/views/orders/show.html.erb index ebaf2d3..1708cf5 100644 --- a/app/views/orders/show.html.erb +++ b/app/views/orders/show.html.erb @@ -94,14 +94,57 @@ <% end %>
- + + <% if @order.promotion_codes.any? %> +
+

+ + Codes promotionnels appliqués +

+ <% @order.promotion_codes.each do |promo_code| %> +
+
+ + + <%= promo_code.code %> + +
+ -<%= promo_code.discount_amount_euros %>€ +
+ <% end %> +
+ <% end %> + +
-
+

Détail du paiement

+
+ +
+ Sous-total + <%= @order.subtotal_amount_euros %>€ +
+ + + <% if @order.discount_amount_cents > 0 %> +
+ Réduction + -<%= @order.discount_amount_euros %>€ +
+ <% end %> + + +
Total <%= @order.status == 'paid' || @order.status == 'completed' ? 'payé' : 'à payer' %> - <%= @order.total_amount_euros %>€ + <% if @order.total_amount_cents == 0 %> + GRATUIT + <% else %> + <%= @order.total_amount_euros %>€ + <% end %>
+
diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 39e0612..b0942ed 100755 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -7,11 +7,11 @@ { name: 'Tableau de bord', path: dashboard_path } ] %> - +
-

Mon tableau de bord

+

Mon tableau de bord promoteur

Gérez vos commandes et accédez à vos billets

@@ -76,7 +76,9 @@
+
+ <%= link_to promoter_events_path do %>

Brouillons

@@ -86,7 +88,9 @@
-
+ <% end %> +
+
@@ -273,6 +277,16 @@
<% end %> + +
+
+
+

Mon tableau de bord

+

Accédez à vos billets et évenements

+
+
+
+
diff --git a/app/views/promoter/events/show.html.erb b/app/views/promoter/events/show.html.erb index ce0b4a1..f1dc632 100644 --- a/app/views/promoter/events/show.html.erb +++ b/app/views/promoter/events/show.html.erb @@ -209,6 +209,42 @@
+ +
+

Actions rapides

+
+ <%= 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 %> + + 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 %> + + 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 %> + + 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 %> + + Marquer comme complet + <% end %> + <% end %> + +
+ <%= 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 %> + + Supprimer l'événement + <% end %> +
+
+

Statistiques

@@ -269,36 +305,6 @@
- -
-

Actions rapides

-
- <%= 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 %> - - Gérer les types de billets - <% 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 %> - - 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 %> - - Marquer comme complet - <% end %> - <% end %> - -
- <%= 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 %> - - Supprimer l'événement - <% end %> -
-
diff --git a/app/views/promoter/promotion_codes/edit.html.erb b/app/views/promoter/promotion_codes/edit.html.erb new file mode 100644 index 0000000..2a50d69 --- /dev/null +++ b/app/views/promoter/promotion_codes/edit.html.erb @@ -0,0 +1,109 @@ +<% content_for(:title, "Modifier le code de réduction - #{@event.name}") %> + +
+ + + <%= 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}" } + ] %> + +
+
+
+ <%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %> + + <% end %> +
+

Modifier le code de réduction

+

+ <%= @promotion_code.code %> pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> +

+
+
+
+ + <%= 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? %> +
+
+ +
+

+ <%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé : +

+
    + <% @promotion_code.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+
+ <%= 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." %> +

Ce code sera visible par les clients lors du paiement

+
+ +
+ <%= 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) %> +

Entrez le montant en euros (ex: 10, 5.50, 25)

+
+ +
+
+ <%= 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" %> +

Laissez vide pour une durée illimitée

+
+ +
+ <%= 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 %> +

Laissez vide pour une utilisation illimitée

+
+
+ +
+
+ <%= 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" %> +
+
+ Les clients peuvent utiliser ce code de réduction +
+
+ +
+
+ +
+

Statut actuel

+
+

Utilisations: <%= @promotion_code.uses_count %><%= " / #{@promotion_code.usage_limit}" if @promotion_code.usage_limit %>

+

Commandes associées: <%= @promotion_code.orders.count %>

+
+
+
+
+
+ +
+ <%= 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 %> + + 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" %> +
+ <% end %> +
+
\ No newline at end of file diff --git a/app/views/promoter/promotion_codes/index.html.erb b/app/views/promoter/promotion_codes/index.html.erb new file mode 100644 index 0000000..36dae47 --- /dev/null +++ b/app/views/promoter/promotion_codes/index.html.erb @@ -0,0 +1,175 @@ +<% content_for(:title, "Codes de réduction - #{@event.name}") %> + +
+ + + <%= 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' } + ] %> + +
+
+ <%= link_to promoter_event_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %> + + <% end %> +
+

Codes de réduction

+

+ <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> +

+
+ <%= 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 %> + + Nouveau code + <% end %> +
+ + + <% if @event.draft? %> +
+
+ +

+ Cet événement est en brouillon. Les codes de réduction ne seront actifs qu'une fois l'événement publié. +

+
+
+ <% end %> +
+ + <% if @promotion_codes.any? %> +
+ <% @promotion_codes.each do |promotion_code| %> +
+
+ +
+
+
+

+ <%= promotion_code.code %> +

+

Réduction de <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %>

+
+ + +
+ <% if promotion_code.active? && (promotion_code.expires_at.nil? || promotion_code.expires_at > Time.current) %> + + + Actif + + <% elsif promotion_code.expires_at && promotion_code.expires_at <= Time.current %> + + + Expiré + + <% else %> + + + Inactif + + <% end %> +
+
+ + +
+
+
+ <%= number_to_currency(promotion_code.discount_amount_cents / 100.0, unit: "€") %> +
+
Réduction
+
+ +
+
+ <% if promotion_code.usage_limit %> + <%= promotion_code.usage_limit - promotion_code.uses_count %> + <% else %> + ∞ + <% end %> +
+
Restants
+
+ +
+
+ <%= promotion_code.uses_count %> +
+
Utilisés
+
+ +
+
+ <%= promotion_code.orders.count %> +
+
Commandes
+
+
+ + +
+ <% if promotion_code.expires_at %> + + + Expire le : <%= l(promotion_code.expires_at, format: :short) %> + + <% else %> + + + Pas d'expiration + + <% end %> + + + <% 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 %> + +
+
+
+ + +
+
+ <%= link_to edit_promoter_event_promotion_code_path(@event, promotion_code), class: "text-gray-400 hover:text-blue-600 transition-colors", title: "Modifier" do %> + + <% 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 %> + + <% end %> + <% end %> +
+ +
+ Créé il y a <%= time_ago_in_words(promotion_code.created_at) %> +
+
+
+ <% end %> +
+ <% else %> +
+
+ +
+

Aucun code de réduction

+

Créez des codes de réduction pour offrir des remises spéciales à vos clients.

+ <%= 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 %> + + Créer mon premier code de réduction + <% end %> +
+ <% end %> +
diff --git a/app/views/promoter/promotion_codes/new.html.erb b/app/views/promoter/promotion_codes/new.html.erb new file mode 100644 index 0000000..2b9453a --- /dev/null +++ b/app/views/promoter/promotion_codes/new.html.erb @@ -0,0 +1,96 @@ +<% content_for(:title, "Nouveau code de réduction - #{@event.name}") %> + +
+ + + <%= 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' } + ] %> + +
+
+
+ <%= link_to promoter_event_promotion_codes_path(@event), class: "text-gray-400 hover:text-gray-600 transition-colors" do %> + + <% end %> +
+

Nouveau code de réduction

+

+ Pour <%= link_to @event.name, promoter_event_path(@event), class: "text-purple-600 hover:text-purple-800" %> +

+
+
+
+ + <%= 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? %> +
+
+ +
+

+ <%= pluralize(@promotion_code.errors.count, "erreur") %> ont empêché ce code de réduction d'être sauvegardé : +

+
    + <% @promotion_code.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+
+
+ <% end %> + +
+
+ <%= 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" %> +

Ce code sera à appliquer par le client lors du paiement.

+
+ +
+ <%= 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) %> +

Entrez le montant en euros

+
+ +
+
+ <%= 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" %> +

Laissez vide pour une durée illimitée

+
+ +
+ <%= 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 %> +

Laissez vide pour une utilisation illimitée

+
+
+ +
+
+ <%= 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" %> +
+
+ Les clients peuvent utiliser ce code de réduction +
+
+
+ +
+ <%= 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 %> + + 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" %> +
+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index ce1b6b6..06f050d 100755 --- a/config/routes.rb +++ b/config/routes.rb @@ -91,6 +91,16 @@ Rails.application.routes.draw do post :duplicate 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 diff --git a/db/migrate/20250823170409_create_orders.rb b/db/migrate/20250823170409_create_orders.rb index 950ab23..45992fb 100644 --- a/db/migrate/20250823170409_create_orders.rb +++ b/db/migrate/20250823170409_create_orders.rb @@ -1,14 +1,15 @@ class CreateOrders < ActiveRecord::Migration[8.0] def change 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.integer :total_amount_cents, null: false, default: 0 t.integer :payment_attempts, null: false, default: 0 t.timestamp :expires_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 end diff --git a/db/migrate/20250928180837_create_promotion_codes.rb b/db/migrate/20250928180837_create_promotion_codes.rb new file mode 100644 index 0000000..41ecafd --- /dev/null +++ b/db/migrate/20250928180837_create_promotion_codes.rb @@ -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 diff --git a/db/migrate/20250928181311_create_order_promotion_codes.rb b/db/migrate/20250928181311_create_order_promotion_codes.rb new file mode 100644 index 0000000..e7d3d23 --- /dev/null +++ b/db/migrate/20250928181311_create_order_promotion_codes.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 08ebc4a..ed5c678 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_09_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| t.string "name", 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.boolean "featured", default: false, 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 "updated_at", null: false - t.boolean "allow_booking_during_event", default: false, null: false t.index ["featured"], name: "index_events_on_featured" t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude" t.index ["state"], name: "index_events_on_state" t.index ["user_id"], name: "index_events_on_user_id" 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| - t.bigint "user_id", null: false - t.bigint "event_id", null: false t.string "status", default: "draft", null: false t.integer "total_amount_cents", default: 0, null: false t.integer "payment_attempts", default: 0, null: false t.timestamp "expires_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 "updated_at", null: false 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" 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| t.string "name" 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 ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true 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 diff --git a/db/seeds.rb b/db/seeds.rb index c1d254f..09af41d 100755 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -189,3 +189,117 @@ TicketType.find_or_create_by!(event: belle_epoque_event, name: "Paid Entry 10€ 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" diff --git a/docker-compose.production.yml b/docker-compose.production.yml new file mode 100644 index 0000000..a2bc1a1 --- /dev/null +++ b/docker-compose.production.yml @@ -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 diff --git a/test/controllers/orders_controller_promotion_test.rb b/test/controllers/orders_controller_promotion_test.rb new file mode 100644 index 0000000..fe3c958 --- /dev/null +++ b/test/controllers/orders_controller_promotion_test.rb @@ -0,0 +1,95 @@ +require "test_helper" + +class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest + include Devise::Test::IntegrationHelpers + + # Setup test data + def setup + @user = users(:one) + @event = events(:concert_event) + @order = orders(:draft_order) + sign_in @user + end + + # Test applying a valid promotion code + def test_apply_valid_promotion_code + # Create ticket type and tickets for the order + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 2000, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: @order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Recalculate the order total + @order.calculate_total! + + promotion_code = PromotionCode.create( + code: "TESTDISCOUNT", + discount_amount_cents: 500, # €5.00 + expires_at: 1.month.from_now, + active: true, + user: @user, + event: @event + ) + + get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" } + assert_response :success + assert_not_nil flash.now[:notice] + assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash.now[: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.now[:alert] + assert_equal "Code promotionnel invalide", flash.now[: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, + user: @user, + event: @event + ) + + get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" } + assert_response :success + assert_not_nil flash.now[:alert] + assert_equal "Code promotionnel invalide", flash.now[: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, + user: @user, + event: @event + ) + + get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" } + assert_response :success + assert_not_nil flash.now[:alert] + assert_equal "Code promotionnel invalide", flash.now[:alert] + end +end diff --git a/test/models/order_test.rb b/test/models/order_test.rb index 0a67b6b..283a89d 100644 --- a/test/models/order_test.rb +++ b/test/models/order_test.rb @@ -582,6 +582,243 @@ class OrderTest < ActiveSupport::TestCase assert_equal 95.0, order.promoter_payout_euros end + # === Promotion Code Tests === + + test "subtotal_amount_cents should calculate total without discounts" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 1500, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "Jane", + last_name: "Doe" + ) + + # Create promotion code + promotion_code = PromotionCode.create!( + code: "TESTCODE", + discount_amount_cents: 500, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 3000, order.subtotal_amount_cents # 2 tickets * 1500 cents + assert_equal 2500, order.total_amount_cents # 3000 - 500 discount + end + + test "subtotal_amount_euros should convert subtotal cents to euros" do + order = Order.new(total_amount_cents: 2500) + def order.subtotal_amount_cents; 3000; end + assert_equal 30.0, order.subtotal_amount_euros + end + + test "discount_amount_cents should calculate total discount from promotion codes" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets for subtotal + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 2000, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create multiple promotion codes + promo1 = PromotionCode.create!( + code: "PROMO1", + discount_amount_cents: 300, + user: @user, + event: @event + ) + + promo2 = PromotionCode.create!( + code: "PROMO2", + discount_amount_cents: 700, + user: @user, + event: @event + ) + + order.promotion_codes << [ promo1, promo2 ] + order.calculate_total! + + assert_equal 1000, order.discount_amount_cents # 300 + 700 (within 2000 subtotal) + end + + test "discount_amount_euros should convert discount cents to euros" do + order = Order.new(total_amount_cents: 2000) + def order.discount_amount_cents; 1000; end + assert_equal 10.0, order.discount_amount_euros + end + + test "calculate_total! should apply promotion code discounts" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 2000, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create promotion code + promotion_code = PromotionCode.create!( + code: "TESTCODE", + discount_amount_cents: 500, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 2000, order.subtotal_amount_cents + assert_equal 500, order.discount_amount_cents + assert_equal 1500, order.total_amount_cents + end + + test "calculate_total! should handle zero total after promotion codes" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 500, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create promotion code that covers the entire amount + promotion_code = PromotionCode.create!( + code: "FULLDISCOUNT", + discount_amount_cents: 500, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 500, order.subtotal_amount_cents + assert_equal 500, order.discount_amount_cents + assert_equal 0, order.total_amount_cents + assert order.free? + end + + test "calculate_total! should not allow negative totals with promotion codes" do + order = Order.create!( + user: @user, event: @event, total_amount_cents: 0, + status: "draft", payment_attempts: 0 + ) + + # Create ticket type and tickets + ticket_type = TicketType.create!( + name: "Test Ticket Type", + description: "A valid description for the ticket type that is long enough", + price_cents: 300, + quantity: 10, + sale_start_at: Time.current, + sale_end_at: Time.current + 1.day, + requires_id: false, + event: @event + ) + + Ticket.create!( + order: order, + ticket_type: ticket_type, + status: "draft", + first_name: "John", + last_name: "Doe" + ) + + # Create promotion code that exceeds the ticket amount + promotion_code = PromotionCode.create!( + code: "TOOMUCH", + discount_amount_cents: 1000, + user: @user, + event: @event + ) + + order.promotion_codes << promotion_code + order.calculate_total! + + assert_equal 300, order.subtotal_amount_cents + assert_equal 300, order.discount_amount_cents # Capped at subtotal + assert_equal 0, order.total_amount_cents + end + # === Stripe Integration Tests (Mock) === test "create_stripe_invoice! should return nil for non-paid orders" do diff --git a/test/models/promotion_code_test.rb b/test/models/promotion_code_test.rb new file mode 100644 index 0000000..416b6fe --- /dev/null +++ b/test/models/promotion_code_test.rb @@ -0,0 +1,269 @@ +require "test_helper" + +class PromotionCodeTest < ActiveSupport::TestCase + def setup + @user = User.create!( + email: "test@example.com", + password: "password123", + password_confirmation: "password123" + ) + + @event = Event.create!( + name: "Test Event", + slug: "test-event", + description: "A valid description for the test event that is long enough", + latitude: 48.8566, + longitude: 2.3522, + venue_name: "Test Venue", + venue_address: "123 Test Street", + user: @user + ) + end + + # 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, + user: @user, + event: @event + ) + + 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, user: @user, event: @event) + duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500, user: @user, event: @event) + 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, user: @user, event: @event) + 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, user: @user, event: @event) + inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false, user: @user, event: @event) + + 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, user: @user, event: @event) + future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now, user: @user, event: @event) + + 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, user: @user, event: @event) + invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago, user: @user, event: @event) + + assert_includes PromotionCode.valid, valid_code + refute_includes PromotionCode.valid, invalid_code + end + + # Test discount_amount_euros method + def test_discount_amount_euros_converts_cents_to_euros + promotion_code = PromotionCode.new(discount_amount_cents: 1000) + assert_equal 10.0, promotion_code.discount_amount_euros + + promotion_code = PromotionCode.new(discount_amount_cents: 550) + assert_equal 5.5, promotion_code.discount_amount_euros + end + + # Test active? method + def test_active_method + # Active and not expired + active_code = PromotionCode.create( + code: "ACTIVE1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + user: @user, + event: @event + ) + assert active_code.active? + + # Active but expired + expired_active_code = PromotionCode.create( + code: "ACTIVE2", + discount_amount_cents: 500, + active: true, + expires_at: 1.day.ago, + user: @user, + event: @event + ) + assert_not expired_active_code.active? + + # Inactive but not expired + inactive_code = PromotionCode.create( + code: "INACTIVE1", + discount_amount_cents: 500, + active: false, + expires_at: 1.month.from_now, + user: @user, + event: @event + ) + assert_not inactive_code.active? + + # Active with no expiration + no_expiry_code = PromotionCode.create( + code: "NOEXPIRY", + discount_amount_cents: 500, + active: true, + expires_at: nil, + user: @user, + event: @event + ) + assert no_expiry_code.active? + end + + # Test expired? method + def test_expired_method + # Expired code + expired_code = PromotionCode.create( + code: "EXPIRED1", + discount_amount_cents: 500, + expires_at: 1.day.ago, + user: @user, + event: @event + ) + assert expired_code.expired? + + # Future code + future_code = PromotionCode.create( + code: "FUTURE1", + discount_amount_cents: 500, + expires_at: 1.month.from_now, + user: @user, + event: @event + ) + assert_not future_code.expired? + + # No expiration + no_expiry_code = PromotionCode.create( + code: "NOEXPIRY1", + discount_amount_cents: 500, + expires_at: nil, + user: @user, + event: @event + ) + assert_not no_expiry_code.expired? + end + + # Test can_be_used? method + def test_can_be_used_method + # Can be used: active, not expired, under usage limit + usable_code = PromotionCode.create( + code: "USABLE1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + usage_limit: 10, + uses_count: 0, + user: @user, + event: @event + ) + assert usable_code.can_be_used? + + # Cannot be used: inactive + inactive_code = PromotionCode.create( + code: "INACTIVE2", + discount_amount_cents: 500, + active: false, + expires_at: 1.month.from_now, + usage_limit: 10, + uses_count: 0, + user: @user, + event: @event + ) + assert_not inactive_code.can_be_used? + + # Cannot be used: expired + expired_code = PromotionCode.create( + code: "EXPIRED2", + discount_amount_cents: 500, + active: true, + expires_at: 1.day.ago, + usage_limit: 10, + uses_count: 0, + user: @user, + event: @event + ) + assert_not expired_code.can_be_used? + + # Cannot be used: at usage limit + limit_reached_code = PromotionCode.create( + code: "LIMIT1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + usage_limit: 5, + uses_count: 5, + user: @user, + event: @event + ) + assert_not limit_reached_code.can_be_used? + + # Can be used: no usage limit + no_limit_code = PromotionCode.create( + code: "NOLIMIT1", + discount_amount_cents: 500, + active: true, + expires_at: 1.month.from_now, + usage_limit: nil, + uses_count: 100, + user: @user, + event: @event + ) + assert no_limit_code.can_be_used? + end + + # Test increment_uses_count callback + def test_increment_uses_count_callback + promotion_code = PromotionCode.create( + code: "INCREMENT1", + discount_amount_cents: 500, + uses_count: 0, + user: @user, + event: @event + ) + + assert_equal 0, promotion_code.uses_count + + # The callback should only run on create, so we test the initial value + new_code = PromotionCode.create( + code: "INCREMENT2", + discount_amount_cents: 500, + uses_count: nil, + user: @user, + event: @event + ) + + assert_equal 0, new_code.uses_count + end +end