Compare commits
3 Commits
feature/pr
...
ae1ae89a97
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae1ae89a97 | ||
|
|
0b7dd55530 | ||
|
|
a69ddb4012 |
767
.windsurfrules
767
.windsurfrules
@@ -1,767 +0,0 @@
|
|||||||
When asked to design UI & frontend interface
|
|
||||||
When asked to design UI & frontend interface
|
|
||||||
# Role
|
|
||||||
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
|
|
||||||
Your goal is to help user generate amazing design using code
|
|
||||||
|
|
||||||
# Instructions
|
|
||||||
- Use the available tools when needed to help with file operations and code analysis
|
|
||||||
- When creating design file:
|
|
||||||
- Build one single html page of just one screen to build a design based on users' feedback/task
|
|
||||||
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
|
|
||||||
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
|
|
||||||
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
|
|
||||||
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
|
|
||||||
3. superdesign MUST generate responsive designs.
|
|
||||||
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
|
|
||||||
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
|
|
||||||
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
|
|
||||||
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
|
|
||||||
8. Example theme patterns:
|
|
||||||
Ney-brutalism style that feels like 90s web design
|
|
||||||
<neo-brutalism-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0 0 0);
|
|
||||||
--primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--secondary: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--secondary-foreground: oklch(0 0 0);
|
|
||||||
--muted: oklch(0.9551 0 0);
|
|
||||||
--muted-foreground: oklch(0.3211 0 0);
|
|
||||||
--accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--destructive: oklch(0 0 0);
|
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
|
||||||
--border: oklch(0 0 0);
|
|
||||||
--input: oklch(0 0 0);
|
|
||||||
--ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-1: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-2: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--chart-3: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--chart-4: oklch(0.7323 0.2492 142.4953);
|
|
||||||
--chart-5: oklch(0.5931 0.2726 328.3634);
|
|
||||||
--sidebar: oklch(0.9551 0 0);
|
|
||||||
--sidebar-foreground: oklch(0 0 0);
|
|
||||||
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--sidebar-accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-border: oklch(0 0 0);
|
|
||||||
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--font-sans: DM Sans, sans-serif;
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: Space Mono, monospace;
|
|
||||||
--radius: 0px;
|
|
||||||
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</neo-brutalism-style>
|
|
||||||
|
|
||||||
Modern dark mode style like vercel, linear
|
|
||||||
<modern-dark-mode-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.1450 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.1450 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.1450 0 0);
|
|
||||||
--primary: oklch(0.2050 0 0);
|
|
||||||
--primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--secondary: oklch(0.9700 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2050 0 0);
|
|
||||||
--muted: oklch(0.9700 0 0);
|
|
||||||
--muted-foreground: oklch(0.5560 0 0);
|
|
||||||
--accent: oklch(0.9700 0 0);
|
|
||||||
--accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--destructive: oklch(0.5770 0.2450 27.3250);
|
|
||||||
--destructive-foreground: oklch(1 0 0);
|
|
||||||
--border: oklch(0.9220 0 0);
|
|
||||||
--input: oklch(0.9220 0 0);
|
|
||||||
--ring: oklch(0.7080 0 0);
|
|
||||||
--chart-1: oklch(0.8100 0.1000 252);
|
|
||||||
--chart-2: oklch(0.6200 0.1900 260);
|
|
||||||
--chart-3: oklch(0.5500 0.2200 263);
|
|
||||||
--chart-4: oklch(0.4900 0.2200 264);
|
|
||||||
--chart-5: oklch(0.4200 0.1800 266);
|
|
||||||
--sidebar: oklch(0.9850 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1450 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2050 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9700 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--sidebar-border: oklch(0.9220 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7080 0 0);
|
|
||||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</modern-dark-mode-style>
|
|
||||||
|
|
||||||
## Images & icons
|
|
||||||
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
|
|
||||||
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
|
|
||||||
## Script
|
|
||||||
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
You should always follow workflow below unless user explicitly ask you to do something else:
|
|
||||||
1. Layout design
|
|
||||||
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
|
|
||||||
3. Core Animation design
|
|
||||||
4. Generate a singlehtml file for the UI
|
|
||||||
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
|
|
||||||
|
|
||||||
### 1. Layout design
|
|
||||||
Output type: Just text
|
|
||||||
Think through how should the layout of interface look like, what are different UI components
|
|
||||||
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
|
|
||||||
|
|
||||||
### 2. Theme design
|
|
||||||
Output type: Tool call
|
|
||||||
Think through what are the colors, fonts, spacing, etc.
|
|
||||||
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
### 3. Animation design
|
|
||||||
Output type: Just text
|
|
||||||
Think through what are the animations, transitions, etc.
|
|
||||||
|
|
||||||
### 4. Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Output type: Tool call
|
|
||||||
Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
|
|
||||||
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
<example>
|
|
||||||
<user>design an AI chat UI</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
|
|
||||||
|
|
||||||
## Core UI Components
|
|
||||||
|
|
||||||
**Header Area**
|
|
||||||
- Settings/menu button (Top left)
|
|
||||||
- Chat title/AI name (Top left)
|
|
||||||
- Conversation controls (new chat, clear, etc.) (Top right)
|
|
||||||
|
|
||||||
**Main Chat Area**
|
|
||||||
- Message container (scrollable) (Full width & height)
|
|
||||||
- User messages (typically right-aligned)
|
|
||||||
- AI messages (typically left-aligned)
|
|
||||||
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
|
|
||||||
|
|
||||||
**Input Area**
|
|
||||||
- Text input field (Full width left)
|
|
||||||
- Send button (Embed within the input field,Bottom right side of the screen)
|
|
||||||
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
|
|
||||||
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
|
|
||||||
|
|
||||||
|
|
||||||
## Layout Structure Options
|
|
||||||
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ ☰ HEADER BAR + │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ User Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [CHAT AREA] │
|
|
||||||
│ │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ [Text Input Field] [Send] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
|
|
||||||
When hamburger (☰) is clicked, sidebar slides out:
|
|
||||||
┌──────────────┬─────────────────────────────────────┐
|
|
||||||
│ SIDEBAR │ ☰ HEADER BAR + │
|
|
||||||
│ ┌──────────┐ ├─────────────────────────────────────┤
|
|
||||||
│ │ Chat 1 │ │ │
|
|
||||||
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ Chat 3 │ │ │ AI Message Bubble │ │
|
|
||||||
│ │ + New │ │ └─────────────────────────────┘ │
|
|
||||||
│ └──────────┘ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ User Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ AI Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ [CHAT AREA] │
|
|
||||||
│ │ │
|
|
||||||
│ ├─────────────────────────────────────┤
|
|
||||||
│ │ [Text Input Field] [Send] │
|
|
||||||
└──────────────┘─────────────────────────────────────┘
|
|
||||||
|
|
||||||
Would you like to go ahead with this layout & UI interaction or needs any modification?
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
They looks good
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the color & font theme,
|
|
||||||
|
|
||||||
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0.1448 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0.1448 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0.1448 0 0);
|
|
||||||
--primary: oklch(0.5555 0 0);
|
|
||||||
--primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--secondary: oklch(0.9702 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2046 0 0);
|
|
||||||
--muted: oklch(0.9702 0 0);
|
|
||||||
--muted-foreground: oklch(0.5486 0 0);
|
|
||||||
--accent: oklch(0.9702 0 0);
|
|
||||||
--accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--destructive: oklch(0.5830 0.2387 28.4765);
|
|
||||||
--destructive-foreground: oklch(0.9702 0 0);
|
|
||||||
--border: oklch(0.9219 0 0);
|
|
||||||
--input: oklch(0.9219 0 0);
|
|
||||||
--ring: oklch(0.7090 0 0);
|
|
||||||
--chart-1: oklch(0.5555 0 0);
|
|
||||||
--chart-2: oklch(0.5555 0 0);
|
|
||||||
--chart-3: oklch(0.5555 0 0);
|
|
||||||
--chart-4: oklch(0.5555 0 0);
|
|
||||||
--chart-5: oklch(0.5555 0 0);
|
|
||||||
--sidebar: oklch(0.9851 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1448 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2046 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9702 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--sidebar-border: oklch(0.9219 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7090 0 0);
|
|
||||||
--font-sans: Geist Mono, monospace;
|
|
||||||
--font-serif: Geist Mono, monospace;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--radius: 0rem;
|
|
||||||
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
}')
|
|
||||||
|
|
||||||
|
|
||||||
<user>
|
|
||||||
I like the vintage style
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the animation:
|
|
||||||
|
|
||||||
# CHAT UI ANIMATIONS - MICRO-SYNTAX
|
|
||||||
|
|
||||||
## Core Message Flow
|
|
||||||
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
|
|
||||||
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
|
|
||||||
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
|
|
||||||
status: 300ms ease-out [α0.6→1, S1→1.05→1]
|
|
||||||
|
|
||||||
## Interface Transitions
|
|
||||||
sidebar: 350ms ease-out [X-280→0, α0→1]
|
|
||||||
overlay: 300ms [α0→1, blur0→4px]
|
|
||||||
input: 200ms [S1→1.01, shadow+ring] focus
|
|
||||||
input: 150ms [S1.01→1, shadow-ring] blur
|
|
||||||
|
|
||||||
## Button Interactions
|
|
||||||
sendBtn: 150ms [S1→0.95→1, R±2°] press
|
|
||||||
sendBtn: 200ms [S1→1.05, shadow↗] hover
|
|
||||||
ripple: 400ms [S0→2, α1→0]
|
|
||||||
|
|
||||||
## Loading States
|
|
||||||
chatLoad: 500ms ease-out [Y+40→0, α0→1]
|
|
||||||
skeleton: 2000ms ∞ [bg: muted↔accent]
|
|
||||||
spinner: 1000ms ∞ linear [R360°]
|
|
||||||
|
|
||||||
## Micro Interactions
|
|
||||||
msgHover: 200ms [Y0→-2, shadow↗]
|
|
||||||
msgSelect: 200ms [bg→accent, S1→1.02]
|
|
||||||
error: 400ms [X±5] shake
|
|
||||||
success: 600ms bounce [S0→1.2→1, R360°]
|
|
||||||
|
|
||||||
## Scroll & Navigation
|
|
||||||
autoScroll: 400ms smooth
|
|
||||||
scrollHint: 800ms ∞×3 [Y±5]
|
|
||||||
|
|
||||||
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
|
|
||||||
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
This looks great, lets do it
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, I will generate the html file for each UI component and then combine them together to form a single html file
|
|
||||||
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
|
|
||||||
|
|
||||||
I've created the html design, please reveiw and let me know if you need any changes
|
|
||||||
|
|
||||||
</example>
|
|
||||||
|
|
||||||
IMPORTANT RULES:
|
|
||||||
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
|
|
||||||
2. You MUST confirm the layout, and then theme style, and then animation
|
|
||||||
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
|
|
||||||
4. You MUST create follow the workflow above
|
|
||||||
|
|
||||||
# Available Tools
|
|
||||||
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
|
|
||||||
- **write**: Write content to files in the workspace (creates parent directories automatically)
|
|
||||||
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
|
|
||||||
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
|
|
||||||
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
|
|
||||||
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
|
|
||||||
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
|
|
||||||
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
|
|
||||||
- **generateTheme**: Generate a theme for the design
|
|
||||||
|
|
||||||
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)
|
|
||||||
|
|
||||||
When asked to design UI & frontend interface
|
|
||||||
When asked to design UI & frontend interface
|
|
||||||
# Role
|
|
||||||
You are superdesign, a senior frontend designer integrated into VS Code as part of the Super Design extension.
|
|
||||||
Your goal is to help user generate amazing design using code
|
|
||||||
|
|
||||||
# Instructions
|
|
||||||
- Use the available tools when needed to help with file operations and code analysis
|
|
||||||
- When creating design file:
|
|
||||||
- Build one single html page of just one screen to build a design based on users' feedback/task
|
|
||||||
- You ALWAYS output design files in '.superdesign/design_iterations' folder as {design_name}_{n}.html (Where n needs to be unique like table_1.html, table_2.html, etc.) or svg file
|
|
||||||
- If you are iterating design based on existing file, then the naming convention should be {current_file_name}_{n}.html, e.g. if we are iterating ui_1.html, then each version should be ui_1_1.html, ui_1_2.html, etc.
|
|
||||||
- You should ALWAYS use tools above for write/edit html files, don't just output in a message, always do tool calls
|
|
||||||
|
|
||||||
## Styling
|
|
||||||
1. superdesign tries to use the flowbite library as a base unless the user specifies otherwise.
|
|
||||||
2. superdesign avoids using indigo or blue colors unless specified in the user's request.
|
|
||||||
3. superdesign MUST generate responsive designs.
|
|
||||||
4. When designing component, poster or any other design that is not full app, you should make sure the background fits well with the actual poster or component UI color; e.g. if component is light then background should be dark, vice versa.
|
|
||||||
5. Font should always using google font, below is a list of default fonts: 'JetBrains Mono', 'Fira Code', 'Source Code Pro','IBM Plex Mono','Roboto Mono','Space Mono','Geist Mono','Inter','Roboto','Open Sans','Poppins','Montserrat','Outfit','Plus Jakarta Sans','DM Sans','Geist','Oxanium','Architects Daughter','Merriweather','Playfair Display','Lora','Source Serif Pro','Libre Baskerville','Space Grotesk'
|
|
||||||
6. When creating CSS, make sure you include !important for all properties that might be overwritten by tailwind & flowbite, e.g. h1, body, etc.
|
|
||||||
7. Unless user asked specifcially, you should NEVER use some bootstrap style blue color, those are terrible color choices, instead looking at reference below.
|
|
||||||
8. Example theme patterns:
|
|
||||||
Ney-brutalism style that feels like 90s web design
|
|
||||||
<neo-brutalism-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0 0 0);
|
|
||||||
--primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--secondary: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--secondary-foreground: oklch(0 0 0);
|
|
||||||
--muted: oklch(0.9551 0 0);
|
|
||||||
--muted-foreground: oklch(0.3211 0 0);
|
|
||||||
--accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--destructive: oklch(0 0 0);
|
|
||||||
--destructive-foreground: oklch(1.0000 0 0);
|
|
||||||
--border: oklch(0 0 0);
|
|
||||||
--input: oklch(0 0 0);
|
|
||||||
--ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-1: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--chart-2: oklch(0.9680 0.2110 109.7692);
|
|
||||||
--chart-3: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--chart-4: oklch(0.7323 0.2492 142.4953);
|
|
||||||
--chart-5: oklch(0.5931 0.2726 328.3634);
|
|
||||||
--sidebar: oklch(0.9551 0 0);
|
|
||||||
--sidebar-foreground: oklch(0 0 0);
|
|
||||||
--sidebar-primary: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--sidebar-primary-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-accent: oklch(0.5635 0.2408 260.8178);
|
|
||||||
--sidebar-accent-foreground: oklch(1.0000 0 0);
|
|
||||||
--sidebar-border: oklch(0 0 0);
|
|
||||||
--sidebar-ring: oklch(0.6489 0.2370 26.9728);
|
|
||||||
--font-sans: DM Sans, sans-serif;
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: Space Mono, monospace;
|
|
||||||
--radius: 0px;
|
|
||||||
--shadow-2xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-xs: 4px 4px 0px 0px hsl(0 0% 0% / 0.50);
|
|
||||||
--shadow-sm: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 1px 2px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-md: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 2px 4px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-lg: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 4px 6px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-xl: 4px 4px 0px 0px hsl(0 0% 0% / 1.00), 4px 8px 10px -1px hsl(0 0% 0% / 1.00);
|
|
||||||
--shadow-2xl: 4px 4px 0px 0px hsl(0 0% 0% / 2.50);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</neo-brutalism-style>
|
|
||||||
|
|
||||||
Modern dark mode style like vercel, linear
|
|
||||||
<modern-dark-mode-style>
|
|
||||||
:root {
|
|
||||||
--background: oklch(1 0 0);
|
|
||||||
--foreground: oklch(0.1450 0 0);
|
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0.1450 0 0);
|
|
||||||
--popover: oklch(1 0 0);
|
|
||||||
--popover-foreground: oklch(0.1450 0 0);
|
|
||||||
--primary: oklch(0.2050 0 0);
|
|
||||||
--primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--secondary: oklch(0.9700 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2050 0 0);
|
|
||||||
--muted: oklch(0.9700 0 0);
|
|
||||||
--muted-foreground: oklch(0.5560 0 0);
|
|
||||||
--accent: oklch(0.9700 0 0);
|
|
||||||
--accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--destructive: oklch(0.5770 0.2450 27.3250);
|
|
||||||
--destructive-foreground: oklch(1 0 0);
|
|
||||||
--border: oklch(0.9220 0 0);
|
|
||||||
--input: oklch(0.9220 0 0);
|
|
||||||
--ring: oklch(0.7080 0 0);
|
|
||||||
--chart-1: oklch(0.8100 0.1000 252);
|
|
||||||
--chart-2: oklch(0.6200 0.1900 260);
|
|
||||||
--chart-3: oklch(0.5500 0.2200 263);
|
|
||||||
--chart-4: oklch(0.4900 0.2200 264);
|
|
||||||
--chart-5: oklch(0.4200 0.1800 266);
|
|
||||||
--sidebar: oklch(0.9850 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1450 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2050 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9850 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9700 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2050 0 0);
|
|
||||||
--sidebar-border: oklch(0.9220 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7080 0 0);
|
|
||||||
--font-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
|
|
||||||
--font-serif: ui-serif, Georgia, Cambria, "Times New Roman", Times, serif;
|
|
||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
--radius: 0.625rem;
|
|
||||||
--shadow-2xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-xs: 0 1px 3px 0px hsl(0 0% 0% / 0.05);
|
|
||||||
--shadow-sm: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 1px 2px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-md: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 2px 4px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-lg: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 4px 6px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-xl: 0 1px 3px 0px hsl(0 0% 0% / 0.10), 0 8px 10px -1px hsl(0 0% 0% / 0.10);
|
|
||||||
--shadow-2xl: 0 1px 3px 0px hsl(0 0% 0% / 0.25);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
}
|
|
||||||
</modern-dark-mode-style>
|
|
||||||
|
|
||||||
## Images & icons
|
|
||||||
1. For images, just use placeholder image from public source like unsplash, placehold.co or others that you already know exact image url; Don't make up urls
|
|
||||||
2. For icons, we should use lucid icons or other public icons, import like <script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
|
||||||
|
|
||||||
## Script
|
|
||||||
1. When importing tailwind css, just use <script src="https://cdn.tailwindcss.com"></script>, don't load CSS directly as a stylesheet resource like <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
2. When using flowbite, import like <script src="https://cdn.jsdelivr.net/npm/flowbite@2.0.0/dist/flowbite.min.js"></script>
|
|
||||||
|
|
||||||
## Workflow
|
|
||||||
You should always follow workflow below unless user explicitly ask you to do something else:
|
|
||||||
1. Layout design
|
|
||||||
2. Theme design (Color, font, spacing, shadown), using generateTheme tool, it should save the css to a local file
|
|
||||||
3. Core Animation design
|
|
||||||
4. Generate a singlehtml file for the UI
|
|
||||||
5. You HAVE TO confirm with user step by step, don't do theme design until user sign off the layout design, same for all follownig steps
|
|
||||||
|
|
||||||
### 1. Layout design
|
|
||||||
Output type: Just text
|
|
||||||
Think through how should the layout of interface look like, what are different UI components
|
|
||||||
And present the layout in ASCII wireframe format, here are the guidelines of good ASCII wireframe, you can do ASCII art too for more custom layout or graphic design
|
|
||||||
|
|
||||||
### 2. Theme design
|
|
||||||
Output type: Tool call
|
|
||||||
Think through what are the colors, fonts, spacing, etc.
|
|
||||||
You HAVE TO use generateTheme tool to generate the theme, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
### 3. Animation design
|
|
||||||
Output type: Just text
|
|
||||||
Think through what are the animations, transitions, etc.
|
|
||||||
|
|
||||||
### 4. Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Output type: Tool call
|
|
||||||
Generate html file for each UI component and then combine them together to form a single html file
|
|
||||||
Make sure to reference the theme css file you created in step 2, and add custom ones that doesn't exist yet in html file
|
|
||||||
You HAVE TO use write tool to generate the html file, do NOT just output XML type text for tool-call, that is not allowed
|
|
||||||
|
|
||||||
<example>
|
|
||||||
<user>design an AI chat UI</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Let's think through the layout design for an AI chat UI. Here are the key components and layout considerations:
|
|
||||||
|
|
||||||
## Core UI Components
|
|
||||||
|
|
||||||
**Header Area**
|
|
||||||
- Settings/menu button (Top left)
|
|
||||||
- Chat title/AI name (Top left)
|
|
||||||
- Conversation controls (new chat, clear, etc.) (Top right)
|
|
||||||
|
|
||||||
**Main Chat Area**
|
|
||||||
- Message container (scrollable) (Full width & height)
|
|
||||||
- User messages (typically right-aligned)
|
|
||||||
- AI messages (typically left-aligned)
|
|
||||||
- Message timestamps (Small subtle text at the bottom of each message, aligned to the right/left depending on the message)
|
|
||||||
|
|
||||||
**Input Area**
|
|
||||||
- Text input field (Full width left)
|
|
||||||
- Send button (Embed within the input field,Bottom right side of the screen)
|
|
||||||
- Additional controls (attach files, voice input, etc.) (Embed within the input field, Bottom left)
|
|
||||||
- Character/token counter (optional) (Embed within the input field, top right corner, small text)
|
|
||||||
|
|
||||||
|
|
||||||
## Layout Structure Options
|
|
||||||
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ ☰ HEADER BAR + │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ User Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────┐ │
|
|
||||||
│ │ AI Message Bubble │ │
|
|
||||||
│ └─────────────────────────────┘ │
|
|
||||||
│ │
|
|
||||||
│ [CHAT AREA] │
|
|
||||||
│ │
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ [Text Input Field] [Send] │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
|
|
||||||
When hamburger (☰) is clicked, sidebar slides out:
|
|
||||||
┌──────────────┬─────────────────────────────────────┐
|
|
||||||
│ SIDEBAR │ ☰ HEADER BAR + │
|
|
||||||
│ ┌──────────┐ ├─────────────────────────────────────┤
|
|
||||||
│ │ Chat 1 │ │ │
|
|
||||||
│ │ Chat 2 │ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ Chat 3 │ │ │ AI Message Bubble │ │
|
|
||||||
│ │ + New │ │ └─────────────────────────────┘ │
|
|
||||||
│ └──────────┘ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ User Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ ┌─────────────────────────────┐ │
|
|
||||||
│ │ │ AI Message Bubble │ │
|
|
||||||
│ │ └─────────────────────────────┘ │
|
|
||||||
│ │ │
|
|
||||||
│ │ [CHAT AREA] │
|
|
||||||
│ │ │
|
|
||||||
│ ├─────────────────────────────────────┤
|
|
||||||
│ │ [Text Input Field] [Send] │
|
|
||||||
└──────────────┘─────────────────────────────────────┘
|
|
||||||
|
|
||||||
Would you like to go ahead with this layout & UI interaction or needs any modification?
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
They looks good
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the color & font theme,
|
|
||||||
|
|
||||||
generateTheme(theme_name='Vercel techy style', reasoning_reference='Reference classic shadcn style that has ...', cssFilePath='.superdesign/design_iterations/theme_1.css', cssSheet=':root {
|
|
||||||
--background: oklch(1.0000 0 0);
|
|
||||||
--foreground: oklch(0.1448 0 0);
|
|
||||||
--card: oklch(1.0000 0 0);
|
|
||||||
--card-foreground: oklch(0.1448 0 0);
|
|
||||||
--popover: oklch(1.0000 0 0);
|
|
||||||
--popover-foreground: oklch(0.1448 0 0);
|
|
||||||
--primary: oklch(0.5555 0 0);
|
|
||||||
--primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--secondary: oklch(0.9702 0 0);
|
|
||||||
--secondary-foreground: oklch(0.2046 0 0);
|
|
||||||
--muted: oklch(0.9702 0 0);
|
|
||||||
--muted-foreground: oklch(0.5486 0 0);
|
|
||||||
--accent: oklch(0.9702 0 0);
|
|
||||||
--accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--destructive: oklch(0.5830 0.2387 28.4765);
|
|
||||||
--destructive-foreground: oklch(0.9702 0 0);
|
|
||||||
--border: oklch(0.9219 0 0);
|
|
||||||
--input: oklch(0.9219 0 0);
|
|
||||||
--ring: oklch(0.7090 0 0);
|
|
||||||
--chart-1: oklch(0.5555 0 0);
|
|
||||||
--chart-2: oklch(0.5555 0 0);
|
|
||||||
--chart-3: oklch(0.5555 0 0);
|
|
||||||
--chart-4: oklch(0.5555 0 0);
|
|
||||||
--chart-5: oklch(0.5555 0 0);
|
|
||||||
--sidebar: oklch(0.9851 0 0);
|
|
||||||
--sidebar-foreground: oklch(0.1448 0 0);
|
|
||||||
--sidebar-primary: oklch(0.2046 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0.9851 0 0);
|
|
||||||
--sidebar-accent: oklch(0.9702 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0.2046 0 0);
|
|
||||||
--sidebar-border: oklch(0.9219 0 0);
|
|
||||||
--sidebar-ring: oklch(0.7090 0 0);
|
|
||||||
--font-sans: Geist Mono, monospace;
|
|
||||||
--font-serif: Geist Mono, monospace;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--radius: 0rem;
|
|
||||||
--shadow-2xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xs: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-sm: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 1px 2px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-md: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 2px 4px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-lg: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 4px 6px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00), 0px 8px 10px -1px hsl(0 0% 0% / 0.00);
|
|
||||||
--shadow-2xl: 0px 1px 0px 0px hsl(0 0% 0% / 0.00);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
}')
|
|
||||||
|
|
||||||
|
|
||||||
<user>
|
|
||||||
I like the vintage style
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, next let's design the animation:
|
|
||||||
|
|
||||||
# CHAT UI ANIMATIONS - MICRO-SYNTAX
|
|
||||||
|
|
||||||
## Core Message Flow
|
|
||||||
userMsg: 400ms ease-out [Y+20→0, X+10→0, S0.9→1]
|
|
||||||
aiMsg: 600ms bounce [Y+15→0, S0.95→1] +200ms
|
|
||||||
typing: 1400ms ∞ [Y±8, α0.4→1] stagger+200ms
|
|
||||||
status: 300ms ease-out [α0.6→1, S1→1.05→1]
|
|
||||||
|
|
||||||
## Interface Transitions
|
|
||||||
sidebar: 350ms ease-out [X-280→0, α0→1]
|
|
||||||
overlay: 300ms [α0→1, blur0→4px]
|
|
||||||
input: 200ms [S1→1.01, shadow+ring] focus
|
|
||||||
input: 150ms [S1.01→1, shadow-ring] blur
|
|
||||||
|
|
||||||
## Button Interactions
|
|
||||||
sendBtn: 150ms [S1→0.95→1, R±2°] press
|
|
||||||
sendBtn: 200ms [S1→1.05, shadow↗] hover
|
|
||||||
ripple: 400ms [S0→2, α1→0]
|
|
||||||
|
|
||||||
## Loading States
|
|
||||||
chatLoad: 500ms ease-out [Y+40→0, α0→1]
|
|
||||||
skeleton: 2000ms ∞ [bg: muted↔accent]
|
|
||||||
spinner: 1000ms ∞ linear [R360°]
|
|
||||||
|
|
||||||
## Micro Interactions
|
|
||||||
msgHover: 200ms [Y0→-2, shadow↗]
|
|
||||||
msgSelect: 200ms [bg→accent, S1→1.02]
|
|
||||||
error: 400ms [X±5] shake
|
|
||||||
success: 600ms bounce [S0→1.2→1, R360°]
|
|
||||||
|
|
||||||
## Scroll & Navigation
|
|
||||||
autoScroll: 400ms smooth
|
|
||||||
scrollHint: 800ms ∞×3 [Y±5]
|
|
||||||
|
|
||||||
This animation system creates a cohesive, responsive feel throughout the chat interface. Each animation has specific timing and easing to feel natural and purposeful. Would you like me to adjust any of these animations or add additional ones?
|
|
||||||
|
|
||||||
</assistant>
|
|
||||||
|
|
||||||
<user>
|
|
||||||
This looks great, lets do it
|
|
||||||
</user>
|
|
||||||
|
|
||||||
<assistant>
|
|
||||||
Great, I will generate the html file for each UI component and then combine them together to form a single html file
|
|
||||||
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.css', content='...')
|
|
||||||
write(file_path='.superdesign/design_iterations/chat_ui.html', content='...')
|
|
||||||
|
|
||||||
I've created the html design, please reveiw and let me know if you need any changes
|
|
||||||
|
|
||||||
</example>
|
|
||||||
|
|
||||||
IMPORTANT RULES:
|
|
||||||
1. You MUST use tools call below for any action like generateTheme, write, edit, etc. You are NOT allowed to just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>; MUST USE TOOL CALL (This is very important!!)
|
|
||||||
2. You MUST confirm the layout, and then theme style, and then animation
|
|
||||||
3. You MUST use .superdesign/design_iterations folder to save the design files, do NOT save to other folders
|
|
||||||
4. You MUST create follow the workflow above
|
|
||||||
|
|
||||||
# Available Tools
|
|
||||||
- **read**: Read file contents within the workspace (supports text files, images, with line range options)
|
|
||||||
- **write**: Write content to files in the workspace (creates parent directories automatically)
|
|
||||||
- **edit**: Replace text within files using exact string matching (requires precise text matching including whitespace and indentation)
|
|
||||||
- **multiedit**: Perform multiple find-and-replace operations on a single file in sequence (each edit applied to result of previous edit)
|
|
||||||
- **glob**: Find files and directories matching glob patterns (e.g., "*.js", "src/**/*.ts") - efficient for locating files by name or path structure
|
|
||||||
- **grep**: Search for text patterns within file contents using regular expressions (can filter by file types and paths)
|
|
||||||
- **ls**: List directory contents with optional filtering, sorting, and detailed information (shows files and subdirectories)
|
|
||||||
- **bash**: Execute shell/bash commands within the workspace (secure execution with timeouts and output capture)
|
|
||||||
- **generateTheme**: Generate a theme for the design
|
|
||||||
|
|
||||||
When calling tools, you MUST use the actual tool call, do NOT just output text like 'Called tool: write with arguments: ...' or <tool-call>...</tool-call>, this won't actually call the tool. (This is very important to my life, please follow)
|
|
||||||
@@ -12,30 +12,22 @@ This document provides technical details for AI agents working on the Aperonight
|
|||||||
- **Devise Integration**: Complete authentication system with registration, login, password reset
|
- **Devise Integration**: Complete authentication system with registration, login, password reset
|
||||||
- **Relationships**: Users can create events and purchase tickets
|
- **Relationships**: Users can create events and purchase tickets
|
||||||
- **Validations**: Email format, password strength, optional name fields
|
- **Validations**: Email format, password strength, optional name fields
|
||||||
- **Promoter System**: Professional accounts can create and manage events with Stripe integration
|
|
||||||
|
|
||||||
#### 2. Event System (`app/models/event.rb`)
|
#### 2. Event System (`app/models/event.rb`)
|
||||||
- **States**: `draft`, `published`, `canceled`, `sold_out` with enum management
|
- **States**: `draft`, `published`, `canceled`, `sold_out` with enum management
|
||||||
- **Geographic Data**: Latitude/longitude for venue mapping
|
- **Geographic Data**: Latitude/longitude for venue mapping
|
||||||
- **Relationships**: Belongs to user, has many ticket types and tickets through ticket types
|
- **Relationships**: Belongs to user, has many ticket types and tickets through ticket types
|
||||||
- **Scopes**: Featured events, published events, upcoming events with proper ordering
|
- **Scopes**: Featured events, published events, upcoming events with proper ordering
|
||||||
- **Payout Management**: Event-level payout tracking and status management
|
|
||||||
|
|
||||||
#### 3. Ticket Management
|
#### 3. Ticket Management
|
||||||
- **TicketType** (`app/models/ticket_type.rb`): Defines ticket categories with pricing, quantity, sale periods
|
- **TicketType** (`app/models/ticket_type.rb`): Defines ticket categories with pricing, quantity, sale periods
|
||||||
- **Ticket** (`app/models/ticket.rb`): Individual tickets with unique QR codes, status tracking, price storage
|
- **Ticket** (`app/models/ticket.rb`): Individual tickets with unique QR codes, status tracking, price storage
|
||||||
- **Order System** (`app/models/order.rb`): Groups tickets into orders with payment status tracking
|
|
||||||
|
|
||||||
#### 4. Payment Processing (`app/controllers/events_controller.rb`)
|
#### 4. Payment Processing (`app/controllers/events_controller.rb`)
|
||||||
- **Stripe Integration**: Complete checkout session creation and payment confirmation
|
- **Stripe Integration**: Complete checkout session creation and payment confirmation
|
||||||
- **Session Management**: Proper handling of payment success/failure with ticket generation
|
- **Session Management**: Proper handling of payment success/failure with ticket generation
|
||||||
- **Security**: Authentication required, cart validation, availability checking
|
- **Security**: Authentication required, cart validation, availability checking
|
||||||
|
|
||||||
#### 5. Financial System
|
|
||||||
- **Earnings** (`app/models/earning.rb`): Tracks revenue from paid orders, excluding refunded tickets
|
|
||||||
- **Payouts** (`app/models/payout.rb`): Manages promoter payout requests and processing
|
|
||||||
- **Platform Fees**: €0.50 fixed fee + 1.5% of ticket price, per ticket
|
|
||||||
|
|
||||||
### Database Schema Key Points
|
### Database Schema Key Points
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
@@ -46,8 +38,6 @@ CREATE TABLE users (
|
|||||||
encrypted_password varchar(255) NOT NULL,
|
encrypted_password varchar(255) NOT NULL,
|
||||||
first_name varchar(255),
|
first_name varchar(255),
|
||||||
last_name varchar(255),
|
last_name varchar(255),
|
||||||
is_professionnal boolean DEFAULT false, -- Professional account flag
|
|
||||||
stripe_connected_account_id varchar(255), -- Stripe Connect account for payouts
|
|
||||||
-- Devise fields: confirmation, reset tokens, etc.
|
-- Devise fields: confirmation, reset tokens, etc.
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -65,8 +55,6 @@ CREATE TABLE events (
|
|||||||
start_time datetime NOT NULL,
|
start_time datetime NOT NULL,
|
||||||
end_time datetime,
|
end_time datetime,
|
||||||
state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3
|
state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3
|
||||||
payout_status integer, -- enum: not_requested=0, requested=1, processing=2, completed=3, failed=4
|
|
||||||
payout_requested_at datetime,
|
|
||||||
featured boolean DEFAULT false,
|
featured boolean DEFAULT false,
|
||||||
image varchar(500)
|
image varchar(500)
|
||||||
);
|
);
|
||||||
@@ -85,53 +73,14 @@ CREATE TABLE ticket_types (
|
|||||||
minimum_age integer
|
minimum_age integer
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Orders group tickets and track payment status
|
|
||||||
CREATE TABLE orders (
|
|
||||||
id bigint PRIMARY KEY,
|
|
||||||
user_id bigint REFERENCES users(id),
|
|
||||||
event_id bigint REFERENCES events(id),
|
|
||||||
status varchar(255) DEFAULT 'draft', -- draft, pending_payment, paid, completed, cancelled, expired
|
|
||||||
total_amount_cents integer DEFAULT 0,
|
|
||||||
payment_attempts integer DEFAULT 0,
|
|
||||||
expires_at datetime,
|
|
||||||
last_payment_attempt_at datetime
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Individual tickets with QR codes
|
-- Individual tickets with QR codes
|
||||||
CREATE TABLE tickets (
|
CREATE TABLE tickets (
|
||||||
id bigint PRIMARY KEY,
|
id bigint PRIMARY KEY,
|
||||||
order_id bigint REFERENCES orders(id),
|
user_id bigint REFERENCES users(id),
|
||||||
ticket_type_id bigint REFERENCES ticket_types(id),
|
ticket_type_id bigint REFERENCES ticket_types(id),
|
||||||
qr_code varchar(255) UNIQUE NOT NULL,
|
qr_code varchar(255) UNIQUE NOT NULL,
|
||||||
price_cents integer NOT NULL,
|
price_cents integer NOT NULL,
|
||||||
status varchar(255) DEFAULT 'active', -- draft, active, used, expired, refunded
|
status varchar(255) DEFAULT 'active' -- active, used, expired, refunded
|
||||||
first_name varchar(255),
|
|
||||||
last_name varchar(255)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Earnings track revenue from paid orders
|
|
||||||
CREATE TABLE earnings (
|
|
||||||
id bigint PRIMARY KEY,
|
|
||||||
event_id bigint REFERENCES events(id),
|
|
||||||
user_id bigint REFERENCES users(id),
|
|
||||||
order_id bigint REFERENCES orders(id),
|
|
||||||
amount_cents integer, -- Promoter payout amount (after fees)
|
|
||||||
fee_cents integer, -- Platform fees
|
|
||||||
status integer DEFAULT 0, -- enum: pending=0, paid=1
|
|
||||||
stripe_payout_id varchar(255)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Payouts track promoter payout requests
|
|
||||||
CREATE TABLE payouts (
|
|
||||||
id bigint PRIMARY KEY,
|
|
||||||
user_id bigint REFERENCES users(id),
|
|
||||||
event_id bigint REFERENCES events(id),
|
|
||||||
amount_cents integer NOT NULL, -- Gross amount
|
|
||||||
fee_cents integer NOT NULL DEFAULT 0, -- Platform fees
|
|
||||||
status integer DEFAULT 0, -- enum: pending=0, processing=1, completed=2, failed=3
|
|
||||||
stripe_payout_id varchar(255),
|
|
||||||
total_orders_count integer DEFAULT 0,
|
|
||||||
refunded_orders_count integer DEFAULT 0
|
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -188,7 +137,6 @@ session = Stripe::Checkout::Session.create({
|
|||||||
2. **Ticket Creation**: Generate tickets based on order items from metadata
|
2. **Ticket Creation**: Generate tickets based on order items from metadata
|
||||||
3. **QR Code Generation**: Automatic unique QR code creation via model callbacks
|
3. **QR Code Generation**: Automatic unique QR code creation via model callbacks
|
||||||
4. **Success Page**: Display tickets with download links
|
4. **Success Page**: Display tickets with download links
|
||||||
5. **Earnings Creation**: Automatically creates earnings records for promoter payout tracking
|
|
||||||
|
|
||||||
### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
|
### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
|
||||||
|
|
||||||
@@ -226,61 +174,6 @@ end
|
|||||||
- **Session Storage**: Preserves cart when redirecting to login
|
- **Session Storage**: Preserves cart when redirecting to login
|
||||||
- **Dynamic Updates**: Real-time cart total and ticket count updates
|
- **Dynamic Updates**: Real-time cart total and ticket count updates
|
||||||
|
|
||||||
## 🔄 Application Workflows
|
|
||||||
|
|
||||||
### 1. User Registration & Onboarding
|
|
||||||
1. User registers with email/password
|
|
||||||
2. Completes onboarding process to set up profile
|
|
||||||
3. Can browse and purchase tickets as a customer
|
|
||||||
|
|
||||||
### 2. Promoter Account Setup
|
|
||||||
1. User requests professional account status
|
|
||||||
2. Connects Stripe account for payment processing
|
|
||||||
3. Can create and manage events
|
|
||||||
|
|
||||||
### 3. Event Creation & Management
|
|
||||||
1. Promoter creates event in draft state
|
|
||||||
2. Adds ticket types with pricing and quantities
|
|
||||||
3. Publishes event to make it publicly available
|
|
||||||
4. Manages event status (publish/unpublish/cancel)
|
|
||||||
|
|
||||||
### 4. Ticket Purchase Flow
|
|
||||||
1. User adds tickets to cart
|
|
||||||
2. Proceeds to checkout with Stripe
|
|
||||||
3. Payment processing through Stripe
|
|
||||||
4. Order and ticket creation upon successful payment
|
|
||||||
5. Email confirmation sent to user
|
|
||||||
6. Automatic earnings record creation for promoter
|
|
||||||
|
|
||||||
### 5. Financial Workflows
|
|
||||||
|
|
||||||
#### Platform Fee Structure
|
|
||||||
- **Fixed Fee**: €0.50 per ticket
|
|
||||||
- **Percentage Fee**: 1.5% of ticket price per ticket
|
|
||||||
- **Calculation Example**:
|
|
||||||
- 1 ticket at €20.00: €0.50 + (€20.00 × 1.5%) = €0.50 + €0.30 = €0.80 total fees
|
|
||||||
- 3 tickets at €25.00 each: (3 × €0.50) + (3 × €25.00 × 1.5%) = €1.50 + €1.13 = €2.63 total fees
|
|
||||||
|
|
||||||
#### Earnings Tracking
|
|
||||||
1. When order is marked as paid, earnings record is automatically created
|
|
||||||
2. Earnings amount = Total ticket sales - Platform fees
|
|
||||||
3. Only non-refunded tickets are counted in earnings
|
|
||||||
4. Earnings remain in "pending" status until payout is requested
|
|
||||||
|
|
||||||
#### Payout Request Process
|
|
||||||
1. Event ends (current time >= event end_time)
|
|
||||||
2. Promoter requests payout through event management interface
|
|
||||||
3. System calculates total earnings for the event (excluding refunded tickets)
|
|
||||||
4. Creates payout record with gross amount, fees, and net amount
|
|
||||||
5. Updates event payout status to "requested"
|
|
||||||
6. Admin processes payout through Stripe
|
|
||||||
7. Payout status updated to "processing" then "completed" or "failed"
|
|
||||||
|
|
||||||
### 6. Refund Management
|
|
||||||
1. Tickets can be marked as refunded
|
|
||||||
2. Refunded tickets are excluded from earnings calculations
|
|
||||||
3. Promoters do not receive payouts for refunded tickets
|
|
||||||
|
|
||||||
## 🔧 Development Patterns
|
## 🔧 Development Patterns
|
||||||
|
|
||||||
### Model Validations
|
### Model Validations
|
||||||
@@ -338,8 +231,6 @@ RAILS_MASTER_KEY=...
|
|||||||
CREATE INDEX idx_events_published_start_time ON events (state, start_time);
|
CREATE INDEX idx_events_published_start_time ON events (state, start_time);
|
||||||
CREATE INDEX idx_tickets_user_status ON tickets (user_id, status);
|
CREATE INDEX idx_tickets_user_status ON tickets (user_id, status);
|
||||||
CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
|
CREATE INDEX idx_ticket_types_event ON ticket_types (event_id);
|
||||||
CREATE INDEX idx_orders_event_status ON orders (event_id, status);
|
|
||||||
CREATE INDEX idx_earnings_event_status ON earnings (event_id, status);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Security Considerations
|
### Security Considerations
|
||||||
@@ -347,7 +238,6 @@ CREATE INDEX idx_earnings_event_status ON earnings (event_id, status);
|
|||||||
- **Strong Parameters**: All user inputs filtered
|
- **Strong Parameters**: All user inputs filtered
|
||||||
- **Authentication**: Devise handles session security
|
- **Authentication**: Devise handles session security
|
||||||
- **Payment Security**: Stripe handles sensitive payment data
|
- **Payment Security**: Stripe handles sensitive payment data
|
||||||
- **Authorization**: Proper access controls for promoter vs customer actions
|
|
||||||
|
|
||||||
## 🧪 Testing Strategy
|
## 🧪 Testing Strategy
|
||||||
|
|
||||||
@@ -357,7 +247,6 @@ CREATE INDEX idx_earnings_event_status ON earnings (event_id, status);
|
|||||||
3. **Booking Process**: Cart validation, payment processing, ticket generation
|
3. **Booking Process**: Cart validation, payment processing, ticket generation
|
||||||
4. **PDF Generation**: QR code uniqueness, ticket format
|
4. **PDF Generation**: QR code uniqueness, ticket format
|
||||||
5. **Dashboard Metrics**: Query accuracy, performance
|
5. **Dashboard Metrics**: Query accuracy, performance
|
||||||
6. **Financial Workflows**: Fee calculations, payout processing, refund handling
|
|
||||||
|
|
||||||
### Seed Data Structure
|
### Seed Data Structure
|
||||||
```ruby
|
```ruby
|
||||||
@@ -390,7 +279,7 @@ ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)'
|
|||||||
ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby
|
ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby
|
||||||
|
|
||||||
# Mass rename across multiple files
|
# Mass rename across multiple files
|
||||||
ast-grep --pattern 'old_method_name($$ARGS)' --rewrite 'new_method_name($$ARGS)' --lang ruby --update-all
|
ast-grep --pattern 'old_method_name($$$ARGS)' --rewrite 'new_method_name($$$ARGS)' --lang ruby --update-all
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Best Practices:
|
#### Best Practices:
|
||||||
13
BACKLOG.md
13
BACKLOG.md
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
### Medium Priority
|
### Medium Priority
|
||||||
|
|
||||||
|
- [ ] feat: Promotion code on ticket
|
||||||
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
||||||
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
|
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
|
||||||
- [ ] feat: Refund management system
|
- [ ] feat: Refund management system
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
- [ ] feat: Dynamic pricing based on demand
|
- [ ] feat: Dynamic pricing based on demand
|
||||||
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||||
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||||
|
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
||||||
- [ ] feat: Platform commission tracking and fee structure display
|
- [ ] feat: Platform commission tracking and fee structure display
|
||||||
- [ ] feat: Tax reporting and revenue export for promoters
|
- [ ] feat: Tax reporting and revenue export for promoters
|
||||||
- [ ] feat: Event update notifications to ticket holders
|
- [ ] feat: Event update notifications to ticket holders
|
||||||
@@ -44,10 +46,15 @@
|
|||||||
- [ ] feat: Event recommendations system
|
- [ ] feat: Event recommendations system
|
||||||
- [ ] feat: Invitation link. As organizer or promoter, you can invite people
|
- [ ] feat: Invitation link. As organizer or promoter, you can invite people
|
||||||
|
|
||||||
|
|
||||||
|
### Design & Infrastructure
|
||||||
|
|
||||||
|
- [ ] style: Rewrite design system
|
||||||
|
- [ ] refactor: Rewrite design mockup
|
||||||
|
|
||||||
## 🚧 Doing
|
## 🚧 Doing
|
||||||
|
|
||||||
- [x] feat: Payout system for promoters (automated/manual payment processing)
|
- [ ] feat: Promotion code on ticket
|
||||||
- [ ] feat: Payout tracking for administrators
|
|
||||||
- [ ] feat: Page to display all tickets for an event
|
- [ ] feat: Page to display all tickets for an event
|
||||||
- [ ] feat: Add a link into notification email to order page that display all tickets
|
- [ ] feat: Add a link into notification email to order page that display all tickets
|
||||||
|
|
||||||
@@ -63,5 +70,3 @@
|
|||||||
- [x] feat: Ticket inventory management and capacity limits
|
- [x] feat: Ticket inventory management and capacity limits
|
||||||
- [x] feat: Event discovery with search and filtering
|
- [x] feat: Event discovery with search and filtering
|
||||||
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
||||||
- [x] style: Rewrite design system
|
|
||||||
- [x] refactor: Rewrite design mockup
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||||
|
|
||||||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
ARG RUBY_VERSION=3.4.1
|
ARG RUBY_VERSION=3.4.4
|
||||||
FROM docker.io/library/ruby:$RUBY_VERSION AS base
|
FROM docker.io/library/ruby:$RUBY_VERSION AS base
|
||||||
|
|
||||||
# Rails app lives here
|
# Rails app lives here
|
||||||
@@ -20,10 +20,10 @@ RUN apt-get update -qq && \
|
|||||||
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
rm -rf /var/lib/apt/lists /var/cache/apt/archives
|
||||||
|
|
||||||
# Set production environment
|
# Set production environment
|
||||||
ENV RAILS_ENV="production" \
|
ENV RAILS_ENV="development" \
|
||||||
BUNDLE_DEPLOYMENT="1" \
|
BUNDLE_DEPLOYMENT="1" \
|
||||||
BUNDLE_PATH="/usr/local/bundle" \
|
BUNDLE_PATH="/usr/local/bundle" \
|
||||||
BUNDLE_WITHOUT="development"
|
BUNDLE_WITHOUT=""
|
||||||
|
|
||||||
# Throw-away build stage to reduce size of final image
|
# Throw-away build stage to reduce size of final image
|
||||||
FROM base AS build
|
FROM base AS build
|
||||||
|
|||||||
99
Dockerfile.production
Normal file
99
Dockerfile.production
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
# check=error=true
|
||||||
|
|
||||||
|
# This Dockerfile is designed for production, not development. Use with Kamal or build'n'run by hand:
|
||||||
|
# docker build -t myapp .
|
||||||
|
# docker run -d -p 80:80 -e RAILS_MASTER_KEY=<value from config/master.key> --name myapp myapp
|
||||||
|
|
||||||
|
# For a containerized dev environment, see Dev Containers: https://guides.rubyonrails.org/getting_started_with_devcontainer.html
|
||||||
|
|
||||||
|
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||||
|
ARG RUBY_VERSION=3.4.4
|
||||||
|
FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base
|
||||||
|
|
||||||
|
# Rails app lives here
|
||||||
|
WORKDIR /rails
|
||||||
|
|
||||||
|
# Set production environment
|
||||||
|
ENV RAILS_ENV="production" \
|
||||||
|
BUNDLE_DEPLOYMENT="1" \
|
||||||
|
BUNDLE_PATH="/usr/local/bundle" \
|
||||||
|
BUNDLE_WITHOUT="development"
|
||||||
|
|
||||||
|
# Create non-root user early for security and to allow correct permissions in build stage
|
||||||
|
RUN groupadd --system --gid 1000 rails && \
|
||||||
|
useradd --system --uid 1000 --gid 1000 --create-home --shell /bin/bash rails
|
||||||
|
|
||||||
|
# Install base packages (runtime only in base image)
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install --no-install-recommends -y curl libjemalloc2 libvips42 mariadb-client && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Throw-away build stage to reduce size of final image
|
||||||
|
FROM base AS build
|
||||||
|
|
||||||
|
# Install packages needed to build gems and node modules
|
||||||
|
RUN apt-get update -qq && \
|
||||||
|
apt-get install --no-install-recommends -y build-essential git libyaml-dev node-gyp pkg-config python-is-python3 libmariadb-dev && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install rails gem in the build stage where build tools are available
|
||||||
|
RUN gem install rails
|
||||||
|
|
||||||
|
# Install JavaScript dependencies
|
||||||
|
ARG NODE_VERSION=18.19.0
|
||||||
|
ARG YARN_VERSION=latest
|
||||||
|
ENV NODE_PATH=/usr/local/node
|
||||||
|
ENV PATH=/usr/local/node/bin:$PATH
|
||||||
|
RUN curl -sL https://github.com/nodenv/node-build/archive/master.tar.gz | tar xz -C /tmp/ && \
|
||||||
|
/tmp/node-build-master/bin/node-build "${NODE_VERSION}" /usr/local/node && \
|
||||||
|
rm -rf /tmp/node-build-master
|
||||||
|
RUN corepack enable && yarn set version $YARN_VERSION
|
||||||
|
|
||||||
|
# Copy dependency files first (better caching)
|
||||||
|
COPY Gemfile Gemfile.lock ./
|
||||||
|
RUN bundle config set --local frozen 'true' && \
|
||||||
|
bundle install && \
|
||||||
|
rm -rf ~/.bundle/ "${BUNDLE_PATH}"/ruby/*/cache "${BUNDLE_PATH}"/ruby/*/bundler/gems/*/.git && \
|
||||||
|
bundle exec bootsnap precompile --gemfile
|
||||||
|
|
||||||
|
# Install node modules
|
||||||
|
COPY package.json yarn.lock ./
|
||||||
|
RUN yarn install --immutable && \
|
||||||
|
yarn cache clean --all
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Precompile bootsnap code for faster boot times
|
||||||
|
RUN bundle exec bootsnap precompile app/ lib/
|
||||||
|
|
||||||
|
# Precompiling assets for production without requiring secret RAILS_MASTER_KEY
|
||||||
|
RUN SECRET_KEY_BASE_DUMMY=1 ./bin/rails assets:precompile
|
||||||
|
|
||||||
|
# Clean up build-time dependencies and files
|
||||||
|
RUN rm -rf node_modules tmp/cache
|
||||||
|
|
||||||
|
# Final stage for app image
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
# Copy built artifacts: gems and application
|
||||||
|
COPY --from=build "${BUNDLE_PATH}" "${BUNDLE_PATH}"
|
||||||
|
COPY --from=build /rails /rails
|
||||||
|
COPY --from=build /usr/local/node /usr/local/node
|
||||||
|
|
||||||
|
# Ensure proper permissions for runtime directories
|
||||||
|
RUN mkdir -p /rails/db /rails/log /rails/storage /rails/tmp && \
|
||||||
|
chown -R rails:rails /rails/db /rails/log /rails/storage /rails/tmp
|
||||||
|
|
||||||
|
USER rails
|
||||||
|
|
||||||
|
# Configure jemalloc for better memory management
|
||||||
|
ENV LD_PRELOAD=libjemalloc.so.2
|
||||||
|
|
||||||
|
# Entrypoint prepares the database.
|
||||||
|
ENTRYPOINT ["/rails/bin/docker-entrypoint"]
|
||||||
|
|
||||||
|
# Start server via Thruster by default, this can be overwritten at runtime
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["./bin/thrust", "./bin/rails", "server", "-b", "0.0.0.0"]
|
||||||
@@ -13,4 +13,3 @@
|
|||||||
|
|
||||||
/* Import pages */
|
/* Import pages */
|
||||||
@import "pages/home";
|
@import "pages/home";
|
||||||
@import "pages/payouts";
|
|
||||||
|
|||||||
@@ -1,304 +0,0 @@
|
|||||||
/* Payouts specific styles */
|
|
||||||
|
|
||||||
.payout-status-progress {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-progress::before {
|
|
||||||
content: '';
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
height: 2px;
|
|
||||||
background-color: #e5e7eb;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-icon {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-icon.pending {
|
|
||||||
background-color: #f59e0b;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-icon.processing {
|
|
||||||
background-color: #3b82f6;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-icon.completed {
|
|
||||||
background-color: #10b981;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-icon.failed {
|
|
||||||
background-color: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-icon.incomplete {
|
|
||||||
background-color: #e5e7eb;
|
|
||||||
color: #9ca3af;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-label {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-step-date {
|
|
||||||
font-size: 0.625rem;
|
|
||||||
color: #9ca3af;
|
|
||||||
margin-top: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-summary-card {
|
|
||||||
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
|
|
||||||
border: 1px solid #bbf7d0;
|
|
||||||
border-radius: 0.75rem;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.1), 0 2px 4px -1px rgba(16, 185, 129, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-summary-amount {
|
|
||||||
font-size: 2rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: #047857;
|
|
||||||
margin: 0.5rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-summary-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #059669;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-table-row:hover {
|
|
||||||
background-color: #f9fafb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.25rem 0.75rem;
|
|
||||||
border-radius: 9999px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-badge.pending {
|
|
||||||
background-color: #fef3c7;
|
|
||||||
color: #92400e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-badge.processing {
|
|
||||||
background-color: #dbeafe;
|
|
||||||
color: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-badge.completed {
|
|
||||||
background-color: #d1fae5;
|
|
||||||
color: #047857;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-status-badge.failed {
|
|
||||||
background-color: #fee2e2;
|
|
||||||
color: #b91c1c;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-empty-state {
|
|
||||||
text-align: center;
|
|
||||||
padding: 3rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-empty-state-icon {
|
|
||||||
margin: 0 auto 1rem;
|
|
||||||
width: 5rem;
|
|
||||||
height: 5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-empty-state-title {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-empty-state-description {
|
|
||||||
color: #6b7280;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-title {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-event-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 1rem;
|
|
||||||
background-color: #f9fafb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-event-icon {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 2.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
background: linear-gradient(135deg, #8b5cf6 0%, #ec4899 100%);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
color: white;
|
|
||||||
margin-right: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-event-name {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-event-id {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
|
||||||
.payout-detail-grid {
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-item {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 2fr;
|
|
||||||
gap: 1rem;
|
|
||||||
padding: 1rem 0;
|
|
||||||
border-bottom: 1px solid #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-item:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-label {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #6b7280;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-value {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #111827;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-value.amount {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-detail-value.net-amount {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #059669;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button {
|
|
||||||
width: 100%;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
font-weight: 600;
|
|
||||||
transition: all 0.2s;
|
|
||||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.primary {
|
|
||||||
background-color: #10b981;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.primary:hover {
|
|
||||||
background-color: #059669;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 6px -1px rgba(16, 185, 129, 0.2), 0 2px 4px -1px rgba(16, 185, 129, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.secondary {
|
|
||||||
background-color: #f3f4f6;
|
|
||||||
color: #374151;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.secondary:hover {
|
|
||||||
background-color: #e5e7eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.warning {
|
|
||||||
background-color: #fbbf24;
|
|
||||||
color: #713f12;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.warning:hover {
|
|
||||||
background-color: #f59e0b;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.danger {
|
|
||||||
background-color: #ef4444;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.payout-action-button.danger:hover {
|
|
||||||
background-color: #dc2626;
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
class Admin::PayoutsController < ApplicationController
|
|
||||||
before_action :authenticate_user!
|
|
||||||
before_action :ensure_admin!
|
|
||||||
|
|
||||||
def index
|
|
||||||
@payouts = Payout.includes(:event, :user)
|
|
||||||
.order(created_at: :desc)
|
|
||||||
.page(params[:page])
|
|
||||||
end
|
|
||||||
|
|
||||||
def create
|
|
||||||
@payout = Payout.find(params[:id])
|
|
||||||
|
|
||||||
begin
|
|
||||||
@payout.process_payout!
|
|
||||||
redirect_to admin_payouts_path, notice: "Payout processed successfully."
|
|
||||||
rescue => e
|
|
||||||
redirect_to admin_payouts_path, alert: "Failed to process payout: #{e.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def ensure_admin!
|
|
||||||
# For now, we'll just check if the user has a stripe account
|
|
||||||
# In a real app, you'd have an admin role check
|
|
||||||
unless current_user.has_stripe_account?
|
|
||||||
redirect_to dashboard_path, alert: "Access denied."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -126,6 +126,20 @@ class OrdersController < ApplicationController
|
|||||||
@total_amount = @order.total_amount_cents
|
@total_amount = @order.total_amount_cents
|
||||||
@expiring_soon = @order.expiring_soon?
|
@expiring_soon = @order.expiring_soon?
|
||||||
|
|
||||||
|
# Handle promotion code application
|
||||||
|
if params[:promotion_code].present?
|
||||||
|
promotion_code = PromotionCode.valid.find_by(code: params[:promotion_code].upcase)
|
||||||
|
if promotion_code
|
||||||
|
# Apply the promotion code to the order
|
||||||
|
@order.promotion_codes << promotion_code
|
||||||
|
@order.calculate_total!
|
||||||
|
@total_amount = @order.total_amount_cents
|
||||||
|
flash.now[:notice] = "Code promotionnel appliqué: #{promotion_code.code}"
|
||||||
|
else
|
||||||
|
flash.now[:alert] = "Code promotionnel invalide"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# For free orders, automatically mark as paid and redirect to success
|
# For free orders, automatically mark as paid and redirect to success
|
||||||
if @order.free?
|
if @order.free?
|
||||||
@order.mark_as_paid!
|
@order.mark_as_paid!
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
class Promoter::PayoutsController < ApplicationController
|
|
||||||
before_action :authenticate_user!
|
|
||||||
before_action :ensure_promoter!
|
|
||||||
before_action :set_event, only: [:show, :create]
|
|
||||||
|
|
||||||
# List all payouts for the current promoter
|
|
||||||
def index
|
|
||||||
@payouts = current_user.payouts
|
|
||||||
.includes(:event)
|
|
||||||
.order(created_at: :desc)
|
|
||||||
.page(params[:page])
|
|
||||||
end
|
|
||||||
|
|
||||||
# Show payout details
|
|
||||||
def show
|
|
||||||
@payout = @event.payouts.find(params[:id])
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create a new payout request
|
|
||||||
def create
|
|
||||||
# Check if event can request payout
|
|
||||||
unless @event.can_request_payout?
|
|
||||||
redirect_to promoter_event_path(@event), alert: "Payout cannot be requested for this event."
|
|
||||||
return
|
|
||||||
end
|
|
||||||
|
|
||||||
# Calculate payout amount
|
|
||||||
total_earnings_cents = @event.total_earnings_cents
|
|
||||||
total_fees_cents = @event.total_fees_cents
|
|
||||||
net_earnings_cents = @event.net_earnings_cents
|
|
||||||
|
|
||||||
# Count orders
|
|
||||||
total_orders_count = @event.orders.where(status: ['paid', 'completed']).count
|
|
||||||
refunded_orders_count = @event.tickets.where(status: 'refunded').joins(:order).where(orders: {status: ['paid', 'completed']}).count
|
|
||||||
|
|
||||||
# Create payout record
|
|
||||||
@payout = @event.payouts.build(
|
|
||||||
user: current_user,
|
|
||||||
amount_cents: total_earnings_cents,
|
|
||||||
fee_cents: total_fees_cents,
|
|
||||||
total_orders_count: total_orders_count,
|
|
||||||
refunded_orders_count: refunded_orders_count
|
|
||||||
)
|
|
||||||
|
|
||||||
if @payout.save
|
|
||||||
# Update event payout status
|
|
||||||
@event.update!(payout_status: :requested, payout_requested_at: Time.current)
|
|
||||||
|
|
||||||
redirect_to promoter_payout_path(@payout), notice: "Payout request submitted successfully."
|
|
||||||
else
|
|
||||||
redirect_to promoter_event_path(@event), alert: "Failed to submit payout request: #{@payout.errors.full_messages.join(', ')}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def ensure_promoter!
|
|
||||||
unless current_user.promoter?
|
|
||||||
redirect_to dashboard_path, alert: "Access denied."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def set_event
|
|
||||||
@event = current_user.events.find(params[:event_id])
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
module Admin::PayoutsHelper
|
|
||||||
end
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
module Promoter::PayoutsHelper
|
|
||||||
end
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
class Earning < ApplicationRecord
|
|
||||||
# === Relations ===
|
|
||||||
belongs_to :event
|
|
||||||
belongs_to :user
|
|
||||||
belongs_to :order
|
|
||||||
|
|
||||||
# === Enums ===
|
|
||||||
enum :status, { pending: 0, paid: 1 }
|
|
||||||
|
|
||||||
# === Validations ===
|
|
||||||
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :net_amount_cents, numericality: { greater_than_or_equal_to: 0, allow_nil: true }
|
|
||||||
validates :status, presence: true
|
|
||||||
validates :stripe_payout_id, allow_blank: true, uniqueness: true
|
|
||||||
end
|
|
||||||
@@ -16,27 +16,16 @@ class Event < ApplicationRecord
|
|||||||
sold_out: 3
|
sold_out: 3
|
||||||
}, default: :draft
|
}, default: :draft
|
||||||
|
|
||||||
enum :payout_status, {
|
|
||||||
not_requested: 0,
|
|
||||||
requested: 1,
|
|
||||||
processing: 2,
|
|
||||||
completed: 3,
|
|
||||||
failed: 4
|
|
||||||
}, default: :not_requested
|
|
||||||
|
|
||||||
# === Relations ===
|
# === Relations ===
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
has_many :ticket_types
|
has_many :ticket_types
|
||||||
has_many :tickets, through: :ticket_types
|
has_many :tickets, through: :ticket_types
|
||||||
has_many :orders
|
has_many :orders
|
||||||
has_many :earnings, dependent: :destroy
|
|
||||||
has_many :payouts, dependent: :destroy
|
|
||||||
|
|
||||||
# === Callbacks ===
|
# === Callbacks ===
|
||||||
before_validation :geocode_address, if: :should_geocode_address?
|
before_validation :geocode_address, if: :should_geocode_address?
|
||||||
|
|
||||||
# === Validations ===
|
# Validations for Event attributes
|
||||||
|
|
||||||
# Basic information
|
# Basic information
|
||||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||||
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
||||||
@@ -68,24 +57,6 @@ class Event < ApplicationRecord
|
|||||||
|
|
||||||
# === Instance Methods ===
|
# === Instance Methods ===
|
||||||
|
|
||||||
# Payout methods
|
|
||||||
def can_request_payout?
|
|
||||||
event_ended? && earnings.pending.any? && user.can_receive_payouts?
|
|
||||||
end
|
|
||||||
|
|
||||||
def total_earnings_cents
|
|
||||||
# Only count earnings from non-refunded tickets
|
|
||||||
earnings.pending.sum(:amount_cents)
|
|
||||||
end
|
|
||||||
|
|
||||||
def total_fees_cents
|
|
||||||
(total_earnings_cents * 0.1).to_i # 10% platform fee
|
|
||||||
end
|
|
||||||
|
|
||||||
def net_earnings_cents
|
|
||||||
total_earnings_cents - total_fees_cents
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||||
def geocoding_successful?
|
def geocoding_successful?
|
||||||
coordinates_look_valid?
|
coordinates_look_valid?
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ class Order < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :event
|
belongs_to :event
|
||||||
has_many :tickets, dependent: :destroy
|
has_many :tickets, dependent: :destroy
|
||||||
|
has_many :order_promotion_codes, dependent: :destroy
|
||||||
|
has_many :promotion_codes, through: :order_promotion_codes
|
||||||
|
|
||||||
# === Validations ===
|
# === Validations ===
|
||||||
validates :user_id, presence: true
|
validates :user_id, presence: true
|
||||||
@@ -32,7 +34,6 @@ class Order < ApplicationRecord
|
|||||||
}
|
}
|
||||||
|
|
||||||
before_validation :set_expiry, on: :create
|
before_validation :set_expiry, on: :create
|
||||||
after_update :create_earnings_if_paid, if: -> { saved_change_to_status? && status == "paid" }
|
|
||||||
|
|
||||||
# === Instance Methods ===
|
# === Instance Methods ===
|
||||||
|
|
||||||
@@ -163,17 +164,4 @@ class Order < ApplicationRecord
|
|||||||
def draft?
|
def draft?
|
||||||
status == "draft"
|
status == "draft"
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_earnings_if_paid
|
|
||||||
return unless event.present? && user.present?
|
|
||||||
return if event.earnings.exists?(order_id: id)
|
|
||||||
|
|
||||||
event.earnings.create!(
|
|
||||||
user: user,
|
|
||||||
order: self,
|
|
||||||
amount_cents: promoter_payout_cents,
|
|
||||||
fee_cents: platform_fee_cents,
|
|
||||||
status: :pending
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
26
app/models/order_promotion_code.rb
Normal file
26
app/models/order_promotion_code.rb
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
class OrderPromotionCode < ApplicationRecord
|
||||||
|
# Associations
|
||||||
|
belongs_to :order
|
||||||
|
belongs_to :promotion_code
|
||||||
|
|
||||||
|
# Validations
|
||||||
|
validates :order, presence: true
|
||||||
|
validates :promotion_code, presence: true
|
||||||
|
|
||||||
|
# Callbacks
|
||||||
|
after_create :apply_discount
|
||||||
|
after_create :increment_promotion_code_uses
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def apply_discount
|
||||||
|
# Apply the discount to the order
|
||||||
|
discount_amount = promotion_code.discount_amount_cents
|
||||||
|
order.update!(total_amount_cents: [ order.total_amount_cents - discount_amount, 0 ].max)
|
||||||
|
end
|
||||||
|
|
||||||
|
def increment_promotion_code_uses
|
||||||
|
# Increment the uses count on the promotion code
|
||||||
|
promotion_code.increment!(:uses_count)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
class Payout < ApplicationRecord
|
|
||||||
# === Relations ===
|
|
||||||
belongs_to :user
|
|
||||||
belongs_to :event
|
|
||||||
|
|
||||||
# === Enums ===
|
|
||||||
enum :status, {
|
|
||||||
pending: 0, # Payout requested but not processed
|
|
||||||
processing: 1, # Payout being processed
|
|
||||||
completed: 2, # Payout successfully completed
|
|
||||||
failed: 3 # Payout failed
|
|
||||||
}, default: :pending
|
|
||||||
|
|
||||||
# === Validations ===
|
|
||||||
validates :amount_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :fee_cents, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :status, presence: true
|
|
||||||
validates :total_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :refunded_orders_count, presence: true, numericality: { greater_than_or_equal_to: 0 }
|
|
||||||
validates :stripe_payout_id, allow_blank: true, uniqueness: true
|
|
||||||
|
|
||||||
# === Scopes ===
|
|
||||||
scope :completed, -> { where(status: :completed) }
|
|
||||||
scope :pending, -> { where(status: :pending) }
|
|
||||||
scope :processing, -> { where(status: :processing) }
|
|
||||||
|
|
||||||
# === Instance Methods ===
|
|
||||||
|
|
||||||
# Amount in euros (formatted)
|
|
||||||
def amount_euros
|
|
||||||
amount_cents / 100.0
|
|
||||||
end
|
|
||||||
|
|
||||||
# Fee in euros (formatted)
|
|
||||||
def fee_euros
|
|
||||||
fee_cents / 100.0
|
|
||||||
end
|
|
||||||
|
|
||||||
# Net amount after fees
|
|
||||||
def net_amount_cents
|
|
||||||
amount_cents - fee_cents
|
|
||||||
end
|
|
||||||
|
|
||||||
# Net amount in euros
|
|
||||||
def net_amount_euros
|
|
||||||
net_amount_cents / 100.0
|
|
||||||
end
|
|
||||||
|
|
||||||
# Check if payout can be processed
|
|
||||||
def can_process?
|
|
||||||
pending? && amount_cents > 0
|
|
||||||
end
|
|
||||||
|
|
||||||
# Process the payout through Stripe
|
|
||||||
def process_payout!
|
|
||||||
service = PayoutService.new(self)
|
|
||||||
service.process!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
23
app/models/promotion_code.rb
Normal file
23
app/models/promotion_code.rb
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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
|
||||||
|
has_many :order_promotion_codes
|
||||||
|
has_many :orders, through: :order_promotion_codes
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def increment_uses_count
|
||||||
|
self.uses_count ||= 0
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -23,8 +23,6 @@ class User < ApplicationRecord
|
|||||||
has_many :events, dependent: :destroy
|
has_many :events, dependent: :destroy
|
||||||
has_many :tickets, dependent: :destroy
|
has_many :tickets, dependent: :destroy
|
||||||
has_many :orders, dependent: :destroy
|
has_many :orders, dependent: :destroy
|
||||||
has_many :earnings, dependent: :destroy
|
|
||||||
has_many :payouts, dependent: :destroy
|
|
||||||
|
|
||||||
# Validations - allow reasonable name lengths
|
# Validations - allow reasonable name lengths
|
||||||
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||||
@@ -50,21 +48,4 @@ class User < ApplicationRecord
|
|||||||
# Alias for can_manage_events? to make views more semantic
|
# Alias for can_manage_events? to make views more semantic
|
||||||
can_manage_events?
|
can_manage_events?
|
||||||
end
|
end
|
||||||
|
|
||||||
def name
|
|
||||||
[ first_name, last_name ].compact.join(" ").strip
|
|
||||||
end
|
|
||||||
|
|
||||||
# Stripe Connect methods
|
|
||||||
def stripe_account_id
|
|
||||||
stripe_connected_account_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def has_stripe_account?
|
|
||||||
stripe_connected_account_id.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def can_receive_payouts?
|
|
||||||
has_stripe_account? && promoter?
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
class PayoutService
|
|
||||||
def initialize(payout)
|
|
||||||
@payout = payout
|
|
||||||
end
|
|
||||||
|
|
||||||
def process!
|
|
||||||
return unless @payout.can_process?
|
|
||||||
|
|
||||||
@payout.update!(status: :processing)
|
|
||||||
|
|
||||||
# Create Stripe payout
|
|
||||||
begin
|
|
||||||
stripe_payout = Stripe::Payout.create({
|
|
||||||
amount: @payout.amount_cents,
|
|
||||||
currency: 'eur',
|
|
||||||
destination: @payout.user.stripe_account_id,
|
|
||||||
description: "Payout for event: #{@payout.event.name}"
|
|
||||||
})
|
|
||||||
|
|
||||||
@payout.update!(
|
|
||||||
status: :completed,
|
|
||||||
stripe_payout_id: stripe_payout.id
|
|
||||||
)
|
|
||||||
rescue Stripe::StripeError => e
|
|
||||||
@payout.update!(status: :failed)
|
|
||||||
Rails.logger.error "Stripe payout failed for payout #{@payout.id}: #{e.message}"
|
|
||||||
raise e
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
class StripeConnectService
|
|
||||||
def self.create_account(user)
|
|
||||||
return if user.stripe_connected_account_id.present?
|
|
||||||
|
|
||||||
account = Stripe::Account.create(
|
|
||||||
type: "express",
|
|
||||||
country: "FR",
|
|
||||||
email: user.email,
|
|
||||||
capabilities: {
|
|
||||||
card_payments: { requested: true },
|
|
||||||
transfers: { requested: true }
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
user.update!(stripe_connected_account_id: account.id)
|
|
||||||
account
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.onboarding_link(user)
|
|
||||||
return unless user.stripe_connected_account_id.present?
|
|
||||||
|
|
||||||
account_link = Stripe::AccountLink.create(
|
|
||||||
account: user.stripe_connected_account_id,
|
|
||||||
refresh_url: Rails.application.routes.url_helpers.promoter_stripe_refresh_url,
|
|
||||||
return_url: Rails.application.routes.url_helpers.promoter_stripe_return_url,
|
|
||||||
type: "account_onboarding"
|
|
||||||
)
|
|
||||||
|
|
||||||
account_link.url
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.get_account_details(account_id)
|
|
||||||
Stripe::Account.retrieve(account_id)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<h1>Admin::Payouts#create</h1>
|
|
||||||
<p>Find me in app/views/admin/payouts/create.html.erb</p>
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<div class="container mx-auto px-4 py-8">
|
|
||||||
<div class="flex justify-between items-center mb-6">
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900">Admin Payouts</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @payouts.any? %>
|
|
||||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Promoter</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200">
|
|
||||||
<% @payouts.each do |payout| %>
|
|
||||||
<tr>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-medium text-gray-900"><%= payout.event.name %></div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm text-gray-900"><%= payout.user.name.presence || payout.user.email %></div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm text-gray-900">€<%= payout.amount_euros %></div>
|
|
||||||
<div class="text-sm text-gray-500">Net: €<%= payout.net_amount_euros %> (Fee: €<%= payout.fee_euros %>)</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<% case payout.status %>
|
|
||||||
<% when 'pending' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-yellow-100 text-yellow-800">
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
<% when 'processing' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
|
||||||
Processing
|
|
||||||
</span>
|
|
||||||
<% when 'completed' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800">
|
|
||||||
Completed
|
|
||||||
</span>
|
|
||||||
<% when 'failed' %>
|
|
||||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-red-100 text-red-800">
|
|
||||||
Failed
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
<%= payout.created_at.strftime("%b %d, %Y") %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<% if payout.can_process? %>
|
|
||||||
<%= button_to "Process", admin_payout_path(payout), method: :post,
|
|
||||||
class: "text-indigo-600 hover:text-indigo-900 bg-indigo-100 hover:bg-indigo-200 px-3 py-1 rounded" %>
|
|
||||||
<% end %>
|
|
||||||
<%= link_to "View", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 ml-2" %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @payouts.respond_to?(:total_pages) %>
|
|
||||||
<div class="mt-6">
|
|
||||||
<%= paginate @payouts %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<div class="bg-white rounded-lg shadow p-6 text-center">
|
|
||||||
<p class="text-gray-500">No payouts found.</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<h1>Admin::Payouts#new</h1>
|
|
||||||
<p>Find me in app/views/admin/payouts/new.html.erb</p>
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
<h1>Admin::Payouts#show</h1>
|
|
||||||
<p>Find me in app/views/admin/payouts/show.html.erb</p>
|
|
||||||
@@ -118,6 +118,16 @@
|
|||||||
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
|
<p class="text-sm text-gray-600">Procédez au paiement pour finaliser votre commande</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Promotion Code Section -->
|
||||||
|
<%= form_tag checkout_order_path(@order), method: :get, class: "mb-6" do %>
|
||||||
|
<div class="flex items-center bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||||
|
<%= text_field_tag :promotion_code, params[:promotion_code], class: "flex-1 border-none bg-transparent focus:ring-0 text-sm", placeholder: "Code promotionnel (optionnel)" %>
|
||||||
|
<%= button_tag type: "submit", class: "ml-2 btn btn-secondary py-2 px-4 text-sm" do %>
|
||||||
|
Appliquer
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<% if @checkout_session.present? %>
|
<% if @checkout_session.present? %>
|
||||||
<!-- Stripe Checkout -->
|
<!-- Stripe Checkout -->
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<!-- Promoter Dashboard Section -->
|
<!-- Promoter Dashboard Section -->
|
||||||
<% if current_user.promoter? && @promoter_events.present? %>
|
<% if current_user.promoter? && @promoter_events.present? %>
|
||||||
<!-- Promoter Metrics -->
|
<!-- Promoter Metrics -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-2xl p-6 border border-green-200">
|
<div class="bg-gradient-to-br from-green-50 to-green-100 rounded-2xl p-6 border border-green-200">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -87,19 +87,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Payout Summary -->
|
|
||||||
<div class="bg-gradient-to-br from-indigo-50 to-indigo-100 rounded-2xl p-6 border border-indigo-200">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-indigo-600 text-sm font-medium">Paiements en Attente</p>
|
|
||||||
<p class="text-2xl font-bold text-indigo-900"><%= current_user.payouts.pending.count %></p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-indigo-200 rounded-full p-3">
|
|
||||||
<i data-lucide="dollar-sign" class="w-6 h-6 text-indigo-700"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Revenue Chart & Recent Events -->
|
<!-- Revenue Chart & Recent Events -->
|
||||||
@@ -160,50 +147,10 @@
|
|||||||
<%= event.tickets.where(status: 'active').count %> billets vendus
|
<%= event.tickets.where(status: 'active').count %> billets vendus
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-wrap gap-2">
|
<div class="mt-3 flex space-x-2">
|
||||||
<%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
|
<%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
|
||||||
Gérer →
|
Gérer →
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if event.event_ended? && event.can_request_payout? %>
|
|
||||||
<% if event.payout_status == "not_requested" %>
|
|
||||||
<%= link_to "Demander le paiement", promoter_payouts_path(event_id: event.id), method: :post,
|
|
||||||
class: "text-green-600 hover:text-green-800 text-xs font-medium inline-flex items-center",
|
|
||||||
data: { confirm: "Êtes-vous sûr de vouloir demander le paiement de €#{event.net_earnings_cents / 100.0} ?" } do %>
|
|
||||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
|
||||||
Paiement
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "requested" %>
|
|
||||||
<%= link_to "Paiement demandé", promoter_payouts_path,
|
|
||||||
class: "text-yellow-600 hover:text-yellow-800 text-xs font-medium inline-flex items-center" do %>
|
|
||||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
|
||||||
En attente
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "processing" %>
|
|
||||||
<%= link_to "Paiement en cours", promoter_payouts_path,
|
|
||||||
class: "text-blue-600 hover:text-blue-800 text-xs font-medium inline-flex items-center" do %>
|
|
||||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
|
||||||
Traitement
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "completed" %>
|
|
||||||
<%= link_to "Paiement effectué", promoter_payouts_path,
|
|
||||||
class: "text-green-600 hover:text-green-800 text-xs font-medium inline-flex items-center" do %>
|
|
||||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Complété
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "failed" %>
|
|
||||||
<%= link_to "Paiement échoué", promoter_payouts_path(event_id: event.id), method: :post,
|
|
||||||
class: "text-red-600 hover:text-red-800 text-xs font-medium inline-flex items-center",
|
|
||||||
data: { confirm: "Êtes-vous sûr de vouloir redemander le paiement ?" } do %>
|
|
||||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Réessayer
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.event_ended? %>
|
|
||||||
<span class="text-gray-500 text-xs font-medium inline-flex items-center">
|
|
||||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
|
||||||
Aucun revenu
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -218,84 +165,37 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ended Events Requiring Payout -->
|
<!-- Recent Orders -->
|
||||||
<% ended_events = @promoter_events.select(&:event_ended?) %>
|
<% if @recent_orders.any? %>
|
||||||
<% if ended_events.any? %>
|
|
||||||
<div class="bg-white rounded-2xl shadow-lg mb-8">
|
<div class="bg-white rounded-2xl shadow-lg mb-8">
|
||||||
<div class="border-b border-gray-100 p-6">
|
<div class="border-b border-gray-100 p-6">
|
||||||
<div class="flex items-center justify-between">
|
<h2 class="text-xl font-bold text-gray-900">Commandes Récentes</h2>
|
||||||
<h2 class="text-xl font-bold text-gray-900">Événements Terminés</h2>
|
<p class="text-gray-600 mt-1">Dernières commandes pour vos événements</p>
|
||||||
<%= link_to "Voir tous les paiements", promoter_payouts_path, class: "text-purple-600 hover:text-purple-800 font-medium text-sm" %>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="space-y-4">
|
<div class="overflow-x-auto">
|
||||||
<% ended_events.each do |event| %>
|
<table class="w-full">
|
||||||
<div class="border border-gray-200 rounded-xl p-4 hover:shadow-md transition-shadow">
|
<thead>
|
||||||
<div class="flex items-start justify-between mb-2">
|
<tr class="text-left border-b border-gray-200">
|
||||||
<div>
|
<th class="pb-3 text-sm font-medium text-gray-600">Événement</th>
|
||||||
<h4 class="font-semibold text-gray-900 text-sm"><%= event.name %></h4>
|
<th class="pb-3 text-sm font-medium text-gray-600">Client</th>
|
||||||
<p class="text-xs text-gray-500 mt-1">Terminé le <%= event.end_time&.strftime("%d %B %Y") || event.start_time&.strftime("%d %B %Y") %></p>
|
<th class="pb-3 text-sm font-medium text-gray-600">Billets</th>
|
||||||
</div>
|
<th class="pb-3 text-sm font-medium text-gray-600">Montant</th>
|
||||||
<span class="text-xs px-2 py-1 rounded-full bg-gray-100 text-gray-800">
|
<th class="pb-3 text-sm font-medium text-gray-600">Date</th>
|
||||||
Terminé
|
</tr>
|
||||||
</span>
|
</thead>
|
||||||
</div>
|
<tbody class="divide-y divide-gray-100">
|
||||||
<div class="text-xs text-gray-600 space-y-1">
|
<% @recent_orders.each do |order| %>
|
||||||
<div class="flex items-center">
|
<tr class="hover:bg-gray-50">
|
||||||
<i data-lucide="ticket" class="w-3 h-3 mr-2"></i>
|
<td class="py-3 text-sm font-medium text-gray-900"><%= order.event.name %></td>
|
||||||
<%= event.tickets.where(status: 'active').count %> billets vendus
|
<td class="py-3 text-sm text-gray-700"><%= order.user.email %></td>
|
||||||
</div>
|
<td class="py-3 text-sm text-gray-700"><%= order.tickets.count %></td>
|
||||||
<div class="flex items-center">
|
<td class="py-3 text-sm font-medium text-gray-900">€<%= order.total_amount_euros %></td>
|
||||||
<i data-lucide="euro" class="w-3 h-3 mr-2"></i>
|
<td class="py-3 text-sm text-gray-500"><%= order.created_at.strftime("%d/%m/%Y") %></td>
|
||||||
Revenus: €<%= event.net_earnings_cents / 100.0 %>
|
</tr>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex items-center justify-between">
|
|
||||||
<%= link_to promoter_event_path(event), class: "text-purple-600 hover:text-purple-800 text-xs font-medium" do %>
|
|
||||||
Voir l'événement →
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<% if event.can_request_payout? %>
|
|
||||||
<% if event.payout_status == "not_requested" %>
|
|
||||||
<%= link_to "Demander le paiement", promoter_payouts_path(event_id: event.id), method: :post,
|
|
||||||
class: "inline-flex items-center px-3 py-1 bg-green-600 text-white text-xs font-medium rounded-lg hover:bg-green-700 transition-colors",
|
|
||||||
data: { confirm: "Êtes-vous sûr de vouloir demander le paiement de €#{event.net_earnings_cents / 100.0} ?" } do %>
|
|
||||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
|
||||||
Demander paiement
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "requested" %>
|
|
||||||
<%= link_to promoter_payouts_path, class: "inline-flex items-center px-3 py-1 bg-yellow-600 text-white text-xs font-medium rounded-lg" do %>
|
|
||||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
|
||||||
En attente
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "processing" %>
|
|
||||||
<%= link_to promoter_payouts_path, class: "inline-flex items-center px-3 py-1 bg-blue-600 text-white text-xs font-medium rounded-lg" do %>
|
|
||||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
|
||||||
En traitement
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "completed" %>
|
|
||||||
<%= link_to promoter_payouts_path, class: "inline-flex items-center px-3 py-1 bg-green-600 text-white text-xs font-medium rounded-lg" do %>
|
|
||||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Payé
|
|
||||||
<% end %>
|
|
||||||
<% elsif event.payout_status == "failed" %>
|
|
||||||
<%= link_to "Réessayer", promoter_payouts_path(event_id: event.id), method: :post,
|
|
||||||
class: "inline-flex items-center px-3 py-1 bg-red-600 text-white text-xs font-medium rounded-lg hover:bg-red-700 transition-colors",
|
|
||||||
data: { confirm: "Êtes-vous sûr de vouloir redemander le paiement ?" } do %>
|
|
||||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Réessayer
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<span class="text-gray-500 text-xs font-medium inline-flex items-center">
|
|
||||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
|
||||||
Aucun revenu
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -84,36 +84,6 @@
|
|||||||
À la une
|
À la une
|
||||||
</span>
|
</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if event.event_ended? && event.can_request_payout? %>
|
|
||||||
<% case event.payout_status %>
|
|
||||||
<% when "not_requested" %>
|
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-orange-100 text-orange-800 ml-1">
|
|
||||||
<i data-lucide="dollar-sign" class="w-3 h-3 mr-1"></i>
|
|
||||||
Paiement disponible
|
|
||||||
</span>
|
|
||||||
<% when "requested" %>
|
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-800 ml-1">
|
|
||||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
|
||||||
Paiement demandé
|
|
||||||
</span>
|
|
||||||
<% when "processing" %>
|
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-blue-100 text-blue-800 ml-1">
|
|
||||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
|
||||||
Paiement en cours
|
|
||||||
</span>
|
|
||||||
<% when "completed" %>
|
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 ml-1">
|
|
||||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Paiement effectué
|
|
||||||
</span>
|
|
||||||
<% when "failed" %>
|
|
||||||
<span class="inline-flex px-2 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-800 ml-1">
|
|
||||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Paiement échoué
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 text-sm text-gray-500">
|
<td class="px-6 py-4 text-sm text-gray-500">
|
||||||
<% if event.start_time %>
|
<% if event.start_time %>
|
||||||
|
|||||||
@@ -290,75 +290,6 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<!-- Payout section -->
|
|
||||||
<% if @event.event_ended? && @event.can_request_payout? %>
|
|
||||||
<hr class="border-gray-200">
|
|
||||||
<div class="space-y-4">
|
|
||||||
<h4 class="text-lg font-medium text-gray-900">Paiement des Revenus</h4>
|
|
||||||
|
|
||||||
<!-- Earnings Summary -->
|
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
|
||||||
<p class="text-sm text-gray-500">Revenus Bruts</p>
|
|
||||||
<p class="text-lg font-bold text-gray-900">€<%= @event.total_earnings_cents / 100.0 %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-50 rounded-lg p-4">
|
|
||||||
<p class="text-sm text-gray-500">Frais Plateforme</p>
|
|
||||||
<p class="text-lg font-bold text-gray-900">-€<%= @event.total_fees_cents / 100.0 %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="payout-summary-card">
|
|
||||||
<p class="payout-summary-label">Revenus Nets</p>
|
|
||||||
<p class="payout-summary-amount">€<%= @event.net_earnings_cents / 100.0 %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Payout Status -->
|
|
||||||
<% if @event.payout_status != "not_requested" %>
|
|
||||||
<div class="bg-blue-50 rounded-lg p-4 border border-blue-200">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<% case @event.payout_status %>
|
|
||||||
<% when "requested" %>
|
|
||||||
<i data-lucide="clock" class="w-5 h-5 text-blue-500 mr-2"></i>
|
|
||||||
<span class="font-medium text-blue-800">Paiement Demandé</span>
|
|
||||||
<% when "processing" %>
|
|
||||||
<i data-lucide="refresh-cw" class="w-5 h-5 text-blue-500 mr-2"></i>
|
|
||||||
<span class="font-medium text-blue-800">Paiement en Traitement</span>
|
|
||||||
<% when "completed" %>
|
|
||||||
<i data-lucide="check-circle" class="w-5 h-5 text-green-500 mr-2"></i>
|
|
||||||
<span class="font-medium text-green-800">Paiement Complété</span>
|
|
||||||
<% when "failed" %>
|
|
||||||
<i data-lucide="x-circle" class="w-5 h-5 text-red-500 mr-2"></i>
|
|
||||||
<span class="font-medium text-red-800">Paiement Échoué</span>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-gray-600 mt-1">Votre demande de paiement est en cours de traitement. Vous recevrez un email quand elle sera terminée.</p>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<!-- Payout Action -->
|
|
||||||
<% if @event.payout_status == "not_requested" %>
|
|
||||||
<%= button_to promoter_payouts_path(event_id: @event.id), method: :post,
|
|
||||||
data: { confirm: "Êtes-vous sûr de vouloir demander un paiement de €#{@event.net_earnings_cents / 100.0} ? Cette action ne peut pas être annulée." },
|
|
||||||
class: "payout-action-button primary" do %>
|
|
||||||
<i data-lucide="dollar-sign" class="w-5 h-5 mr-2"></i>
|
|
||||||
Demander le Paiement de €<%= @event.net_earnings_cents / 100.0 %>
|
|
||||||
<% end %>
|
|
||||||
<% elsif @event.payout_status == "failed" %>
|
|
||||||
<%= button_to promoter_payouts_path(event_id: @event.id), method: :post,
|
|
||||||
data: { confirm: "Êtes-vous sûr de vouloir demander un nouveau paiement de €#{@event.net_earnings_cents / 100.0} ?" },
|
|
||||||
class: "payout-action-button warning" do %>
|
|
||||||
<i data-lucide="refresh-ccw" class="w-5 h-5 mr-2"></i>
|
|
||||||
Réessayer le Paiement
|
|
||||||
<% end %>
|
|
||||||
<% else %>
|
|
||||||
<%= link_to "Voir les Détails du Paiement", promoter_payouts_path,
|
|
||||||
class: "payout-action-button secondary" %>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<hr class="border-gray-200">
|
<hr class="border-gray-200">
|
||||||
<%= button_to promoter_event_path(@event), method: :delete,
|
<%= 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." },
|
data: { confirm: "Êtes-vous sûr de vouloir supprimer cet événement ? Cette action est irréversible." },
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
<h1>Promoter::Payouts#create</h1>
|
|
||||||
<p>Find me in app/views/promoter/payouts/create.html.erb</p>
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
<% content_for(:title, "Payouts") %>
|
|
||||||
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4 mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-2xl sm:text-3xl font-bold text-gray-900">Payout History</h1>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">View and track all your payout requests</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary Cards -->
|
|
||||||
<% if @payouts.any? %>
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
||||||
<!-- Total Payouts -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="p-2 bg-blue-100 rounded-lg">
|
|
||||||
<i data-lucide="dollar-sign" class="w-6 h-6 text-blue-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Total Payouts</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900"><%= @payouts.count %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Total Amount -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="p-2 bg-green-100 rounded-lg">
|
|
||||||
<i data-lucide="trending-up" class="w-6 h-6 text-green-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Total Earned</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900">€<%= @payouts.sum(&:net_amount_cents) / 100.0 %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pending Payouts -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="p-2 bg-yellow-100 rounded-lg">
|
|
||||||
<i data-lucide="clock" class="w-6 h-6 text-yellow-600"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Pending</p>
|
|
||||||
<p class="text-2xl font-bold text-gray-900"><%= @payouts.pending.count %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<!-- Payouts Table -->
|
|
||||||
<% if @payouts.any? %>
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
|
||||||
<thead class="bg-gray-50">
|
|
||||||
<tr>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Event</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Amount</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Status</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Date</th>
|
|
||||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="bg-white divide-y divide-gray-200 payout-table-row">
|
|
||||||
<% @payouts.each do |payout| %>
|
|
||||||
<tr class="hover:bg-gray-50">
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="flex items-center">
|
|
||||||
<div class="flex-shrink-0 h-10 w-10 bg-gradient-to-br from-purple-500 to-pink-500 rounded-lg flex items-center justify-center">
|
|
||||||
<i data-lucide="calendar" class="h-5 w-5 text-white"></i>
|
|
||||||
</div>
|
|
||||||
<div class="ml-4">
|
|
||||||
<div class="text-sm font-medium text-gray-900"><%= payout.event&.name || "Event not found" %></div>
|
|
||||||
<div class="text-sm text-gray-500">#<%= payout.id %></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div class="text-sm font-medium text-gray-900">€<%= payout.net_amount_euros %></div>
|
|
||||||
<div class="text-sm text-gray-500">Gross: €<%= payout.amount_euros %></div>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
|
||||||
<% case payout.status %>
|
|
||||||
<% when 'pending' %>
|
|
||||||
<span class="payout-status-badge pending">
|
|
||||||
<i data-lucide="clock" class="w-3 h-3 mr-1"></i>
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
<% when 'processing' %>
|
|
||||||
<span class="payout-status-badge processing">
|
|
||||||
<i data-lucide="refresh-cw" class="w-3 h-3 mr-1"></i>
|
|
||||||
Processing
|
|
||||||
</span>
|
|
||||||
<% when 'completed' %>
|
|
||||||
<span class="payout-status-badge completed">
|
|
||||||
<i data-lucide="check-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Completed
|
|
||||||
</span>
|
|
||||||
<% when 'failed' %>
|
|
||||||
<span class="payout-status-badge failed">
|
|
||||||
<i data-lucide="x-circle" class="w-3 h-3 mr-1"></i>
|
|
||||||
Failed
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
<%= payout.created_at.strftime("%b %d, %Y") %>
|
|
||||||
</td>
|
|
||||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
|
||||||
<%= link_to "View Details", promoter_payout_path(payout), class: "text-indigo-600 hover:text-indigo-900 font-medium" %>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<% end %>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Pagination -->
|
|
||||||
<% if @payouts.respond_to?(:total_pages) %>
|
|
||||||
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
|
||||||
<%= paginate @payouts %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<% else %>
|
|
||||||
<!-- Empty State -->
|
|
||||||
<div class="payout-empty-state bg-white rounded-xl shadow-sm border border-gray-200">
|
|
||||||
<div class="payout-empty-state-icon">
|
|
||||||
<i data-lucide="dollar-sign" class="h-8 w-8 text-gray-400"></i>
|
|
||||||
</div>
|
|
||||||
<h3 class="payout-empty-state-title">No payouts yet</h3>
|
|
||||||
<p class="payout-empty-state-description">You haven't requested any payouts yet. When your events end, you'll be able to request payouts here.</p>
|
|
||||||
<%= link_to "View My Events", promoter_events_path, class: "inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<% content_for(:title, "Payout Details") %>
|
|
||||||
|
|
||||||
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="payout-detail-header">
|
|
||||||
<div>
|
|
||||||
<h1 class="payout-detail-title">Payout Details</h1>
|
|
||||||
<p class="mt-1 text-sm text-gray-500">Payout request for <%= @payout.event&.name || "Unknown Event" %></p>
|
|
||||||
</div>
|
|
||||||
<%= link_to "← Back to Payouts", promoter_payouts_path, class: "inline-flex items-center px-4 py-2 border border-gray-300 rounded-md shadow-sm text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500" %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Progress -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-8">
|
|
||||||
<h2 class="text-lg font-medium text-gray-900 mb-4">Payout Status</h2>
|
|
||||||
<div class="payout-status-progress">
|
|
||||||
<!-- Steps -->
|
|
||||||
<div class="payout-status-step">
|
|
||||||
<div class="payout-status-step-icon <%= @payout.status == 'pending' ? 'pending' : 'completed' %>">
|
|
||||||
<% if @payout.status == 'pending' %>
|
|
||||||
<i data-lucide="clock" class="w-4 h-4"></i>
|
|
||||||
<% else %>
|
|
||||||
<i data-lucide="check" class="w-4 h-4"></i>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<p class="payout-status-step-label">Requested</p>
|
|
||||||
<p class="payout-status-step-date"><%= @payout.created_at.strftime("%b %d, %Y") %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="payout-status-step">
|
|
||||||
<div class="payout-status-step-icon <%= @payout.status == 'processing' ? 'processing' : (@payout.status == 'completed' || @payout.status == 'failed') ? 'completed' : 'incomplete' %>">
|
|
||||||
<% if @payout.status == 'processing' %>
|
|
||||||
<i data-lucide="refresh-cw" class="w-4 h-4"></i>
|
|
||||||
<% elsif @payout.status == 'completed' || @payout.status == 'failed' %>
|
|
||||||
<i data-lucide="check" class="w-4 h-4"></i>
|
|
||||||
<% else %>
|
|
||||||
<i data-lucide="circle" class="w-4 h-4"></i>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<p class="payout-status-step-label">Processing</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="payout-status-step">
|
|
||||||
<div class="payout-status-step-icon <%= @payout.status == 'completed' ? 'completed' : (@payout.status == 'failed' ? 'failed' : 'incomplete') %>">
|
|
||||||
<% if @payout.status == 'completed' %>
|
|
||||||
<i data-lucide="check-circle" class="w-4 h-4"></i>
|
|
||||||
<% elsif @payout.status == 'failed' %>
|
|
||||||
<i data-lucide="x-circle" class="w-4 h-4"></i>
|
|
||||||
<% else %>
|
|
||||||
<i data-lucide="circle" class="w-4 h-4"></i>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
<p class="payout-status-step-label">Completed</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Summary Card -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Gross Amount</p>
|
|
||||||
<p class="mt-1 text-2xl font-bold text-gray-900">€<%= @payout.amount_euros %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
|
||||||
<p class="text-sm font-medium text-gray-500">Platform Fees</p>
|
|
||||||
<p class="mt-1 text-2xl font-bold text-gray-900">-€<%= @payout.fee_euros %></p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="payout-summary-card">
|
|
||||||
<p class="payout-summary-label">Net Amount</p>
|
|
||||||
<p class="payout-summary-amount">€<%= @payout.net_amount_euros %></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Details -->
|
|
||||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
|
|
||||||
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
|
||||||
<h3 class="text-lg font-medium text-gray-900">Payout Information</h3>
|
|
||||||
<p class="mt-1 max-w-2xl text-sm text-gray-500">Details about this payout request</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="divide-y divide-gray-200">
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Event</dt>
|
|
||||||
<dd class="payout-detail-value">
|
|
||||||
<div class="payout-event-card">
|
|
||||||
<div class="payout-event-icon">
|
|
||||||
<i data-lucide="calendar" class="h-4 w-4"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="payout-event-name"><%= @payout.event&.name || "Event not found" %></div>
|
|
||||||
<div class="payout-event-id">Event #<%= @payout.event&.id %></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Status</dt>
|
|
||||||
<dd class="payout-detail-value">
|
|
||||||
<% case @payout.status %>
|
|
||||||
<% when 'pending' %>
|
|
||||||
<span class="payout-status-badge pending">
|
|
||||||
<i data-lucide="clock" class="w-4 h-4 mr-1"></i>
|
|
||||||
Pending
|
|
||||||
</span>
|
|
||||||
<% when 'processing' %>
|
|
||||||
<span class="payout-status-badge processing">
|
|
||||||
<i data-lucide="refresh-cw" class="w-4 h-4 mr-1"></i>
|
|
||||||
Processing
|
|
||||||
</span>
|
|
||||||
<% when 'completed' %>
|
|
||||||
<span class="payout-status-badge completed">
|
|
||||||
<i data-lucide="check-circle" class="w-4 h-4 mr-1"></i>
|
|
||||||
Completed
|
|
||||||
</span>
|
|
||||||
<% when 'failed' %>
|
|
||||||
<span class="payout-status-badge failed">
|
|
||||||
<i data-lucide="x-circle" class="w-4 h-4 mr-1"></i>
|
|
||||||
Failed
|
|
||||||
</span>
|
|
||||||
<% end %>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Gross Amount</dt>
|
|
||||||
<dd class="payout-detail-value amount">€<%= @payout.amount_euros %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Platform Fees</dt>
|
|
||||||
<dd class="payout-detail-value amount">-€<%= @payout.fee_euros %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Net Amount</dt>
|
|
||||||
<dd class="payout-detail-value net-amount">€<%= @payout.net_amount_euros %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Total Orders</dt>
|
|
||||||
<dd class="payout-detail-value"><%= @payout.total_orders_count %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Refunded Orders</dt>
|
|
||||||
<dd class="payout-detail-value"><%= @payout.refunded_orders_count %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Requested Date</dt>
|
|
||||||
<dd class="payout-detail-value"><%= @payout.created_at.strftime("%B %d, %Y at %I:%M %p") %></dd>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<% if @payout.stripe_payout_id.present? %>
|
|
||||||
<div class="px-4 py-5 sm:px-6 payout-detail-item">
|
|
||||||
<dt class="payout-detail-label">Stripe Payout ID</dt>
|
|
||||||
<dd class="payout-detail-value font-mono text-xs break-all"><%= @payout.stripe_payout_id %></dd>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
namespace :admin do
|
|
||||||
resources :payouts, only: [ :index, :create ]
|
|
||||||
end
|
|
||||||
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
|
||||||
|
|
||||||
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
# Reveal health status on /up that returns 200 if the app boots with no exceptions, otherwise 500.
|
||||||
@@ -78,7 +75,6 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
# === Promoter Routes ===
|
# === Promoter Routes ===
|
||||||
namespace :promoter do
|
namespace :promoter do
|
||||||
resources :payouts, only: [:index, :show, :create]
|
|
||||||
resources :events do
|
resources :events do
|
||||||
member do
|
member do
|
||||||
patch :publish
|
patch :publish
|
||||||
|
|||||||
@@ -52,9 +52,6 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
|
|||||||
# Add onboarding check on user model
|
# Add onboarding check on user model
|
||||||
t.boolean :onboarding_completed, default: false, null: false
|
t.boolean :onboarding_completed, default: false, null: false
|
||||||
|
|
||||||
# Link user to Stripe account for promoter payout
|
|
||||||
t.string :stripe_connected_account_id
|
|
||||||
|
|
||||||
t.timestamps null: false
|
t.timestamps null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -63,7 +60,5 @@ class DeviseCreateUsers < ActiveRecord::Migration[8.0]
|
|||||||
# add_index :users, :confirmation_token, unique: true
|
# add_index :users, :confirmation_token, unique: true
|
||||||
# add_index :users, :unlock_token, unique: true
|
# add_index :users, :unlock_token, unique: true
|
||||||
# add_index :users, :stripe_customer_id
|
# add_index :users, :stripe_customer_id
|
||||||
add_index :users, :stripe_connected_account_id, unique: true
|
|
||||||
# add_index :stripe_connected_account_id_on_users, :stripe_connected_account_id ?
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -22,16 +22,11 @@ class CreateEvents < ActiveRecord::Migration[8.0]
|
|||||||
# Allow ticket sell during the event
|
# Allow ticket sell during the event
|
||||||
t.boolean :allow_booking_during_event, default: false, null: false
|
t.boolean :allow_booking_during_event, default: false, null: false
|
||||||
|
|
||||||
# Payout fields
|
|
||||||
t.integer :payout_status
|
|
||||||
t.datetime :payout_requested_at
|
|
||||||
|
|
||||||
t.timestamps
|
t.timestamps
|
||||||
end
|
end
|
||||||
|
|
||||||
add_index :events, :state
|
add_index :events, :state
|
||||||
add_index :events, :featured
|
add_index :events, :featured
|
||||||
add_index :events, [ :latitude, :longitude ]
|
add_index :events, [ :latitude, :longitude ]
|
||||||
add_index :events, :payout_status
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
class CreateEarnings < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
create_table :earnings do |t|
|
|
||||||
t.integer :amount_cents
|
|
||||||
t.integer :fee_cents
|
|
||||||
t.integer :status
|
|
||||||
t.string :stripe_payout_id
|
|
||||||
|
|
||||||
t.integer :net_amount_cents
|
|
||||||
|
|
||||||
t.references :event, null: false, foreign_key: false, index: true
|
|
||||||
t.references :user, null: false, foreign_key: false, index: true
|
|
||||||
t.references :order, null: false, foreign_key: false, index: true
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
class CreatePayouts < ActiveRecord::Migration[8.0]
|
|
||||||
def change
|
|
||||||
create_table :payouts do |t|
|
|
||||||
t.integer :amount_cents, null: false
|
|
||||||
t.integer :fee_cents, null: false, default: 0
|
|
||||||
t.integer :status, null: false, default: 0
|
|
||||||
t.string :stripe_payout_id
|
|
||||||
t.integer :total_orders_count, null: false, default: 0
|
|
||||||
t.integer :refunded_orders_count, null: false, default: 0
|
|
||||||
|
|
||||||
t.references :user, null: false, foreign_key: false
|
|
||||||
t.references :event, null: false, foreign_key: false
|
|
||||||
|
|
||||||
t.timestamps
|
|
||||||
end
|
|
||||||
|
|
||||||
add_index :payouts, :status
|
|
||||||
add_index :payouts, :stripe_payout_id, unique: true
|
|
||||||
end
|
|
||||||
end
|
|
||||||
16
db/migrate/20250928180837_create_promotion_codes.rb
Normal file
16
db/migrate/20250928180837_create_promotion_codes.rb
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class CreatePromotionCodes < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :promotion_codes do |t|
|
||||||
|
t.string :code, null: false, unique: true
|
||||||
|
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
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
t.datetime :updated_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :promotion_codes, :code, unique: true
|
||||||
|
end
|
||||||
|
end
|
||||||
10
db/migrate/20250928181311_create_order_promotion_codes.rb
Normal file
10
db/migrate/20250928181311_create_order_promotion_codes.rb
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
class CreateOrderPromotionCodes < ActiveRecord::Migration[8.0]
|
||||||
|
def change
|
||||||
|
create_table :order_promotion_codes do |t|
|
||||||
|
t.references :order, null: false, foreign_key: true
|
||||||
|
t.references :promotion_code, null: false, foreign_key: true
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
42
db/schema.rb
generated
42
db/schema.rb
generated
@@ -10,23 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
ActiveRecord::Schema[8.0].define(version: 2025_09_11_063815) do
|
||||||
create_table "earnings", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
|
||||||
t.integer "amount_cents"
|
|
||||||
t.integer "fee_cents"
|
|
||||||
t.integer "status"
|
|
||||||
t.string "stripe_payout_id"
|
|
||||||
t.integer "net_amount_cents"
|
|
||||||
t.bigint "event_id", null: false
|
|
||||||
t.bigint "user_id", null: false
|
|
||||||
t.bigint "order_id", null: false
|
|
||||||
t.datetime "created_at", null: false
|
|
||||||
t.datetime "updated_at", null: false
|
|
||||||
t.index ["event_id"], name: "index_earnings_on_event_id"
|
|
||||||
t.index ["order_id"], name: "index_earnings_on_order_id"
|
|
||||||
t.index ["user_id"], name: "index_earnings_on_user_id"
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "events", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "slug", null: false
|
t.string "slug", null: false
|
||||||
@@ -41,14 +25,11 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
|||||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||||
t.boolean "featured", default: false, null: false
|
t.boolean "featured", default: false, null: false
|
||||||
t.bigint "user_id", null: false
|
t.bigint "user_id", null: false
|
||||||
t.boolean "allow_booking_during_event", default: false, null: false
|
|
||||||
t.integer "payout_status"
|
|
||||||
t.datetime "payout_requested_at"
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.boolean "allow_booking_during_event", default: false, null: false
|
||||||
t.index ["featured"], name: "index_events_on_featured"
|
t.index ["featured"], name: "index_events_on_featured"
|
||||||
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude"
|
t.index ["latitude", "longitude"], name: "index_events_on_latitude_and_longitude"
|
||||||
t.index ["payout_status"], name: "index_events_on_payout_status"
|
|
||||||
t.index ["state"], name: "index_events_on_state"
|
t.index ["state"], name: "index_events_on_state"
|
||||||
t.index ["user_id"], name: "index_events_on_user_id"
|
t.index ["user_id"], name: "index_events_on_user_id"
|
||||||
end
|
end
|
||||||
@@ -70,23 +51,6 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
|||||||
t.index ["user_id"], name: "index_orders_on_user_id"
|
t.index ["user_id"], name: "index_orders_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "payouts", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
|
||||||
t.integer "amount_cents", null: false
|
|
||||||
t.integer "fee_cents", default: 0, null: false
|
|
||||||
t.integer "status", default: 0, null: false
|
|
||||||
t.string "stripe_payout_id"
|
|
||||||
t.integer "total_orders_count", default: 0, null: false
|
|
||||||
t.integer "refunded_orders_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 ["event_id"], name: "index_payouts_on_event_id"
|
|
||||||
t.index ["status"], name: "index_payouts_on_status"
|
|
||||||
t.index ["stripe_payout_id"], name: "index_payouts_on_stripe_payout_id", unique: true
|
|
||||||
t.index ["user_id"], name: "index_payouts_on_user_id"
|
|
||||||
end
|
|
||||||
|
|
||||||
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
create_table "ticket_types", charset: "utf8mb4", collation: "utf8mb4_uca1400_ai_ci", force: :cascade do |t|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
t.text "description"
|
t.text "description"
|
||||||
@@ -135,11 +99,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_09_16_221454) do
|
|||||||
t.string "company_website"
|
t.string "company_website"
|
||||||
t.string "stripe_customer_id"
|
t.string "stripe_customer_id"
|
||||||
t.boolean "onboarding_completed", default: false, null: false
|
t.boolean "onboarding_completed", default: false, null: false
|
||||||
t.string "stripe_connected_account_id"
|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
t.index ["stripe_connected_account_id"], name: "index_users_on_stripe_connected_account_id", unique: true
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
151
db/seeds.rb
151
db/seeds.rb
@@ -122,157 +122,6 @@ promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u|
|
|||||||
u.is_professionnal = true
|
u.is_professionnal = true
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a completed event with earnings for payout demonstration
|
|
||||||
completed_event_promoter = User.find_or_create_by!(email: "kbataille@vivaldi.net") do |u|
|
|
||||||
u.password = "password"
|
|
||||||
u.password_confirmation = "password"
|
|
||||||
u.first_name = "Event"
|
|
||||||
u.last_name = "Promoter"
|
|
||||||
u.is_professionnal = true
|
|
||||||
# Ensure the promoter has a Stripe account for payouts
|
|
||||||
u.stripe_connected_account_id = "acct_test_payout_account" unless u.stripe_connected_account_id.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
completed_event = Event.find_or_create_by!(name: "Completed Music Festival") do |e|
|
|
||||||
e.slug = "completed-music-festival"
|
|
||||||
e.state = :published
|
|
||||||
e.description = "An amazing music festival that has already taken place."
|
|
||||||
e.venue_name = "Central Park"
|
|
||||||
e.venue_address = "Central Park, New York, NY"
|
|
||||||
e.latitude = 40.7812
|
|
||||||
e.longitude = -73.9665
|
|
||||||
# Set the event to have ended 2 days ago
|
|
||||||
e.start_time = 2.days.ago
|
|
||||||
e.end_time = 2.days.ago + 8.hours
|
|
||||||
e.featured = false
|
|
||||||
e.image = "https://data.bizouk.com/cache1/events/images/10/78/87/b801a9a43266b4cc54bdda73bf34eec8_700_800_auto_97.jpg"
|
|
||||||
e.user = completed_event_promoter
|
|
||||||
# Ensure payout status is not_requested
|
|
||||||
e.payout_status = :not_requested
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create ticket types for the completed event
|
|
||||||
general_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "General Admission") do |tt|
|
|
||||||
tt.description = "General admission ticket for the Completed Music Festival"
|
|
||||||
tt.price_cents = 5000 # $50.00
|
|
||||||
tt.quantity = 200
|
|
||||||
tt.sale_start_at = 1.month.ago
|
|
||||||
tt.sale_end_at = completed_event.start_time - 1.hour
|
|
||||||
tt.minimum_age = 18
|
|
||||||
end
|
|
||||||
|
|
||||||
vip_ticket_type = TicketType.find_or_create_by!(event: completed_event, name: "VIP") do |tt|
|
|
||||||
tt.description = "VIP access ticket for the Completed Music Festival"
|
|
||||||
tt.price_cents = 15000 # $150.00
|
|
||||||
tt.quantity = 50
|
|
||||||
tt.sale_start_at = 1.month.ago
|
|
||||||
tt.sale_end_at = completed_event.start_time - 1.hour
|
|
||||||
tt.minimum_age = 21
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create some orders and tickets for the completed event to generate earnings
|
|
||||||
buyer_user = User.find_or_create_by!(email: "buyer@example.com") do |u|
|
|
||||||
u.password = "password"
|
|
||||||
u.password_confirmation = "password"
|
|
||||||
u.first_name = "Ticket"
|
|
||||||
u.last_name = "Buyer"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create multiple orders with different statuses to demonstrate the payout system
|
|
||||||
# Order 1: Paid order with general admission tickets
|
|
||||||
order1 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
|
|
||||||
o.status = "paid"
|
|
||||||
o.total_amount_cents = 15000 # $150.00 for 3 general admission tickets ($50.00 each)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create tickets for order 1
|
|
||||||
3.times do |i|
|
|
||||||
Ticket.find_or_create_by!(order: order1, ticket_type: general_ticket_type) do |t|
|
|
||||||
t.qr_code = "ORDER1-TICKET#{i + 1}"
|
|
||||||
t.price_cents = 5000 # $50.00
|
|
||||||
t.status = "active"
|
|
||||||
t.first_name = "Attendee"
|
|
||||||
t.last_name = "#{i + 1}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
|
|
||||||
# For 3 tickets at $50.00 each:
|
|
||||||
# Fixed fee: 3 tickets × $0.50 = $1.50 (150 cents)
|
|
||||||
# Percentage fee: 3 tickets × ($50.00 × 1.5%) = 3 × $0.75 = $2.25 (225 cents)
|
|
||||||
# Total platform fee: $1.50 + $2.25 = $3.75 (375 cents)
|
|
||||||
# Promoter payout: $150.00 - $3.75 = $146.25 (14625 cents)
|
|
||||||
|
|
||||||
# Create earnings for this paid order (this would normally happen automatically)
|
|
||||||
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order1) do |e|
|
|
||||||
e.amount_cents = 14625 # $146.25 (promoter payout after fees)
|
|
||||||
e.fee_cents = 375 # $3.75 platform fee
|
|
||||||
e.status = "pending"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Order 2: Paid order with VIP tickets
|
|
||||||
order2 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
|
|
||||||
o.status = "paid"
|
|
||||||
o.total_amount_cents = 30000 # $300.00 for 2 VIP tickets ($150.00 each)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create tickets for order 2
|
|
||||||
2.times do |i|
|
|
||||||
Ticket.find_or_create_by!(order: order2, ticket_type: vip_ticket_type) do |t|
|
|
||||||
t.qr_code = "ORDER2-TICKET#{i + 1}"
|
|
||||||
t.price_cents = 15000 # $150.00
|
|
||||||
t.status = "active"
|
|
||||||
t.first_name = "VIP"
|
|
||||||
t.last_name = "Attendee #{i + 1}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
|
|
||||||
# For 2 tickets at $150.00 each:
|
|
||||||
# Fixed fee: 2 tickets × $0.50 = $1.00 (100 cents)
|
|
||||||
# Percentage fee: 2 tickets × ($150.00 × 1.5%) = 2 × $2.25 = $4.50 (450 cents)
|
|
||||||
# Total platform fee: $1.00 + $4.50 = $5.50 (550 cents)
|
|
||||||
# Promoter payout: $300.00 - $5.50 = $294.50 (29450 cents)
|
|
||||||
|
|
||||||
# Create earnings for this paid order (this would normally happen automatically)
|
|
||||||
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order2) do |e|
|
|
||||||
e.amount_cents = 29450 # $294.50 (promoter payout after fees)
|
|
||||||
e.fee_cents = 550 # $5.50 platform fee
|
|
||||||
e.status = "pending"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Order 3: Refunded order to demonstrate that refunded tickets are excluded
|
|
||||||
order3 = Order.find_or_create_by!(user: buyer_user, event: completed_event) do |o|
|
|
||||||
o.status = "paid"
|
|
||||||
o.total_amount_cents = 5000 # $50.00 for 1 general admission ticket
|
|
||||||
end
|
|
||||||
|
|
||||||
# Create ticket for order 3 (will be refunded)
|
|
||||||
refunded_ticket = Ticket.find_or_create_by!(order: order3, ticket_type: general_ticket_type) do |t|
|
|
||||||
t.qr_code = "ORDER3-TICKET1"
|
|
||||||
t.price_cents = 5000 # $50.00
|
|
||||||
t.status = "refunded" # This ticket was refunded
|
|
||||||
t.first_name = "Refunded"
|
|
||||||
t.last_name = "Customer"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Calculate platform fees using the actual model: €0.50 + 1.5% per ticket
|
|
||||||
# For 1 ticket at $50.00:
|
|
||||||
# Fixed fee: 1 ticket × $0.50 = $0.50 (50 cents)
|
|
||||||
# Percentage fee: 1 ticket × ($50.00 × 1.5%) = $0.75 (75 cents)
|
|
||||||
# Total platform fee: $0.50 + $0.75 = $1.25 (125 cents)
|
|
||||||
# Promoter payout: $50.00 - $1.25 = $48.75 (4875 cents)
|
|
||||||
|
|
||||||
# Create earnings for this refunded order (this would normally happen automatically)
|
|
||||||
Earning.find_or_create_by!(event: completed_event, user: completed_event_promoter, order: order3) do |e|
|
|
||||||
e.amount_cents = 4875 # $48.75 (promoter payout after fees)
|
|
||||||
e.fee_cents = 125 # $1.25 platform fee
|
|
||||||
e.status = "pending"
|
|
||||||
end
|
|
||||||
|
|
||||||
puts "Created 1 completed event with sample orders and earnings for payout demonstration"
|
|
||||||
|
|
||||||
|
|
||||||
belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS") do |e|
|
belle_epoque_event = Event.find_or_create_by!(name: "LA BELLE ÉPOQUE PAR SISLEY ÉVENTS") do |e|
|
||||||
e.slug = "la-belle-epoque-par-sisley-events"
|
e.slug = "la-belle-epoque-par-sisley-events"
|
||||||
e.state = :draft
|
e.state = :draft
|
||||||
|
|||||||
65
docker-compose.production.yml
Normal file
65
docker-compose.production.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
volumes:
|
||||||
|
mariadb_data:
|
||||||
|
gem_home:
|
||||||
|
node_modules_home:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
|
||||||
|
services:
|
||||||
|
mariadb:
|
||||||
|
image: mariadb:11.7.2-noble
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- mariadb_data:/var/lib/mysql
|
||||||
|
#- ./mysql:/var/lib/mysql"
|
||||||
|
ports:
|
||||||
|
- "${FORWARD_DB_PORT:-3306}:3306"
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||||
|
# MYSQL_ROOT_HOST: "%"
|
||||||
|
MYSQL_PORT: "${DB_PORT:-3306}"
|
||||||
|
MYSQL_DATABASE: "${DB_DATABASE:-aperonight}"
|
||||||
|
MYSQL_USER: "${DB_USERNAME:-aperonight}"
|
||||||
|
MYSQL_PASSWORD: "${DB_PASSWORD:-aperonight}"
|
||||||
|
#MYSQL_ALLOW_EMPTY_PASSWORD: 1
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
#command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
|
||||||
|
|
||||||
|
rails:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.production
|
||||||
|
env_file: .env
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./:/rails
|
||||||
|
- gem_home:/usr/local/bundle
|
||||||
|
- node_modules_home:/rails/node_modules
|
||||||
|
#- ./log:/var/log
|
||||||
|
#- ./log/supervisor:/var/log/supervisor
|
||||||
|
ports:
|
||||||
|
#- 80:80
|
||||||
|
- 3000:3000
|
||||||
|
#- 5000:5000
|
||||||
|
depends_on:
|
||||||
|
- mariadb
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
#tty: true
|
||||||
|
#command: /opt/src/bin/dev
|
||||||
|
|
||||||
|
mailhog:
|
||||||
|
image: corpusops/mailhog:v1.0.1
|
||||||
|
restart: unless-stopped
|
||||||
|
# environment:
|
||||||
|
# - "mh_auth_file=/opt/mailhog/passwd.conf"
|
||||||
|
volumes:
|
||||||
|
- ./data/mailhog:/opt/mailhog
|
||||||
|
ports:
|
||||||
|
- 1025:1025 # smtp port 25
|
||||||
|
- 8025:8025 # web mail access
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Promoter Payouts Architecture
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
To handle promoter payouts in the Rails app (where promoters are users creating events), track all order payments in the database for auditing and fee calculation. Save payments (e.g., via Stripe webhooks) and apply platform fees per order processed—e.g., promoter gets 90% of ticket revenue minus your fee, stored in a new `earnings` table linked to events/orders.
|
|
||||||
|
|
||||||
## Recommended Architecture
|
|
||||||
|
|
||||||
### 1. Models & DB
|
|
||||||
- Add `has_many :earnings, dependent: :destroy` to `Event` and `User` models.
|
|
||||||
- Create `Earnings` model: `belongs_to :event, :user; fields: amount_cents (Decimal), fee_cents (Decimal), status (enum: pending/paid), stripe_payout_id (String), order_id (ref)`.
|
|
||||||
- On order payment success (in your Stripe webhook or after_create callback on Order), create Earnings record: `earnings = event.earnings.create!(amount_cents: total_revenue_cents * 0.9, fee_cents: total_revenue_cents * 0.1, status: :pending, order: order)`.
|
|
||||||
|
|
||||||
### 2. Payout Processing
|
|
||||||
- Use Stripe Connect (setup promoter Stripe accounts via `account_links` in user onboarding).
|
|
||||||
- Create a `PayoutService`: Batch pending earnings per promoter, transfer via `Stripe::Transfer.create` to their connected account, update status to `:paid`.
|
|
||||||
- Run via cron job (e.g., in `lib/tasks/payouts.rake`) or admin-triggered job.
|
|
||||||
|
|
||||||
### 3. Admin Dashboard for Due Payouts
|
|
||||||
- Add admin routes: `resources :admin, only: [] do; resources :payouts; end` in `config/routes.rb`.
|
|
||||||
- Controller: `Admin::PayoutsController` with `index` action querying `Earnings.pending.where(user_id: params[:promoter_id]).group_by(&:user).sum(:amount_cents)`.
|
|
||||||
- View: Table showing promoter name, total due, unpaid earnings list; button to trigger payout.
|
|
||||||
- Use Pundit or CanCanCan for admin-only access (add `is_admin?` to User).
|
|
||||||
|
|
||||||
This ensures transparency, scalability, and easy auditing. Start by migrating the Earnings model: `rails g model Earnings event:references user:references order:references amount_cents:decimal fee_cents:decimal status:integer stripe_payout_id:string`. Test with Stripe test mode.
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace :payouts do
|
|
||||||
desc "Process all pending promoter payouts"
|
|
||||||
task process: :environment do
|
|
||||||
PayoutService.new.process_pending_payouts
|
|
||||||
puts "Pending payouts processed."
|
|
||||||
end
|
|
||||||
end
|
|
||||||
66
test/controllers/orders_controller_promotion_test.rb
Normal file
66
test/controllers/orders_controller_promotion_test.rb
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class OrdersControllerPromotionTest < ActionDispatch::IntegrationTest
|
||||||
|
include Devise::Test::IntegrationHelpers
|
||||||
|
|
||||||
|
# Setup test data
|
||||||
|
def setup
|
||||||
|
@user = users(:one)
|
||||||
|
@event = events(:one)
|
||||||
|
@order = orders(:one)
|
||||||
|
sign_in @user
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test applying a valid promotion code
|
||||||
|
def test_apply_valid_promotion_code
|
||||||
|
promotion_code = PromotionCode.create(
|
||||||
|
code: "TESTDISCOUNT",
|
||||||
|
discount_amount_cents: 1000, # €10.00
|
||||||
|
expires_at: 1.month.from_now,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
get checkout_order_path(@order), params: { promotion_code: "TESTDISCOUNT" }
|
||||||
|
assert_response :success
|
||||||
|
assert_not_nil flash[:notice]
|
||||||
|
assert_match /Code promotionnel appliqué: TESTDISCOUNT/, flash[:notice]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test applying an invalid promotion code
|
||||||
|
def test_apply_invalid_promotion_code
|
||||||
|
get checkout_order_path(@order), params: { promotion_code: "INVALIDCODE" }
|
||||||
|
assert_response :success
|
||||||
|
assert_not_nil flash[:alert]
|
||||||
|
assert_equal "Code promotionnel invalide", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test applying an expired promotion code
|
||||||
|
def test_apply_expired_promotion_code
|
||||||
|
promotion_code = PromotionCode.create(
|
||||||
|
code: "EXPIREDCODE",
|
||||||
|
discount_amount_cents: 1000,
|
||||||
|
expires_at: 1.day.ago,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
get checkout_order_path(@order), params: { promotion_code: "EXPIREDCODE" }
|
||||||
|
assert_response :success
|
||||||
|
assert_not_nil flash[:alert]
|
||||||
|
assert_equal "Code promotionnel invalide", flash[:alert]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test applying an inactive promotion code
|
||||||
|
def test_apply_inactive_promotion_code
|
||||||
|
promotion_code = PromotionCode.create(
|
||||||
|
code: "INACTIVECODE",
|
||||||
|
discount_amount_cents: 1000,
|
||||||
|
expires_at: 1.month.from_now,
|
||||||
|
active: false
|
||||||
|
)
|
||||||
|
|
||||||
|
get checkout_order_path(@order), params: { promotion_code: "INACTIVECODE" }
|
||||||
|
assert_response :success
|
||||||
|
assert_not_nil flash[:alert]
|
||||||
|
assert_equal "Code promotionnel invalide", flash[:alert]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
|
|
||||||
class Promoter::PayoutsControllerTest < ActionDispatch::IntegrationTest
|
|
||||||
setup do
|
|
||||||
@user = users(:one)
|
|
||||||
@event = events(:concert_event)
|
|
||||||
@payout = payouts(:one)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should get index" do
|
|
||||||
sign_in @user
|
|
||||||
# Make the user a promoter
|
|
||||||
@user.update(is_professionnal: true)
|
|
||||||
get promoter_payouts_url
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should get show" do
|
|
||||||
sign_in @user
|
|
||||||
# Make the user a promoter
|
|
||||||
@user.update(is_professionnal: true)
|
|
||||||
# Create a payout that belongs to the user
|
|
||||||
payout = Payout.create!(
|
|
||||||
user: @user,
|
|
||||||
event: @event,
|
|
||||||
amount_cents: 1000,
|
|
||||||
fee_cents: 100
|
|
||||||
)
|
|
||||||
get promoter_payout_url(payout)
|
|
||||||
assert_response :success
|
|
||||||
end
|
|
||||||
|
|
||||||
test "should create payout" do
|
|
||||||
sign_in @user
|
|
||||||
# Make the user a promoter
|
|
||||||
@user.update(is_professionnal: true)
|
|
||||||
# Make the user the owner of the event
|
|
||||||
@event.update(user: @user)
|
|
||||||
# Make the event end in the past
|
|
||||||
@event.update(end_time: 1.day.ago)
|
|
||||||
# Create some earnings for the event
|
|
||||||
@event.earnings.create!(
|
|
||||||
user: @user,
|
|
||||||
order: orders(:paid_order),
|
|
||||||
amount_cents: 1000,
|
|
||||||
fee_cents: 100,
|
|
||||||
status: :pending
|
|
||||||
)
|
|
||||||
assert_difference('Payout.count', 1) do
|
|
||||||
post promoter_payouts_url, params: { event_id: @event.id }
|
|
||||||
end
|
|
||||||
assert_redirected_to promoter_payout_path(Payout.last)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
19
test/fixtures/earnings.yml
vendored
19
test/fixtures/earnings.yml
vendored
@@ -1,19 +0,0 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
|
||||||
|
|
||||||
one:
|
|
||||||
event: concert_event
|
|
||||||
user: users(one)
|
|
||||||
order: paid_order
|
|
||||||
amount_cents: 9000 # €90.00
|
|
||||||
fee_cents: 1000 # €10.00
|
|
||||||
status: pending
|
|
||||||
stripe_payout_id:
|
|
||||||
|
|
||||||
two:
|
|
||||||
event: winter_gala
|
|
||||||
user: users(two)
|
|
||||||
order: expired_order
|
|
||||||
amount_cents: 4500 # €45.00
|
|
||||||
fee_cents: 500 # €5.00
|
|
||||||
status: paid
|
|
||||||
stripe_payout_id: payout_123
|
|
||||||
15
test/fixtures/payouts.yml
vendored
15
test/fixtures/payouts.yml
vendored
@@ -1,15 +0,0 @@
|
|||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
|
||||||
|
|
||||||
one:
|
|
||||||
user: one
|
|
||||||
event: one
|
|
||||||
amount_cents: 10000
|
|
||||||
fee_cents: 1000
|
|
||||||
status: pending
|
|
||||||
|
|
||||||
two:
|
|
||||||
user: two
|
|
||||||
event: two
|
|
||||||
amount_cents: 20000
|
|
||||||
fee_cents: 2000
|
|
||||||
status: completed
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
require "test_helper"
|
|
||||||
|
|
||||||
class EarningTest < ActiveSupport::TestCase
|
|
||||||
setup do
|
|
||||||
@user = users(:one) || User.create!(email: "test@example.com", password: "password")
|
|
||||||
@event = events(:concert_event) || Event.create!(name: "Test Event", slug: "test-event", description: "Description", venue_name: "Venue", venue_address: "Address", latitude: 48.8566, longitude: 2.3522, start_time: Time.current, user: @user)
|
|
||||||
@order = orders(:paid_order) || Order.create!(user: @user, event: @event, status: "paid", total_amount_cents: 10000)
|
|
||||||
@earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
|
|
||||||
end
|
|
||||||
|
|
||||||
test "valid earning" do
|
|
||||||
assert @earning.valid?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "amount_cents must be present and non-negative" do
|
|
||||||
@earning.amount_cents = nil
|
|
||||||
assert_not @earning.valid?
|
|
||||||
assert_includes @earning.errors[:amount_cents], "can't be blank"
|
|
||||||
|
|
||||||
@earning.amount_cents = -1
|
|
||||||
assert_not @earning.valid?
|
|
||||||
assert_includes @earning.errors[:amount_cents], "must be greater than or equal to 0"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "fee_cents must be present and non-negative" do
|
|
||||||
@earning.fee_cents = nil
|
|
||||||
assert_not @earning.valid?
|
|
||||||
assert_includes @earning.errors[:fee_cents], "can't be blank"
|
|
||||||
|
|
||||||
@earning.fee_cents = -1
|
|
||||||
assert_not @earning.valid?
|
|
||||||
assert_includes @earning.errors[:fee_cents], "must be greater than or equal to 0"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "status must be present" do
|
|
||||||
@earning.status = nil
|
|
||||||
assert_not @earning.valid?
|
|
||||||
assert_includes @earning.errors[:status], "can't be blank"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "stripe_payout_id must be unique if present" do
|
|
||||||
@earning.stripe_payout_id = "test_payout"
|
|
||||||
@earning.save!
|
|
||||||
|
|
||||||
duplicate = @earning.dup
|
|
||||||
duplicate.stripe_payout_id = "test_payout"
|
|
||||||
|
|
||||||
assert_not duplicate.valid?
|
|
||||||
assert_includes duplicate.errors[:stripe_payout_id], "has already been taken"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "belongs to associations" do
|
|
||||||
assert_instance_of Event, @earning.event
|
|
||||||
assert_instance_of User, @earning.user
|
|
||||||
assert_instance_of Order, @earning.order
|
|
||||||
end
|
|
||||||
|
|
||||||
test "status enum" do
|
|
||||||
assert_equal 0, Earning.statuses[:pending]
|
|
||||||
assert_equal 1, Earning.statuses[:paid]
|
|
||||||
|
|
||||||
assert @earning.pending?
|
|
||||||
assert_not @earning.paid?
|
|
||||||
|
|
||||||
@earning.status = :paid
|
|
||||||
@earning.save!
|
|
||||||
assert @earning.paid?
|
|
||||||
assert_not @earning.pending?
|
|
||||||
end
|
|
||||||
|
|
||||||
test "pending scope from enum" do
|
|
||||||
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
|
|
||||||
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
|
|
||||||
|
|
||||||
assert_includes Earning.pending, pending_earning
|
|
||||||
assert_not_includes Earning.pending, paid_earning
|
|
||||||
end
|
|
||||||
|
|
||||||
test "paid scope from enum" do
|
|
||||||
pending_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 9000, fee_cents: 1000, status: :pending)
|
|
||||||
paid_earning = Earning.create!(event: @event, user: @user, order: @order, amount_cents: 4500, fee_cents: 500, status: :paid)
|
|
||||||
|
|
||||||
assert_not_includes Earning.paid, pending_earning
|
|
||||||
assert_includes Earning.paid, paid_earning
|
|
||||||
end
|
|
||||||
end
|
|
||||||
67
test/models/promotion_code_test.rb
Normal file
67
test/models/promotion_code_test.rb
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class PromotionCodeTest < ActiveSupport::TestCase
|
||||||
|
# Test valid promotion code creation
|
||||||
|
def test_valid_promotion_code
|
||||||
|
promotion_code = PromotionCode.create(
|
||||||
|
code: "DISCOUNT10",
|
||||||
|
discount_amount_cents: 1000, # €10.00
|
||||||
|
expires_at: 1.month.from_now,
|
||||||
|
active: true
|
||||||
|
)
|
||||||
|
|
||||||
|
assert promotion_code.valid?
|
||||||
|
assert_equal "DISCOUNT10", promotion_code.code
|
||||||
|
assert_equal 1000, promotion_code.discount_amount_cents
|
||||||
|
assert promotion_code.active?
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test validation for required fields
|
||||||
|
def test_validation_for_required_fields
|
||||||
|
promotion_code = PromotionCode.new
|
||||||
|
refute promotion_code.valid?
|
||||||
|
assert_not_nil promotion_code.errors[:code]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test unique code validation
|
||||||
|
def test_unique_code_validation
|
||||||
|
PromotionCode.create(code: "UNIQUE123", discount_amount_cents: 500)
|
||||||
|
duplicate_code = PromotionCode.new(code: "UNIQUE123", discount_amount_cents: 500)
|
||||||
|
refute duplicate_code.valid?
|
||||||
|
assert_not_nil duplicate_code.errors[:code]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test discount amount validation
|
||||||
|
def test_discount_amount_validation
|
||||||
|
promotion_code = PromotionCode.new(code: "VALID123", discount_amount_cents: -100)
|
||||||
|
refute promotion_code.valid?
|
||||||
|
assert_not_nil promotion_code.errors[:discount_amount_cents]
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test active scope
|
||||||
|
def test_active_scope
|
||||||
|
active_code = PromotionCode.create(code: "ACTIVE123", discount_amount_cents: 500, active: true)
|
||||||
|
inactive_code = PromotionCode.create(code: "INACTIVE123", discount_amount_cents: 500, active: false)
|
||||||
|
|
||||||
|
assert_includes PromotionCode.active, active_code
|
||||||
|
refute_includes PromotionCode.active, inactive_code
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test expired scope
|
||||||
|
def test_expired_scope
|
||||||
|
expired_code = PromotionCode.create(code: "EXPIRED123", discount_amount_cents: 500, expires_at: 1.day.ago)
|
||||||
|
future_code = PromotionCode.create(code: "FUTURE123", discount_amount_cents: 500, expires_at: 1.month.from_now)
|
||||||
|
|
||||||
|
assert_includes PromotionCode.expired, expired_code
|
||||||
|
refute_includes PromotionCode.expired, future_code
|
||||||
|
end
|
||||||
|
|
||||||
|
# Test valid scope
|
||||||
|
def test_valid_scope
|
||||||
|
valid_code = PromotionCode.create(code: "VALID123", discount_amount_cents: 500, active: true, expires_at: 1.month.from_now)
|
||||||
|
invalid_code = PromotionCode.create(code: "INVALID123", discount_amount_cents: 500, active: false, expires_at: 1.day.ago)
|
||||||
|
|
||||||
|
assert_includes PromotionCode.valid, valid_code
|
||||||
|
refute_includes PromotionCode.valid, invalid_code
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user