Compare commits
227 Commits
ed659c423e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 580b24bbed | |||
| a8d3bc12ae | |||
|
|
b228d5a174 | ||
|
|
61ad8c64d4 | ||
|
|
4e06f91acb | ||
|
|
28eddb22ab | ||
|
|
a34eb7aa38 | ||
|
|
aa68885b84 | ||
|
|
c1dde7914c | ||
|
|
dbb972e490 | ||
|
|
049e5505ef | ||
|
|
d6184b6c84 | ||
|
|
4cde466f9a | ||
|
|
ee43996a77 | ||
|
|
f0d32bf3f1 | ||
|
|
20f926cd7a | ||
|
|
d1ef962f74 | ||
|
|
e84d9aad5b | ||
|
|
24126eb834 | ||
|
|
9a1976b6af | ||
|
|
a8c7e82507 | ||
|
|
889afd0d01 | ||
|
|
82f0fab1f5 | ||
|
|
91e6425c1e | ||
|
|
f54742b041 | ||
|
|
21919c813e | ||
|
|
8ecfc7bf99 | ||
|
|
28ef801c9a | ||
|
|
55b39e93bf | ||
|
|
9bebdef5a5 | ||
|
|
d5c0276fcc | ||
|
|
10c93fff2f | ||
|
|
332827c6da | ||
|
|
46d042b85e | ||
|
|
48ec78197b | ||
|
|
31009560c2 | ||
|
|
16c277d0a9 | ||
|
|
6951efdc85 | ||
|
|
d1fb766fef | ||
|
|
a69faf0582 | ||
|
|
9b0228e7ee | ||
|
|
c5c64a87b8 | ||
|
|
4b671a211b | ||
|
|
e4d778355e | ||
|
|
eefa6c3ce2 | ||
|
|
fb447f175f | ||
|
|
ab436d8c5c | ||
|
|
6b47114015 | ||
|
|
39636039f5 | ||
|
|
5fa31f4311 | ||
|
|
070e8d0f2a | ||
|
|
89bda03f45 | ||
|
|
935974b70a | ||
|
|
f0de3dac8a | ||
|
|
9336d974ba | ||
|
|
0ede98efa4 | ||
|
|
1a7fb818df | ||
|
|
9b5d8fcf97 | ||
|
|
748f839346 | ||
|
|
83e76f71bf | ||
|
|
20ae3de7a3 | ||
|
|
6d2a6ed027 | ||
|
|
60b7bc6aa7 | ||
|
|
8d2127fce2 | ||
|
|
2fb0e1fdbb | ||
|
|
cc03bfad49 | ||
|
|
3250a6f25d | ||
|
|
ca35abe01d | ||
|
|
f2448383d4 | ||
|
|
9be7a01d93 | ||
|
|
569303b631 | ||
|
|
259837622a | ||
|
|
cf34c9c7a6 | ||
|
|
1261efc4c8 | ||
|
|
a101885d87 | ||
|
|
0b6eec0c7b | ||
|
|
8f9795d773 | ||
|
|
d1308bc988 | ||
|
|
758d461c1a | ||
|
|
67d3bcde5b | ||
|
|
bc214867b0 | ||
|
|
4bc40967c8 | ||
|
|
039ae7d1f8 | ||
|
|
f285d689b4 | ||
|
|
5581718ece | ||
|
|
b74fd49816 | ||
|
|
8ad2194d48 | ||
|
|
94d1145668 | ||
|
|
dc228b18ba | ||
|
|
38fc0059ea | ||
|
|
11340e5e58 | ||
|
|
ceb5a13297 | ||
|
|
7694e50fa0 | ||
|
|
e86b84ba61 | ||
|
|
f1750cb887 | ||
|
|
2aae7fe8ea | ||
|
|
b8efa1e26d | ||
|
|
9e6c48dc5c | ||
|
|
6e3413a128 | ||
|
|
0a3a913f66 | ||
|
|
dcaa83e756 | ||
|
|
213a11e731 | ||
|
|
ce0752bbda | ||
|
|
e983b68834 | ||
|
|
d5326c7dc6 | ||
|
|
fdad3bfb7b | ||
|
|
c3f5d72a91 | ||
|
|
241256e373 | ||
|
|
7f36abbcec | ||
|
|
73eefdd7bd | ||
|
|
29f1d75969 | ||
|
|
340f655102 | ||
|
|
974edce238 | ||
|
|
7009245ab0 | ||
|
|
a984243fe2 | ||
|
|
01b545c83e | ||
|
|
cb0de11de1 | ||
|
|
1daeee0eb1 | ||
|
|
ff32b6f21c | ||
|
|
8544802b7f | ||
|
|
0abf8d9aa9 | ||
|
|
da420ccd76 | ||
|
|
24a4560634 | ||
|
|
ed5ff4b8fd | ||
|
|
ffd9d31c94 | ||
|
|
eee7855d36 | ||
|
|
ea7517457a | ||
|
|
6d3ee7e400 | ||
|
|
15e3c7dff5 | ||
|
|
46c8faf10c | ||
|
|
a3689948ae | ||
|
|
d18c1a7b3e | ||
|
|
a0e53325f7 | ||
|
|
61079c8171 | ||
|
|
e1edc1afcd | ||
|
|
bd6c0d5ed8 | ||
|
|
5fc790cd42 | ||
|
|
ec5095d372 | ||
|
|
31f5d2188d | ||
|
|
e866e259bb | ||
|
|
54e99c2f7e | ||
|
|
3ba5710d8f | ||
|
|
0f6d75b1e8 | ||
|
|
ee4399aa46 | ||
|
|
839120f2f4 | ||
|
|
6965eb89fd | ||
|
|
0ba6634e99 | ||
|
|
ca81d2360c | ||
|
|
afe074c8a1 | ||
|
|
e838e91162 | ||
|
|
aa5dccb508 | ||
|
|
3414057795 | ||
|
|
1acc3e09d4 | ||
|
|
48c648e2ca | ||
|
|
b493027c86 | ||
|
|
6ea3005a65 | ||
|
|
476438c5c4 | ||
|
|
055640b73e | ||
|
|
a7e83d79d7 | ||
|
|
9404f10c93 | ||
|
|
907e51fc60 | ||
|
|
56b0a45719 | ||
|
|
58dbcf3a6a | ||
|
|
394190ace8 | ||
|
|
2a2c249a54 | ||
|
|
3fa9249bc8 | ||
|
|
b9576b91f5 | ||
|
|
be3d80e541 | ||
|
|
0b58768a24 | ||
|
|
911e821948 | ||
|
|
2fd93dc3bf | ||
|
|
a3dce5c363 | ||
|
|
7cdb9f468c | ||
|
|
4e2445198f | ||
|
|
49ad935855 | ||
|
|
1989cbf6af | ||
|
|
784d5158b4 | ||
|
|
b2d1cb5fa4 | ||
|
|
74f8350abe | ||
|
|
96734480d5 | ||
|
|
f6675bd5e4 | ||
|
|
a8a8c55041 | ||
|
|
9513867614 | ||
|
|
30f3ecc6ad | ||
|
|
2f80fe8321 | ||
|
|
e084839289 | ||
|
|
81bd30a7af | ||
|
|
a66f3dd17a | ||
|
|
6f31f99def | ||
|
|
7c7db939a2 | ||
|
|
1806c875b5 | ||
|
|
70ad65707e | ||
|
|
b9b7ac4ba1 | ||
|
|
cc7d71ad69 | ||
|
|
062820873f | ||
|
|
c226adc36c | ||
|
|
0879b3c924 | ||
|
|
884c6a8262 | ||
|
|
6b37c67b47 | ||
|
|
a43c47a5db | ||
|
|
a0f008bbd4 | ||
|
|
6385c39c10 | ||
|
|
6fbd24e36e | ||
|
|
632055c44d | ||
|
|
03717dc95b | ||
|
|
7f4aded5aa | ||
|
|
74484597d9 | ||
|
|
a558f7fc9a | ||
|
|
1c7a62acde | ||
|
|
98efdb44ac | ||
|
|
5454e23220 | ||
|
|
71b5d43e89 | ||
|
|
c7d28dca71 | ||
|
|
b5b5ca1cc0 | ||
|
|
ef9cfd6cdf | ||
|
|
74a1c446c4 | ||
|
|
1324463c2f | ||
|
|
3c6389c6e2 | ||
|
|
2927a8344f | ||
|
|
ad97a36650 | ||
|
|
07a6694883 | ||
|
|
ba3f36a5e8 | ||
|
|
17e6711299 | ||
|
|
31534b2c0c | ||
|
|
87af76f078 | ||
|
|
9ec13c7d3d | ||
|
|
6a1f1cca9a |
777
.cursor/rules/design.mdc
Executable file
777
.cursor/rules/design.mdc
Executable file
@@ -0,0 +1,777 @@
|
||||
---
|
||||
description: Use this rule when asked to do any frontend or UI design
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
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)
|
||||
|
||||
---
|
||||
description: Use this rule when asked to do any frontend or UI design
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
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)
|
||||
0
.dockerignore
Normal file → Executable file
0
.dockerignore
Normal file → Executable file
64
.editorconfig
Executable file
64
.editorconfig
Executable file
@@ -0,0 +1,64 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
# Change these settings to your own preference
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# We recommend you to have these uncommented (set to true).
|
||||
# If you want to support older versions of Ruby, set this to 1.9
|
||||
# ruby_version = 2.7
|
||||
# If you want to support older versions of JavaScript, set this to 5
|
||||
# javascript_version = 6
|
||||
|
||||
# Extend from global settings
|
||||
[*.{rb,erb}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{js,jsx}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{json,json5,jsonc}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{css,scss,less}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{html,htm,erb}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.{md,markdown}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
51
.env.example
Executable file
51
.env.example
Executable file
@@ -0,0 +1,51 @@
|
||||
# Application data
|
||||
RAILS_ENV=production
|
||||
SECRET_KEY_BASE=a3f5c6e7b8d9e0f1a2b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1u2v3w4x5y6z7
|
||||
DEVISE_SECRET_KEY=your_devise_secret_key_here
|
||||
APP_NAME=Aperonight
|
||||
|
||||
# Database Configuration for production and development
|
||||
# DB_HOST=127.0.0.1
|
||||
# DB_PORT=3306
|
||||
DB_ROOT_PASSWORD=root
|
||||
DB_DATABASE=aperonight
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=root
|
||||
|
||||
# Test database
|
||||
DB_TEST_DATABASE=aperonight_test
|
||||
DB_TEST_USERNAME=root
|
||||
DB_TEST_USERNAME=root
|
||||
|
||||
# Mailer Configuration (for Devise and tests)
|
||||
MAILER_DEFAULT_URL_OPTIONS=http://localhost:3000
|
||||
# Test environment will use MailHog by default on 127.0.0.1:1025
|
||||
SMTP_ADDRESS=127.0.0.1
|
||||
SMTP_PORT=1025
|
||||
# Optional auth (usually not required for MailHog)
|
||||
# SMTP_USER_NAME=
|
||||
# SMTP_PASSWORD=
|
||||
# SMTP_DOMAIN=localhost
|
||||
SMTP_AUTHENTICATION=plain
|
||||
SMTP_ENABLE_STARTTLS=false
|
||||
# SMTP_STARTTLS=true
|
||||
|
||||
# Invoice Emitter Configuration
|
||||
INVOICE_COMPANY_NAME=AperoNight
|
||||
INVOICE_COMPANY_ADDRESS_LINE_1=123 Avenue des Événements
|
||||
INVOICE_COMPANY_ADDRESS_LINE_2=75000 Paris, France
|
||||
INVOICE_COMPANY_EMAIL=contact@apero-night.fr
|
||||
INVOICE_COMPANY_PHONE=
|
||||
INVOICE_COMPANY_WEBSITE=
|
||||
INVOICE_COMPANY_VAT_NUMBER=
|
||||
INVOICE_COMPANY_SIRET=
|
||||
|
||||
# Application variables
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_51S1M7BJWx6G2LLIXYpTvi0hxMpZ4tZSxkmr2Wbp1dQ73MKNp4Tyu4xFJBqLXK5nn4E0nEf2tdgJqEwWZLosO3QGn00kMvjXWGW
|
||||
STRIPE_SECRET_KEY=sk_test_51S1M7BJWx6G2LLIXK2pdLpRKb9Mgd3sZ30N4ueVjHepgxQKbWgMVJoa4v4ESzHQ6u6zJjO4jUvgLYPU1QLyAiFTN00sGz2ortW
|
||||
STRIPE_WEBHOOK_SECRET=LaReunion974
|
||||
|
||||
# Scaleway login
|
||||
OPENAI_API_KEY=f66dbb5f-9770-4f81-b2ea-eb7370bc9aa5
|
||||
OPENAI_BASE_URL=https://api.scaleway.ai/v1
|
||||
OPENAI_MODEL=devstral-small-2505
|
||||
0
.gitattributes
vendored
Normal file → Executable file
0
.gitattributes
vendored
Normal file → Executable file
93
.gitea/workflows/ai-code-review.yaml.old
Normal file
93
.gitea/workflows/ai-code-review.yaml.old
Normal file
@@ -0,0 +1,93 @@
|
||||
name: AI Code Review
|
||||
run-name: AI Code Review by @${{ github.actor }} 🤖
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
jobs:
|
||||
ai-review:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get PR diff
|
||||
id: diff
|
||||
run: |
|
||||
# Get the diff for the PR
|
||||
git fetch origin ${{ github.base_ref }}
|
||||
DIFF=$(git diff origin/${{ github.base_ref }}...HEAD)
|
||||
echo "diff<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$DIFF" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: AI Code Review
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
# Or use ANTHROPIC_API_KEY for Claude
|
||||
# ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
run: |
|
||||
# Install dependencies
|
||||
pip install openai requests
|
||||
|
||||
# Create review script
|
||||
cat > review.py << 'EOF'
|
||||
import os
|
||||
import openai
|
||||
import requests
|
||||
import json
|
||||
|
||||
# Configure OpenAI client (or use Anthropic client for Claude)
|
||||
client = openai.OpenAI(api_key=os.environ['OPENAI_API_KEY'])
|
||||
|
||||
# Get diff from environment
|
||||
diff = """${{ steps.diff.outputs.diff }}"""
|
||||
|
||||
if not diff.strip():
|
||||
print("No changes to review")
|
||||
exit(0)
|
||||
|
||||
# Create review prompt
|
||||
prompt = f"""
|
||||
Please review this code diff and provide constructive feedback:
|
||||
|
||||
{diff}
|
||||
|
||||
Focus on:
|
||||
- Code quality and best practices
|
||||
- Potential bugs or security issues
|
||||
- Performance considerations
|
||||
- Maintainability and readability
|
||||
- Ruby on Rails specific patterns
|
||||
|
||||
Provide your review as structured feedback with specific line references where possible.
|
||||
"""
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4", # or "claude-3-sonnet" for Claude
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
max_tokens=2000
|
||||
)
|
||||
|
||||
review = response.choices[0].message.content
|
||||
print("AI Code Review:")
|
||||
print("=" * 50)
|
||||
print(review)
|
||||
|
||||
# Post review as PR comment (requires additional API setup)
|
||||
# This would need Gitea API integration
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during review: {e}")
|
||||
EOF
|
||||
|
||||
python review.py
|
||||
|
||||
- name: Comment on PR
|
||||
if: always()
|
||||
run: |
|
||||
echo "Review completed - implement Gitea API integration to post comments"
|
||||
98
.gitea/workflows/ci-mariadb.yaml
Normal file
98
.gitea/workflows/ci-mariadb.yaml
Normal file
@@ -0,0 +1,98 @@
|
||||
name: Ruby on Rails Test
|
||||
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
|
||||
#on: [push]
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
rails-test:
|
||||
runs-on: ubuntu-22.04
|
||||
services:
|
||||
mariadb:
|
||||
image: mariadb:11.7.2-noble
|
||||
env:
|
||||
MYSQL_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||
MYSQL_DATABASE: "${DB_DATABASE:-aperonight_test}"
|
||||
MYSQL_USER: "${DB_USERNAME:-aperonight}"
|
||||
MYSQL_PASSWORD: "${DB_PASSWORD:-aperonight}"
|
||||
# RUNNER_TOOL_CACHE: /toolcache
|
||||
#ports:
|
||||
# - "3306:3306"
|
||||
#options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
|
||||
options: >-
|
||||
--health-cmd="healthcheck.sh --connect --innodb_initialized"
|
||||
--health-interval=10s
|
||||
--health-timeout=5s
|
||||
--health-retries=3
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
DB_HOST: mariadb
|
||||
DB_ROOT_PASSWORD: "${DB_ROOT_PASSWORD:-root}"
|
||||
DB_DATABASE: "${DB_DATABASE:-aperonight_test}"
|
||||
DB_USERNAME: "${DB_USERNAME:-root}"
|
||||
DB_PASSWORD: "${DB_PASSWORD:-root}"
|
||||
RUNNER_TOOL_CACHE: /toolcache # https://about.gitea.com/resources/tutorials/enable-gitea-actions-cache-to-accelerate-cicd
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
echo "📦 Installing dependencies..."
|
||||
gem install bundler
|
||||
bundle install --jobs 4 --retry 3
|
||||
npm install -g yarn
|
||||
yarn install
|
||||
echo "📦 Dependencies installed!"
|
||||
|
||||
- name: Cache bundle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bundle
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||
restore-keys: |-
|
||||
${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/node_modules
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |-
|
||||
${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Run migrations
|
||||
run: |
|
||||
echo "🔄 Running migrations..."
|
||||
bundle exec rails db:drop
|
||||
bundle exec rails db:setup
|
||||
bundle exec rails db:migrate
|
||||
echo "🔄 Migrations complete!"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
echo "🧪 Running tests..."
|
||||
bundle exec rails test
|
||||
echo "🧪 Tests complete!"
|
||||
|
||||
- name: Run linter
|
||||
run: |
|
||||
echo "🚫 Running linter..."
|
||||
bundle exec rubocop
|
||||
echo "🚫 Linter complete!"
|
||||
82
.gitea/workflows/ci-sqlite.yaml.old
Normal file
82
.gitea/workflows/ci-sqlite.yaml.old
Normal file
@@ -0,0 +1,82 @@
|
||||
name: Ruby on Rails Test
|
||||
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
rails-test:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
# SQLite does not require these variables, but you can keep them for consistency
|
||||
DB_TEST_ADAPTER: "sqlite3"
|
||||
DB_TEST_DATABASE: "data/test.sqlite" # Default SQLite database file path
|
||||
DB_TEST_USERNAME: "root"
|
||||
DB_TEST_PASSWORD: "root"
|
||||
RUNNER_TOOL_CACHE: /toolcache # Optional, for caching
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
echo "📦 Installing dependencies..."
|
||||
gem install bundler
|
||||
bundle install --jobs 4 --retry 3
|
||||
npm install -g yarn
|
||||
yarn install
|
||||
echo "📦 Dependencies installed!"
|
||||
|
||||
- name: Cache bundle
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
/usr/local/bundle
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||
restore-keys: |-
|
||||
${{ runner.os }}-${{ hashFiles('**/Gemfile.lock') }}
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/node_modules
|
||||
key: ${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: |-
|
||||
${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
|
||||
|
||||
- name: Run migrations
|
||||
run: |
|
||||
echo "🔄 Running migrations..."
|
||||
bundle exec rails db:drop
|
||||
bundle exec rails db:setup
|
||||
bundle exec rails db:migrate
|
||||
echo "🔄 Migrations complete!"
|
||||
|
||||
- name: Run tests
|
||||
run: |
|
||||
echo "🧪 Running tests..."
|
||||
bundle exec rails test
|
||||
echo "🧪 Tests complete!"
|
||||
|
||||
- name: Run linter
|
||||
run: |
|
||||
echo "🚫 Running linter..."
|
||||
bundle exec rubocop
|
||||
echo "🚫 Linter complete!"
|
||||
45
.gitea/workflows/linter.yaml
Normal file
45
.gitea/workflows/linter.yaml
Normal file
@@ -0,0 +1,45 @@
|
||||
name: Ruby on Rails Test
|
||||
run-name: Deploy to ${{ inputs.deploy_target }} by @${{ github.actor }} 🚀
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
rails-test:
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
RAILS_ENV: test
|
||||
# SQLite does not require these variables, but you can keep them for consistency
|
||||
DB_TEST_ADAPTER: "sqlite3"
|
||||
DB_TEST_DATABASE: "data/test.sqlite" # Default SQLite database file path
|
||||
DB_TEST_USERNAME: "root"
|
||||
DB_TEST_PASSWORD: "root"
|
||||
RUNNER_TOOL_CACHE: /toolcache # Optional, for caching
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Ruby
|
||||
uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: .ruby-version # Not needed with a .ruby-version, .tool-versions or mise.toml
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
echo "📦 Installing dependencies..."
|
||||
gem install bundler
|
||||
bundle install --jobs 4 --retry 3
|
||||
npm install -g yarn
|
||||
yarn install
|
||||
echo "📦 Dependencies installed!"
|
||||
|
||||
- name: Run linter
|
||||
run: |
|
||||
echo "🚫 Running linter..."
|
||||
bundle exec rubocop
|
||||
echo "🚫 Linter complete!"
|
||||
0
.github/dependabot.yml
vendored
Normal file → Executable file
0
.github/dependabot.yml
vendored
Normal file → Executable file
0
.github/workflows/ci.yml
vendored
Normal file → Executable file
0
.github/workflows/ci.yml
vendored
Normal file → Executable file
11
.gitignore
vendored
Normal file → Executable file
11
.gitignore
vendored
Normal file → Executable file
@@ -8,7 +8,7 @@
|
||||
/.bundle
|
||||
|
||||
# Ignore all environment files.
|
||||
/.env*
|
||||
/.env
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
@@ -37,3 +37,12 @@
|
||||
!/app/assets/builds/.keep
|
||||
|
||||
/node_modules
|
||||
|
||||
# Docker shared directory
|
||||
/data
|
||||
|
||||
# Ignore generated reports
|
||||
/test/reports
|
||||
|
||||
# Ignore .fuse file
|
||||
.fuse_hidden*
|
||||
0
.kamal/secrets
Normal file → Executable file
0
.kamal/secrets
Normal file → Executable file
0
.node-version
Normal file → Executable file
0
.node-version
Normal file → Executable file
0
.rubocop.yml
Normal file → Executable file
0
.rubocop.yml
Normal file → Executable file
0
.ruby-version
Normal file → Executable file
0
.ruby-version
Normal file → Executable file
804
.superdesign/design_iterations/aperonight_design_system.css
Normal file
804
.superdesign/design_iterations/aperonight_design_system.css
Normal file
@@ -0,0 +1,804 @@
|
||||
/**
|
||||
* Aperonight Design System
|
||||
* Generated from homepage analysis
|
||||
* A modern, professional design system for event platforms
|
||||
*/
|
||||
|
||||
/* === ROOT VARIABLES === */
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--brand-primary: #667eea;
|
||||
--brand-secondary: #764ba2;
|
||||
--brand-accent: #facc15; /* yellow-400 */
|
||||
--brand-accent-dark: #eab308; /* yellow-500 */
|
||||
|
||||
/* Neutral Colors */
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d1d5db;
|
||||
--color-gray-400: #9ca3af;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #4b5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1f2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* Purple Shades */
|
||||
--color-purple-600: #9333ea;
|
||||
--color-purple-700: #7c3aed;
|
||||
--color-purple-800: #6b21a8;
|
||||
|
||||
/* Blue Shades */
|
||||
--color-blue-600: #2563eb;
|
||||
--color-blue-700: #1d4ed8;
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
--text-6xl: 3.75rem; /* 60px */
|
||||
|
||||
/* Font Weights */
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-24: 6rem; /* 96px */
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.375rem; /* 6px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 0.75rem; /* 12px */
|
||||
--radius-xl: 1rem; /* 16px */
|
||||
--radius-2xl: 1.25rem; /* 20px */
|
||||
--radius-3xl: 1.5rem; /* 24px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
|
||||
/* Gradients */
|
||||
--gradient-primary: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
|
||||
--gradient-overlay: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: all 0.2s ease;
|
||||
--transition-medium: all 0.3s ease;
|
||||
--transition-slow: all 0.5s ease;
|
||||
}
|
||||
|
||||
/* === BASE STYLES === */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-sans);
|
||||
line-height: 1.6;
|
||||
color: var(--color-gray-900);
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
|
||||
/* === TYPOGRAPHY SYSTEM === */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
.text-2xl { font-size: var(--text-2xl); }
|
||||
.text-3xl { font-size: var(--text-3xl); }
|
||||
.text-4xl { font-size: var(--text-4xl); }
|
||||
.text-5xl { font-size: var(--text-5xl); }
|
||||
.text-6xl { font-size: var(--text-6xl); }
|
||||
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
|
||||
.leading-tight { line-height: 1.25; }
|
||||
.leading-normal { line-height: 1.5; }
|
||||
.leading-relaxed { line-height: 1.625; }
|
||||
|
||||
/* === BUTTON SYSTEM === */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
border-radius: var(--radius-full);
|
||||
transition: var(--transition-fast);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-gray-900);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-gray-100);
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--color-white);
|
||||
border: 2px solid var(--color-white);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-white);
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background-color: var(--color-purple-600);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
background-color: var(--color-purple-700);
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
background-color: var(--color-gray-900);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.btn-dark:hover {
|
||||
background-color: var(--color-gray-800);
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
.btn-sm {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--space-4) var(--space-8);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* === CARD SYSTEM === */
|
||||
.card {
|
||||
background-color: var(--color-white);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-event {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-event-image {
|
||||
aspect-ratio: 4/3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-2xl);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-event-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.card-event:hover .card-event-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-event-badge {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
left: var(--space-4);
|
||||
background-color: var(--brand-accent);
|
||||
color: var(--color-gray-900);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.card-event-price {
|
||||
position: absolute;
|
||||
bottom: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--color-gray-900);
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.card-event-content {
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-event-title {
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-gray-900);
|
||||
margin-bottom: var(--space-2);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-event:hover .card-event-title {
|
||||
color: var(--color-purple-600);
|
||||
}
|
||||
|
||||
.card-event-meta {
|
||||
color: var(--color-gray-600);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.card-event-description {
|
||||
color: var(--color-gray-500);
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
max-width: 20rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* === HERO SYSTEM === */
|
||||
.hero {
|
||||
background: var(--gradient-primary);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--gradient-overlay);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-bold);
|
||||
line-height: var(--leading-tight);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: var(--text-xl);
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
margin-bottom: var(--space-8);
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.hero-accent {
|
||||
color: var(--brand-accent);
|
||||
}
|
||||
|
||||
/* Responsive Hero */
|
||||
@media (min-width: 1024px) {
|
||||
.hero-title {
|
||||
font-size: var(--text-6xl);
|
||||
}
|
||||
}
|
||||
|
||||
/* === METRICS SYSTEM === */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.metric-number {
|
||||
font-size: var(--text-4xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-purple-600);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.metric-number {
|
||||
font-size: var(--text-5xl);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--color-gray-600);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
/* === SECTION SYSTEM === */
|
||||
.section {
|
||||
padding: var(--space-16) 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-gray-900);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.section-title {
|
||||
font-size: var(--text-4xl);
|
||||
}
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: var(--text-xl);
|
||||
color: var(--color-gray-600);
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* === GRID SYSTEM === */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.grid-1 { grid-template-columns: 1fr; }
|
||||
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid-md-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-md-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid-lg-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-lg-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
/* === UTILITY CLASSES === */
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding-left: var(--space-4);
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.bg-white { background-color: var(--color-white); }
|
||||
.bg-gray-50 { background-color: var(--color-gray-50); }
|
||||
.bg-gray-900 { background-color: var(--color-gray-900); }
|
||||
|
||||
.text-white { color: var(--color-white); }
|
||||
.text-gray-600 { color: var(--color-gray-600); }
|
||||
.text-gray-900 { color: var(--color-gray-900); }
|
||||
|
||||
.rounded-full { border-radius: var(--radius-full); }
|
||||
.rounded-2xl { border-radius: var(--radius-2xl); }
|
||||
|
||||
.shadow-lg { box-shadow: var(--shadow-lg); }
|
||||
.shadow-xl { box-shadow: var(--shadow-xl); }
|
||||
|
||||
.mb-2 { margin-bottom: var(--space-2); }
|
||||
.mb-4 { margin-bottom: var(--space-4); }
|
||||
.mb-6 { margin-bottom: var(--space-6); }
|
||||
.mb-8 { margin-bottom: var(--space-8); }
|
||||
.mb-12 { margin-bottom: var(--space-12); }
|
||||
|
||||
.p-4 { padding: var(--space-4); }
|
||||
.p-6 { padding: var(--space-6); }
|
||||
.p-8 { padding: var(--space-8); }
|
||||
|
||||
.flex { display: flex; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
|
||||
.transition { transition: var(--transition-fast); }
|
||||
|
||||
.max-w-lg { max-width: 32rem; }
|
||||
.max-w-2xl { max-width: 42rem; }
|
||||
.max-w-4xl { max-width: 56rem; }
|
||||
|
||||
/* === BREADCRUMB SYSTEM === */
|
||||
.breadcrumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background-color: var(--color-white);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.breadcrumb-item a {
|
||||
color: var(--color-gray-700);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.breadcrumb-item a:hover {
|
||||
color: var(--color-purple-600);
|
||||
}
|
||||
|
||||
.breadcrumb-item:not(:last-child)::after {
|
||||
content: '';
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: var(--space-2);
|
||||
background: url("data:image/svg+xml,%3csvg fill='%234b5563' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' d='M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z' clip-rule='evenodd'/%3e%3c/svg%3e") center no-repeat;
|
||||
background-size: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--color-purple-600);
|
||||
}
|
||||
|
||||
/* === PAGE HEADER SYSTEM === */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: var(--space-8) 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-3xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
/* === EVENTS GRID SYSTEM === */
|
||||
.events-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.events-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.events-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.event-card {
|
||||
background-color: var(--color-white);
|
||||
border-radius: var(--radius-xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
transition: var(--transition-medium);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.event-card-image {
|
||||
height: 12rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.event-card:hover .event-card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.event-card-placeholder {
|
||||
height: 12rem;
|
||||
background: var(--gradient-primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-card-placeholder svg {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.event-card-content {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.event-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.event-card-title {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-gray-900);
|
||||
margin-bottom: var(--space-1);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.event-card-venue {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-gray-500);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.event-card-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) calc(var(--space-2) + var(--space-1));
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
background-color: rgba(147, 51, 234, 0.1);
|
||||
color: var(--color-purple-800);
|
||||
white-space: nowrap;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.event-card-description {
|
||||
color: var(--color-gray-600);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--space-4);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-card-price {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
.event-card-price-unavailable {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
|
||||
.event-card-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-white);
|
||||
background: var(--gradient-primary);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.event-card-link:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* === EMPTY STATE SYSTEM === */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-16) var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin: 0 auto var(--space-6);
|
||||
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1) 0%, rgba(79, 70, 229, 0.1) 100%);
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state-icon svg {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
color: var(--color-purple-600);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-gray-900);
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
color: var(--color-gray-500);
|
||||
margin-bottom: var(--space-6);
|
||||
max-width: 24rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* === PAGINATION SYSTEM === */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.pagination .page-item {
|
||||
margin: 0 var(--space-1);
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--color-gray-600);
|
||||
background-color: var(--color-white);
|
||||
border: 1px solid var(--color-gray-200);
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: var(--color-gray-50);
|
||||
border-color: var(--color-purple-300);
|
||||
color: var(--color-purple-600);
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--color-purple-600);
|
||||
border-color: var(--color-purple-600);
|
||||
color: var(--color-white);
|
||||
}
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
color: var(--color-gray-300);
|
||||
background-color: var(--color-white);
|
||||
border-color: var(--color-gray-200);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* === RESPONSIVE UTILITIES === */
|
||||
@media (max-width: 640px) {
|
||||
.sm\:flex-col { flex-direction: column; }
|
||||
.sm\:text-center { text-align: center; }
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:flex-row { flex-direction: row; }
|
||||
.sm\:flex-1 { flex: 1; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:justify-start { justify-content: flex-start; }
|
||||
.lg\:text-left { text-align: left; }
|
||||
}
|
||||
483
.superdesign/design_iterations/aperonight_design_system_1.html
Normal file
483
.superdesign/design_iterations/aperonight_design_system_1.html
Normal file
@@ -0,0 +1,483 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Aperonight Design System</title>
|
||||
<link rel="stylesheet" href="aperonight_design_system.css">
|
||||
<script src="https://unpkg.com/lucide@latest/dist/umd/lucide.min.js"></script>
|
||||
<style>
|
||||
/* Additional showcase styles */
|
||||
.showcase-section {
|
||||
padding: 3rem 0;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
}
|
||||
|
||||
.showcase-grid {
|
||||
display: grid;
|
||||
gap: 2rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.color-swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--color-gray-200);
|
||||
}
|
||||
|
||||
.color-circle {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-gray-300);
|
||||
}
|
||||
|
||||
.component-demo {
|
||||
padding: 2rem;
|
||||
background: var(--color-gray-50);
|
||||
border-radius: var(--radius-xl);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: var(--color-white);
|
||||
padding: 1rem 0;
|
||||
border-bottom: 1px solid var(--color-gray-200);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.navbar-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--color-gray-600);
|
||||
text-decoration: none;
|
||||
font-weight: var(--font-medium);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: var(--color-purple-600);
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--color-gray-900);
|
||||
}
|
||||
|
||||
/* Fixed secondary button for light backgrounds */
|
||||
.btn-secondary-alt {
|
||||
background-color: transparent;
|
||||
color: var(--color-gray-700);
|
||||
border: 2px solid var(--color-gray-300);
|
||||
}
|
||||
|
||||
.btn-secondary-alt:hover {
|
||||
background-color: var(--color-gray-100);
|
||||
color: var(--color-gray-900);
|
||||
border-color: var(--color-gray-400);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="navbar">
|
||||
<div class="container navbar-content">
|
||||
<div class="logo">Aperonight Design System</div>
|
||||
<ul class="nav-links">
|
||||
<li><a href="#colors" class="nav-link">Couleurs</a></li>
|
||||
<li><a href="#typography" class="nav-link">Typographie</a></li>
|
||||
<li><a href="#buttons" class="nav-link">Boutons</a></li>
|
||||
<li><a href="#cards" class="nav-link">Cartes</a></li>
|
||||
<li><a href="#components" class="nav-link">Composants</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="container">
|
||||
<div class="text-center">
|
||||
<h1 class="hero-title">
|
||||
Système de Design
|
||||
<span class="hero-accent">Aperonight</span>
|
||||
</h1>
|
||||
<p class="hero-subtitle">
|
||||
Un système de design moderne et cohérent pour créer des expériences exceptionnelles dans le domaine des événements après-travail.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<a href="#colors" class="btn btn-primary btn-lg">
|
||||
<i data-lucide="palette" class="w-5 h-5"></i>
|
||||
Explorer les Composants
|
||||
</a>
|
||||
<a href="#" class="btn btn-secondary btn-lg">
|
||||
<i data-lucide="download" class="w-5 h-5"></i>
|
||||
Télécharger
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Color Palette -->
|
||||
<section id="colors" class="showcase-section">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Palette de Couleurs</h2>
|
||||
<p class="section-description">
|
||||
Les couleurs de base du système Aperonight, conçues pour transmettre professionnalisme et modernité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="showcase-grid">
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold mb-4">Couleurs de Marque</h3>
|
||||
<div class="grid grid-1 gap-4">
|
||||
<div class="color-swatch">
|
||||
<div class="color-circle" style="background: #667eea;"></div>
|
||||
<div>
|
||||
<div class="font-semibold">Primary Blue</div>
|
||||
<div class="text-sm text-gray-600">#667eea</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-circle" style="background: #764ba2;"></div>
|
||||
<div>
|
||||
<div class="font-semibold">Secondary Purple</div>
|
||||
<div class="text-sm text-gray-600">#764ba2</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-circle" style="background: #facc15;"></div>
|
||||
<div>
|
||||
<div class="font-semibold">Accent Yellow</div>
|
||||
<div class="text-sm text-gray-600">#facc15</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-2xl font-semibold mb-4">Couleurs Neutres</h3>
|
||||
<div class="grid grid-1 gap-4">
|
||||
<div class="color-swatch">
|
||||
<div class="color-circle" style="background: #ffffff; border: 2px solid #e5e7eb;"></div>
|
||||
<div>
|
||||
<div class="font-semibold">White</div>
|
||||
<div class="text-sm text-gray-600">#ffffff</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-circle" style="background: #f3f4f6;"></div>
|
||||
<div>
|
||||
<div class="font-semibold">Gray 100</div>
|
||||
<div class="text-sm text-gray-600">#f3f4f6</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-circle" style="background: #4b5563;"></div>
|
||||
<div>
|
||||
<div class="font-semibold">Gray 600</div>
|
||||
<div class="text-sm text-gray-600">#4b5563</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="color-swatch">
|
||||
<div class="color-circle" style="background: #111827;"></div>
|
||||
<div>
|
||||
<div class="font-semibold">Gray 900</div>
|
||||
<div class="text-sm text-gray-600">#111827</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Typography -->
|
||||
<section id="typography" class="showcase-section bg-gray-50">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Typographie</h2>
|
||||
<p class="section-description">
|
||||
Une hiérarchie typographique claire et lisible pour tous les contenus.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="component-demo bg-white">
|
||||
<h1 class="text-6xl font-bold mb-4">Hero Title - 60px Bold</h1>
|
||||
<h2 class="text-4xl font-bold mb-4">Section Title - 36px Bold</h2>
|
||||
<h3 class="text-2xl font-semibold mb-4">Card Title - 24px Semibold</h3>
|
||||
<p class="text-xl mb-4">Large Text - 20px Regular</p>
|
||||
<p class="text-base mb-4">Body Text - 16px Regular</p>
|
||||
<p class="text-sm text-gray-600">Small Text - 14px Regular</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Buttons -->
|
||||
<section id="buttons" class="showcase-section">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Système de Boutons</h2>
|
||||
<p class="section-description">
|
||||
Différents styles de boutons pour diverses actions et hiérarchies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="component-demo">
|
||||
<div class="grid grid-md-2 gap-8">
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold mb-4">Styles Principaux</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
<button class="btn btn-primary">
|
||||
<i data-lucide="calendar"></i>
|
||||
Bouton Principal
|
||||
</button>
|
||||
<button class="btn btn-secondary-alt">
|
||||
<i data-lucide="user-plus"></i>
|
||||
Bouton Secondaire
|
||||
</button>
|
||||
<button class="btn btn-accent">
|
||||
<i data-lucide="star"></i>
|
||||
Bouton Accent
|
||||
</button>
|
||||
<button class="btn btn-dark">
|
||||
<i data-lucide="arrow-right"></i>
|
||||
Bouton Sombre
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-xl font-semibold mb-4">Tailles</h3>
|
||||
<div class="flex flex-col gap-4">
|
||||
<button class="btn btn-primary btn-sm">
|
||||
<i data-lucide="eye"></i>
|
||||
Petit Bouton
|
||||
</button>
|
||||
<button class="btn btn-primary">
|
||||
<i data-lucide="calendar"></i>
|
||||
Bouton Normal
|
||||
</button>
|
||||
<button class="btn btn-primary btn-lg">
|
||||
<i data-lucide="search"></i>
|
||||
Grand Bouton
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Cards -->
|
||||
<section id="cards" class="showcase-section bg-gray-50">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Système de Cartes</h2>
|
||||
<p class="section-description">
|
||||
Cartes événements et composants modulaires.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-md-2 lg:grid-lg-3 gap-8">
|
||||
<!-- Event Card Example -->
|
||||
<div class="card card-event">
|
||||
<div class="card-event-image">
|
||||
<img src="https://images.unsplash.com/photo-1511578314322-379afb476865?w=600&h=400&fit=crop" alt="Événement exemple">
|
||||
<div class="card-event-badge">★ En vedette</div>
|
||||
<div class="card-event-price">À partir de €25</div>
|
||||
</div>
|
||||
<div class="card-event-content">
|
||||
<h3 class="card-event-title">AFTERWORK ROOFTOP</h3>
|
||||
<div class="card-event-meta mb-4">
|
||||
<div class="flex items-center justify-center gap-2 text-sm mb-2">
|
||||
<i data-lucide="calendar" class="w-4 h-4"></i>
|
||||
Vendredi 15 Décembre • 18:30
|
||||
</div>
|
||||
<div class="flex items-center justify-center gap-2 text-sm">
|
||||
<i data-lucide="map-pin" class="w-4 h-4"></i>
|
||||
Rooftop Bar Paris
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-event-description">
|
||||
Rejoignez-nous pour un afterwork exclusif avec vue panoramique sur Paris.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Simple Card -->
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-semibold mb-2">Carte Simple</h3>
|
||||
<p class="text-gray-600">
|
||||
Une carte basique pour du contenu général avec hover effects.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Metric Card -->
|
||||
<div class="card p-6 text-center">
|
||||
<div class="metric-number">2.5k+</div>
|
||||
<div class="metric-label">Membres Actifs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Components -->
|
||||
<section id="components" class="showcase-section">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Composants UI</h2>
|
||||
<p class="section-description">
|
||||
Éléments d'interface réutilisables pour construire des expériences cohérentes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="showcase-grid">
|
||||
<!-- Hero Component -->
|
||||
<div class="component-demo">
|
||||
<h3 class="text-xl font-semibold mb-4">Section Hero</h3>
|
||||
<div class="hero" style="min-height: 300px; border-radius: var(--radius-2xl);">
|
||||
<div class="hero-content">
|
||||
<div class="container text-center">
|
||||
<h2 class="hero-title" style="font-size: var(--text-3xl);">
|
||||
Titre <span class="hero-accent">Héro</span>
|
||||
</h2>
|
||||
<p class="hero-subtitle" style="font-size: var(--text-base); max-width: 24rem; margin: 0 auto var(--space-6);">
|
||||
Description du héro avec gradient de fond
|
||||
</p>
|
||||
<button class="btn btn-primary">Action Principale</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metrics Grid -->
|
||||
<div class="component-demo">
|
||||
<h3 class="text-xl font-semibold mb-4">Grille de Métriques</h3>
|
||||
<div class="metrics-grid">
|
||||
<div class="metric-item">
|
||||
<div class="metric-number">50+</div>
|
||||
<div class="metric-label">Événements</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-number">2.5k</div>
|
||||
<div class="metric-label">Membres</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-number">12</div>
|
||||
<div class="metric-label">Ce mois-ci</div>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<div class="metric-number">98%</div>
|
||||
<div class="metric-label">Satisfaction</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Usage Guidelines -->
|
||||
<section class="showcase-section bg-gray-50">
|
||||
<div class="container">
|
||||
<div class="section-header">
|
||||
<h2 class="section-title">Guide d'Utilisation</h2>
|
||||
<p class="section-description">
|
||||
Principes et bonnes pratiques pour utiliser ce système de design.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-md-2 gap-8">
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-semibold mb-4">✨ Principes de Design</h3>
|
||||
<ul class="space-y-3 text-gray-600">
|
||||
<li><strong>Cohérence</strong> - Utilisez les composants de manière uniforme</li>
|
||||
<li><strong>Accessibilité</strong> - Respectez les contrastes et la lisibilité</li>
|
||||
<li><strong>Responsive</strong> - Adaptez à tous les écrans</li>
|
||||
<li><strong>Performance</strong> - Optimisez les animations et interactions</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card p-6">
|
||||
<h3 class="text-xl font-semibold mb-4">🎨 Utilisation des Couleurs</h3>
|
||||
<ul class="space-y-3 text-gray-600">
|
||||
<li><strong>Primary</strong> - Actions principales et navigation</li>
|
||||
<li><strong>Accent</strong> - Éléments mis en évidence (badges, etc.)</li>
|
||||
<li><strong>Gray</strong> - Textes, bordures et arrière-plans</li>
|
||||
<li><strong>Purple</strong> - Métriques et éléments spéciaux</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="bg-gray-900 text-white py-16">
|
||||
<div class="container text-center">
|
||||
<h3 class="text-2xl font-bold mb-4">Système de Design Aperonight</h3>
|
||||
<p class="text-gray-400 mb-8 max-w-2xl mx-auto">
|
||||
Créé pour maintenir une expérience utilisateur cohérente et professionnelle à travers tous les points de contact Aperonight.
|
||||
</p>
|
||||
<div class="flex gap-4 justify-center">
|
||||
<button class="btn btn-primary">
|
||||
<i data-lucide="download"></i>
|
||||
Télécharger le CSS
|
||||
</button>
|
||||
<button class="btn btn-secondary">
|
||||
<i data-lucide="github"></i>
|
||||
Voir sur GitHub
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Initialize Lucide icons
|
||||
lucide.createIcons();
|
||||
|
||||
// Smooth scrolling for navigation links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const target = document.querySelector(this.getAttribute('href'));
|
||||
if (target) {
|
||||
target.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'start'
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add interaction effects
|
||||
document.querySelectorAll('.card').forEach(card => {
|
||||
card.addEventListener('mouseenter', function() {
|
||||
this.style.transform = 'translateY(-4px)';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function() {
|
||||
this.style.transform = 'translateY(0)';
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
.tool-versions
Executable file
2
.tool-versions
Executable file
@@ -0,0 +1,2 @@
|
||||
ruby 3.4.4
|
||||
nodejs 24.4.1
|
||||
767
.windsurfrules
Executable file
767
.windsurfrules
Executable file
@@ -0,0 +1,767 @@
|
||||
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)
|
||||
300
AGENT.md
Executable file
300
AGENT.md
Executable file
@@ -0,0 +1,300 @@
|
||||
# Aperonight - Technical Documentation for AI Agents
|
||||
|
||||
## 🤖 Agent Implementation Guide
|
||||
|
||||
This document provides technical details for AI agents working on the Aperonight ticket selling system.
|
||||
|
||||
## 🏗️ System Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
#### 1. User Management (`app/models/user.rb`)
|
||||
- **Devise Integration**: Complete authentication system with registration, login, password reset
|
||||
- **Relationships**: Users can create events and purchase tickets
|
||||
- **Validations**: Email format, password strength, optional name fields
|
||||
|
||||
#### 2. Event System (`app/models/event.rb`)
|
||||
- **States**: `draft`, `published`, `canceled`, `sold_out` with enum management
|
||||
- **Geographic Data**: Latitude/longitude for venue mapping
|
||||
- **Relationships**: Belongs to user, has many ticket types and tickets through ticket types
|
||||
- **Scopes**: Featured events, published events, upcoming events with proper ordering
|
||||
|
||||
#### 3. Ticket Management
|
||||
- **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
|
||||
|
||||
#### 4. Payment Processing (`app/controllers/events_controller.rb`)
|
||||
- **Stripe Integration**: Complete checkout session creation and payment confirmation
|
||||
- **Session Management**: Proper handling of payment success/failure with ticket generation
|
||||
- **Security**: Authentication required, cart validation, availability checking
|
||||
|
||||
### Database Schema Key Points
|
||||
|
||||
```sql
|
||||
-- Users table (managed by Devise)
|
||||
CREATE TABLE users (
|
||||
id bigint PRIMARY KEY,
|
||||
email varchar(255) UNIQUE NOT NULL,
|
||||
encrypted_password varchar(255) NOT NULL,
|
||||
first_name varchar(255),
|
||||
last_name varchar(255),
|
||||
-- Devise fields: confirmation, reset tokens, etc.
|
||||
);
|
||||
|
||||
-- Events table
|
||||
CREATE TABLE events (
|
||||
id bigint PRIMARY KEY,
|
||||
user_id bigint REFERENCES users(id),
|
||||
name varchar(100) NOT NULL,
|
||||
slug varchar(100) NOT NULL,
|
||||
description text(1000) NOT NULL,
|
||||
venue_name varchar(100) NOT NULL,
|
||||
venue_address varchar(200) NOT NULL,
|
||||
latitude decimal(10,8) NOT NULL,
|
||||
longitude decimal(11,8) NOT NULL,
|
||||
start_time datetime NOT NULL,
|
||||
end_time datetime,
|
||||
state integer DEFAULT 0, -- enum: draft=0, published=1, canceled=2, sold_out=3
|
||||
featured boolean DEFAULT false,
|
||||
image varchar(500)
|
||||
);
|
||||
|
||||
-- Ticket types define pricing and availability
|
||||
CREATE TABLE ticket_types (
|
||||
id bigint PRIMARY KEY,
|
||||
event_id bigint REFERENCES events(id),
|
||||
name varchar(255) NOT NULL,
|
||||
description text,
|
||||
price_cents integer NOT NULL,
|
||||
quantity integer NOT NULL,
|
||||
sale_start_at datetime,
|
||||
sale_end_at datetime,
|
||||
requires_id boolean DEFAULT false,
|
||||
minimum_age integer
|
||||
);
|
||||
|
||||
-- Individual tickets with QR codes
|
||||
CREATE TABLE tickets (
|
||||
id bigint PRIMARY KEY,
|
||||
user_id bigint REFERENCES users(id),
|
||||
ticket_type_id bigint REFERENCES ticket_types(id),
|
||||
qr_code varchar(255) UNIQUE NOT NULL,
|
||||
price_cents integer NOT NULL,
|
||||
status varchar(255) DEFAULT 'active' -- active, used, expired, refunded
|
||||
);
|
||||
```
|
||||
|
||||
## 🎯 Key Implementation Details
|
||||
|
||||
### 1. Dashboard Metrics (`app/controllers/pages_controller.rb`)
|
||||
|
||||
```ruby
|
||||
# User-specific metrics with optimized queries
|
||||
@booked_events = current_user.tickets
|
||||
.joins(:ticket_type, :event)
|
||||
.where(events: { state: :published })
|
||||
.count
|
||||
|
||||
# Event counts for different timeframes
|
||||
@events_today = Event.published
|
||||
.where("DATE(start_time) = ?", Date.current)
|
||||
.count
|
||||
|
||||
# User's actual booked events (not just count)
|
||||
@user_booked_events = Event.joins(ticket_types: :tickets)
|
||||
.where(tickets: { user: current_user, status: 'active' })
|
||||
.distinct
|
||||
.limit(5)
|
||||
```
|
||||
|
||||
### 2. Stripe Payment Flow
|
||||
|
||||
#### Checkout Initiation (`events#checkout`)
|
||||
1. **Cart Validation**: Parse JSON cart data, validate ticket types and quantities
|
||||
2. **Availability Check**: Ensure sufficient tickets available before payment
|
||||
3. **Stripe Session**: Create checkout session with line items, success/cancel URLs
|
||||
4. **Metadata Storage**: Store order details in Stripe session metadata for later retrieval
|
||||
|
||||
```ruby
|
||||
# Key Stripe configuration
|
||||
session = Stripe::Checkout::Session.create({
|
||||
payment_method_types: ['card'],
|
||||
line_items: line_items,
|
||||
mode: 'payment',
|
||||
success_url: payment_success_url(event_id: @event.id, session_id: '{CHECKOUT_SESSION_ID}'),
|
||||
cancel_url: event_url(@event.slug, @event),
|
||||
customer_email: current_user.email,
|
||||
metadata: {
|
||||
event_id: @event.id,
|
||||
user_id: current_user.id,
|
||||
order_items: order_items.to_json
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
#### Payment Confirmation (`events#payment_success`)
|
||||
1. **Session Retrieval**: Get Stripe session with payment status
|
||||
2. **Ticket Creation**: Generate tickets based on order items from metadata
|
||||
3. **QR Code Generation**: Automatic unique QR code creation via model callbacks
|
||||
4. **Success Page**: Display tickets with download links
|
||||
|
||||
### 3. PDF Ticket Generation (`app/services/ticket_pdf_generator.rb`)
|
||||
|
||||
```ruby
|
||||
class TicketPdfGenerator
|
||||
def generate
|
||||
Prawn::Document.new(page_size: [350, 600], margin: 20) do |pdf|
|
||||
# Header with branding
|
||||
pdf.fill_color "2D1B69"
|
||||
pdf.font "Helvetica", style: :bold, size: 24
|
||||
pdf.text "ApéroNight", align: :center
|
||||
|
||||
# Event details
|
||||
pdf.text ticket.event.name, align: :center
|
||||
|
||||
# QR Code generation
|
||||
qr_code_data = {
|
||||
ticket_id: ticket.id,
|
||||
qr_code: ticket.qr_code,
|
||||
event_id: ticket.event.id,
|
||||
user_id: ticket.user.id
|
||||
}.to_json
|
||||
|
||||
qrcode = RQRCode::QRCode.new(qr_code_data)
|
||||
pdf.print_qr_code(qrcode, extent: 120, align: :center)
|
||||
end.render
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### 4. Frontend Cart Management (`app/javascript/controllers/ticket_cart_controller.js`)
|
||||
|
||||
- **Stimulus Controller**: Manages cart state and interactions
|
||||
- **Authentication Check**: Validates user login before checkout
|
||||
- **Session Storage**: Preserves cart when redirecting to login
|
||||
- **Dynamic Updates**: Real-time cart total and ticket count updates
|
||||
|
||||
## 🔧 Development Patterns
|
||||
|
||||
### Model Validations
|
||||
```ruby
|
||||
# Event validations
|
||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
validates :latitude, numericality: {
|
||||
greater_than_or_equal_to: -90,
|
||||
less_than_or_equal_to: 90
|
||||
}
|
||||
|
||||
# Ticket QR code generation
|
||||
before_validation :generate_qr_code, on: :create
|
||||
def generate_qr_code
|
||||
loop do
|
||||
self.qr_code = SecureRandom.uuid
|
||||
break unless Ticket.exists?(qr_code: qr_code)
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
### Controller Patterns
|
||||
```ruby
|
||||
# Authentication for sensitive actions
|
||||
before_action :authenticate_user!, only: [:checkout, :payment_success, :download_ticket]
|
||||
|
||||
# Strong parameters
|
||||
private
|
||||
def event_params
|
||||
params.require(:event).permit(:name, :description, :venue_name, :venue_address,
|
||||
:latitude, :longitude, :start_time, :image)
|
||||
end
|
||||
```
|
||||
|
||||
### View Helpers and Partials
|
||||
- **Metric Cards**: Reusable component for dashboard statistics
|
||||
- **Event Items**: Consistent event display across pages
|
||||
- **Flash Messages**: Centralized notification system
|
||||
|
||||
## 🚀 Deployment Considerations
|
||||
|
||||
### Environment Variables
|
||||
```bash
|
||||
# Required for production
|
||||
STRIPE_PUBLISHABLE_KEY=pk_live_...
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
DATABASE_URL=mysql2://user:pass@host/db
|
||||
RAILS_MASTER_KEY=...
|
||||
```
|
||||
|
||||
### Database Indexes
|
||||
```sql
|
||||
-- Performance indexes for common queries
|
||||
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_ticket_types_event ON ticket_types (event_id);
|
||||
```
|
||||
|
||||
### Security Considerations
|
||||
- **CSRF Protection**: Rails default protection enabled
|
||||
- **Strong Parameters**: All user inputs filtered
|
||||
- **Authentication**: Devise handles session security
|
||||
- **Payment Security**: Stripe handles sensitive payment data
|
||||
|
||||
## 🧪 Testing Strategy
|
||||
|
||||
### Key Test Cases
|
||||
1. **User Authentication**: Registration, login, logout flows
|
||||
2. **Event Creation**: Validation, state management, relationships
|
||||
3. **Booking Process**: Cart validation, payment processing, ticket generation
|
||||
4. **PDF Generation**: QR code uniqueness, ticket format
|
||||
5. **Dashboard Metrics**: Query accuracy, performance
|
||||
|
||||
### Seed Data Structure
|
||||
```ruby
|
||||
# Creates test users, events, and ticket types
|
||||
users = User.create!([...])
|
||||
events = Event.create!([...])
|
||||
ticket_types = TicketType.create!([...])
|
||||
```
|
||||
|
||||
## 🛠️ Available Development Tools
|
||||
|
||||
### AST-Grep for Mass Code Replacement
|
||||
|
||||
The system has `ast-grep` installed for structural code search and replacement. This tool is particularly useful for:
|
||||
|
||||
- **Mass refactoring**: Rename methods, classes, or variables across the codebase
|
||||
- **Pattern-based replacements**: Update code patterns using AST matching
|
||||
- **Language-aware transformations**: Safer than regex for code modifications
|
||||
|
||||
#### Usage Examples:
|
||||
|
||||
```bash
|
||||
# Find all method calls to a specific method
|
||||
ast-grep --pattern 'find_by_$FIELD($VALUE)' --lang ruby
|
||||
|
||||
# Replace method calls with new syntax
|
||||
ast-grep --pattern 'find_by_$FIELD($VALUE)' --rewrite 'find_by($FIELD: $VALUE)' --lang ruby
|
||||
|
||||
# Search for specific Rails patterns
|
||||
ast-grep --pattern 'validates :$FIELD, presence: true' --lang ruby
|
||||
|
||||
# Mass rename across multiple files
|
||||
ast-grep --pattern 'old_method_name($$$ARGS)' --rewrite 'new_method_name($$$ARGS)' --lang ruby --update-all
|
||||
```
|
||||
|
||||
#### Best Practices:
|
||||
- Always run with `--dry-run` first to preview changes
|
||||
- Use `--lang ruby` for Ruby files to ensure proper AST parsing
|
||||
- Test changes in a branch before applying to main codebase
|
||||
- Particularly useful for Rails conventions and ActiveRecord pattern updates
|
||||
|
||||
## 📝 Code Style & Conventions
|
||||
|
||||
- **Ruby Style**: Follow Rails conventions and Rubocop rules
|
||||
- **Database**: Use Rails migrations for all schema changes
|
||||
- **JavaScript**: Stimulus controllers for interactive behavior
|
||||
- **CSS**: Tailwind utility classes with custom components
|
||||
- **Documentation**: Inline comments for complex business logic
|
||||
- **Mass Changes**: Use `ast-grep` for structural code replacements instead of simple find/replace
|
||||
|
||||
This architecture provides a solid foundation for a scalable ticket selling platform with proper separation of concerns, security, and user experience.
|
||||
70
BACKLOG.md
Executable file
70
BACKLOG.md
Executable file
@@ -0,0 +1,70 @@
|
||||
# Project Backlog
|
||||
|
||||
## 📋 Todo
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] feat: Check-in system with QR code scanning
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] feat: Promoter system with event creation, ticket types creation and metrics display
|
||||
- [ ] feat: Multiple ticket types (early bird, VIP, general admission)
|
||||
- [ ] feat: Refund management system
|
||||
- [ ] feat: Real-time sales analytics dashboard
|
||||
- [ ] feat: Guest checkout without account creation
|
||||
- [ ] feat: Seat selection with interactive venue maps
|
||||
- [ ] feat: Dynamic pricing based on demand
|
||||
- [ ] feat: Profesionnal account. User can ask to change from a customer to a professionnal account to create and manage events.
|
||||
- [ ] feat: User can choose to create a professionnal account on sign-up page to be allowed to create and manage events
|
||||
- [ ] feat: Payout system for promoters (automated/manual payment processing)
|
||||
- [ ] feat: Platform commission tracking and fee structure display
|
||||
- [ ] feat: Tax reporting and revenue export for promoters
|
||||
- [ ] feat: Event update notifications to ticket holders
|
||||
- [ ] feat: Marketing tools with promotional codes and discounts
|
||||
- [ ] feat: Customer support messaging between promoters and attendees
|
||||
- [ ] feat: Attendance tracking (who showed up vs tickets sold)
|
||||
- [ ] feat: Customer insights and demographics for promoters
|
||||
- [ ] feat: Performance metrics and conversion rate analytics
|
||||
- [ ] feat: Event templates for reusing successful formats
|
||||
- [ ] feat: Staff management and role assignment for promoter teams
|
||||
- [ ] feat: Multiple payment gateway options
|
||||
- [ ] feat: Calendar sync (Google Calendar, Outlook integration)
|
||||
- [ ] feat: Social media auto-posting for events
|
||||
- [ ] feat: CRM and email marketing tool integrations
|
||||
|
||||
### Low Priority
|
||||
|
||||
- [ ] feat: SMS integration for ticket delivery and updates
|
||||
- [ ] feat: Mobile wallet integration
|
||||
- [ ] feat: Multi-currency support
|
||||
- [ ] feat: Event updates communication system
|
||||
- [ ] feat: Bulk operations for group bookings
|
||||
- [ ] feat: Fraud prevention and bot protection
|
||||
- [ ] feat: Social login options
|
||||
- [ ] feat: Event recommendations system
|
||||
- [ ] feat: Invitation link. As organizer or promoter, you can invite people
|
||||
|
||||
|
||||
### Design & Infrastructure
|
||||
|
||||
- [ ] style: Rewrite design system
|
||||
- [ ] refactor: Rewrite design mockup
|
||||
|
||||
## 🚧 Doing
|
||||
|
||||
- [ ] feat: Page to display all tickets for an event
|
||||
- [ ] feat: Add a link into notification email to order page that display all tickets
|
||||
|
||||
## ✅ Done
|
||||
|
||||
- [x] Configure environment variables
|
||||
- [x] Create authentication system
|
||||
- [x] Implement user registration
|
||||
- [x] Add login functionality
|
||||
- [x] refactor: Moving checkout to OrdersController
|
||||
- [x] feat: Payment gateway integration (Stripe) - PayPal not implemented
|
||||
- [x] feat: Digital tickets with QR codes
|
||||
- [x] feat: Ticket inventory management and capacity limits
|
||||
- [x] feat: Event discovery with search and filtering
|
||||
- [x] feat: Email notifications (purchase confirmations, event reminders)
|
||||
0
Dockerfile
Normal file → Executable file
0
Dockerfile
Normal file → Executable file
30
Gemfile
Normal file → Executable file
30
Gemfile
Normal file → Executable file
@@ -51,6 +51,15 @@ group :development, :test do
|
||||
|
||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
||||
gem "rubocop-rails-omakase", require: false
|
||||
|
||||
# Add SQlite3 for local testing
|
||||
gem "sqlite3", "~> 2.7"
|
||||
|
||||
# Improve Minitest output
|
||||
gem "minitest-reporters", "~> 1.7"
|
||||
|
||||
# Load environment variables from .env file
|
||||
gem "dotenv-rails"
|
||||
end
|
||||
|
||||
group :development do
|
||||
@@ -62,6 +71,27 @@ group :test do
|
||||
# Use system testing [https://guides.rubyonrails.org/testing.html#system-testing]
|
||||
gem "capybara"
|
||||
gem "selenium-webdriver"
|
||||
# For controller testing helpers
|
||||
gem "rails-controller-testing"
|
||||
# For mocking and stubbing
|
||||
gem "mocha"
|
||||
gem "timecop"
|
||||
end
|
||||
|
||||
gem "devise", "~> 4.9"
|
||||
|
||||
# Pagination gem
|
||||
gem "kaminari", "~> 1.2"
|
||||
gem "kaminari-tailwind", "~> 0.1.0"
|
||||
|
||||
# Stripe payment processing
|
||||
gem "stripe", "~> 15.5"
|
||||
|
||||
# PDF generation for tickets
|
||||
gem "prawn", "~> 2.5"
|
||||
gem "prawn-qrcode", "~> 0.5"
|
||||
|
||||
# QR code generation
|
||||
gem "rqrcode", "~> 3.1"
|
||||
|
||||
# gem "net-pop", "~> 0.1.2"
|
||||
|
||||
78
Gemfile.lock
Normal file → Executable file
78
Gemfile.lock
Normal file → Executable file
@@ -74,6 +74,7 @@ GEM
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
ansi (1.5.0)
|
||||
ast (2.4.3)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.20)
|
||||
@@ -95,6 +96,7 @@ GEM
|
||||
rack-test (>= 0.6.3)
|
||||
regexp_parser (>= 1.5, < 3.0)
|
||||
xpath (~> 3.2)
|
||||
chunky_png (1.4.0)
|
||||
concurrent-ruby (1.3.5)
|
||||
connection_pool (2.5.3)
|
||||
crass (1.0.6)
|
||||
@@ -111,13 +113,16 @@ GEM
|
||||
responders
|
||||
warden (~> 1.2.3)
|
||||
dotenv (3.1.8)
|
||||
dotenv-rails (3.1.8)
|
||||
dotenv (= 3.1.8)
|
||||
railties (>= 6.1)
|
||||
drb (2.2.3)
|
||||
ed25519 (1.4.0)
|
||||
erb (5.0.2)
|
||||
erubi (1.13.1)
|
||||
et-orbi (1.3.0)
|
||||
tzinfo
|
||||
fugit (1.11.1)
|
||||
fugit (1.11.2)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
globalid (1.2.1)
|
||||
@@ -146,6 +151,19 @@ GEM
|
||||
sshkit (>= 1.23.0, < 2.0)
|
||||
thor (~> 1.3)
|
||||
zeitwerk (>= 2.6.18, < 3.0)
|
||||
kaminari (1.2.2)
|
||||
activesupport (>= 4.1.0)
|
||||
kaminari-actionview (= 1.2.2)
|
||||
kaminari-activerecord (= 1.2.2)
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-actionview (1.2.2)
|
||||
actionview
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-activerecord (1.2.2)
|
||||
activerecord
|
||||
kaminari-core (= 1.2.2)
|
||||
kaminari-core (1.2.2)
|
||||
kaminari-tailwind (0.1.0)
|
||||
language_server-protocol (3.17.0.5)
|
||||
lint_roller (1.1.0)
|
||||
logger (1.7.0)
|
||||
@@ -161,6 +179,13 @@ GEM
|
||||
matrix (0.4.3)
|
||||
mini_mime (1.1.5)
|
||||
minitest (5.25.5)
|
||||
minitest-reporters (1.7.1)
|
||||
ansi
|
||||
builder
|
||||
minitest (>= 5.0)
|
||||
ruby-progressbar
|
||||
mocha (2.7.1)
|
||||
ruby2_keywords (>= 0.0.5)
|
||||
msgpack (1.8.0)
|
||||
mysql2 (0.5.6)
|
||||
net-imap (0.5.9)
|
||||
@@ -186,6 +211,8 @@ GEM
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-arm-linux-musl)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.18.9-x86_64-linux-musl)
|
||||
@@ -196,8 +223,16 @@ GEM
|
||||
parser (3.3.9.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
pdf-core (0.10.0)
|
||||
pp (0.6.2)
|
||||
prettyprint
|
||||
prawn (2.5.0)
|
||||
matrix (~> 0.4)
|
||||
pdf-core (~> 0.10.0)
|
||||
ttfunk (~> 1.8)
|
||||
prawn-qrcode (0.5.2)
|
||||
prawn (>= 1)
|
||||
rqrcode (>= 1.0.0)
|
||||
prettyprint (0.2.0)
|
||||
prism (1.4.0)
|
||||
propshaft (1.2.1)
|
||||
@@ -234,6 +269,10 @@ GEM
|
||||
activesupport (= 8.0.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.0.2.1)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -260,8 +299,12 @@ GEM
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
rexml (3.4.1)
|
||||
rubocop (1.79.2)
|
||||
rexml (3.4.2)
|
||||
rqrcode (3.1.0)
|
||||
chunky_png (~> 1.0)
|
||||
rqrcode_core (~> 2.0)
|
||||
rqrcode_core (2.0.0)
|
||||
rubocop (1.80.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (~> 3.17.0.2)
|
||||
lint_roller (~> 1.1.0)
|
||||
@@ -290,7 +333,8 @@ GEM
|
||||
rubocop-performance (>= 1.24)
|
||||
rubocop-rails (>= 2.30)
|
||||
ruby-progressbar (1.13.0)
|
||||
rubyzip (3.0.1)
|
||||
ruby2_keywords (0.0.5)
|
||||
rubyzip (3.0.2)
|
||||
securerandom (0.4.1)
|
||||
selenium-webdriver (4.35.0)
|
||||
base64 (~> 0.2)
|
||||
@@ -314,6 +358,13 @@ GEM
|
||||
fugit (~> 1.11.0)
|
||||
railties (>= 7.1)
|
||||
thor (>= 1.3.1)
|
||||
sqlite3 (2.7.3-aarch64-linux-gnu)
|
||||
sqlite3 (2.7.3-aarch64-linux-musl)
|
||||
sqlite3 (2.7.3-arm-linux-gnu)
|
||||
sqlite3 (2.7.3-arm-linux-musl)
|
||||
sqlite3 (2.7.3-x86_64-darwin)
|
||||
sqlite3 (2.7.3-x86_64-linux-gnu)
|
||||
sqlite3 (2.7.3-x86_64-linux-musl)
|
||||
sshkit (1.24.0)
|
||||
base64
|
||||
logger
|
||||
@@ -324,11 +375,16 @@ GEM
|
||||
stimulus-rails (1.3.4)
|
||||
railties (>= 6.0.0)
|
||||
stringio (3.1.7)
|
||||
stripe (15.5.0)
|
||||
thor (1.4.0)
|
||||
thruster (0.1.15)
|
||||
thruster (0.1.15-aarch64-linux)
|
||||
thruster (0.1.15-x86_64-darwin)
|
||||
thruster (0.1.15-x86_64-linux)
|
||||
timecop (0.9.10)
|
||||
timeout (0.4.3)
|
||||
ttfunk (1.8.0)
|
||||
bigdecimal (~> 3.1)
|
||||
turbo-rails (2.0.16)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
@@ -361,7 +417,7 @@ PLATFORMS
|
||||
aarch64-linux-musl
|
||||
arm-linux-gnu
|
||||
arm-linux-musl
|
||||
x86_64-linux
|
||||
x86_64-darwin-24
|
||||
x86_64-linux-gnu
|
||||
x86_64-linux-musl
|
||||
|
||||
@@ -372,20 +428,32 @@ DEPENDENCIES
|
||||
cssbundling-rails
|
||||
debug
|
||||
devise (~> 4.9)
|
||||
dotenv-rails
|
||||
jbuilder
|
||||
jsbundling-rails
|
||||
kamal
|
||||
kaminari (~> 1.2)
|
||||
kaminari-tailwind (~> 0.1.0)
|
||||
minitest-reporters (~> 1.7)
|
||||
mocha
|
||||
mysql2 (~> 0.5)
|
||||
prawn (~> 2.5)
|
||||
prawn-qrcode (~> 0.5)
|
||||
propshaft
|
||||
puma (>= 5.0)
|
||||
rails (~> 8.0.2, >= 8.0.2.1)
|
||||
rails-controller-testing
|
||||
rqrcode (~> 3.1)
|
||||
rubocop-rails-omakase
|
||||
selenium-webdriver
|
||||
solid_cable
|
||||
solid_cache
|
||||
solid_queue
|
||||
sqlite3 (~> 2.7)
|
||||
stimulus-rails
|
||||
stripe (~> 15.5)
|
||||
thruster
|
||||
timecop
|
||||
turbo-rails
|
||||
tzinfo-data
|
||||
web-console
|
||||
|
||||
2
Procfile.dev
Normal file → Executable file
2
Procfile.dev
Normal file → Executable file
@@ -1,3 +1,3 @@
|
||||
web: env RUBY_DEBUG_OPEN=true bin/rails server
|
||||
js: yarn build --watch
|
||||
js: yarn build:dev --watch
|
||||
css: yarn build:css --watch
|
||||
|
||||
214
README.md
Normal file → Executable file
214
README.md
Normal file → Executable file
@@ -1,24 +1,212 @@
|
||||
# README
|
||||
# Aperonight - Event Booking Platform
|
||||
|
||||
This README would normally document whatever steps are necessary to get the
|
||||
application up and running.
|
||||

|
||||
|
||||
Things you may want to cover:
|
||||
## 🌃 Overview
|
||||
|
||||
* Ruby version
|
||||
**Aperonight** is a comprehensive ticket selling system that connects event-goers with event organizers. The platform provides a complete solution for event booking, payment processing, and ticket management.
|
||||
|
||||
* System dependencies
|
||||
## 🎯 Key Features
|
||||
|
||||
* Configuration
|
||||
### For Event-Goers
|
||||
✅ **User Dashboard** - Personalized metrics showing booked events, upcoming events, and event statistics
|
||||
✅ **Event Discovery** - Browse upcoming events with detailed information and venue details
|
||||
✅ **Secure Booking** - Multiple ticket types per event with quantity selection
|
||||
✅ **Stripe Integration** - Secure payment processing with credit/debit cards
|
||||
✅ **PDF Tickets** - Automatically generated tickets with unique QR codes for each purchase
|
||||
✅ **Download System** - Instant PDF ticket downloads after successful payment
|
||||
|
||||
* Database creation
|
||||
### For Event Organizers
|
||||
✅ **Event Management** - Create and manage events with detailed information
|
||||
✅ **Ticket Type Configuration** - Set up multiple ticket types with different pricing
|
||||
✅ **Sales Tracking** - Monitor ticket sales and availability
|
||||
✅ **User Authentication** - Secure user registration and login system
|
||||
|
||||
* Database initialization
|
||||
### Technical Implementation
|
||||
✅ **Payment Processing** - Full Stripe Checkout integration with session management
|
||||
✅ **PDF Generation** - Custom PDF tickets with QR codes using Prawn library
|
||||
✅ **Responsive Design** - Mobile-friendly interface with Tailwind CSS
|
||||
✅ **Database Relations** - Proper user-event-ticket relationships
|
||||
|
||||
* How to run the test suite
|
||||
## 🛠 Technical Stack
|
||||
|
||||
* Services (job queues, cache servers, search engines, etc.)
|
||||
### Backend
|
||||
- **Ruby on Rails 8.0+** with Hotwire for reactive UI
|
||||
- **MySQL** database with comprehensive migrations
|
||||
- **Devise** for user authentication and session management
|
||||
- **Kaminari** for pagination
|
||||
|
||||
* Deployment instructions
|
||||
### Frontend
|
||||
- **Hotwire (Turbo + Stimulus)** for interactive JavaScript behavior
|
||||
- **Tailwind CSS** for responsive styling and modern UI
|
||||
- **JavaScript Controllers** for cart management and checkout flow
|
||||
|
||||
* ...
|
||||
### Key Integrations
|
||||
- **Stripe** for secure payment processing and checkout sessions
|
||||
- **Prawn & Prawn-QRCode** for PDF ticket generation
|
||||
- **RQRCode** for unique QR code generation per ticket
|
||||
|
||||
## 📊 Database Schema
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
USER ||--o{ EVENT : creates
|
||||
USER ||--o{ TICKET : purchases
|
||||
USER {
|
||||
integer id
|
||||
string email
|
||||
string encrypted_password
|
||||
string first_name
|
||||
string last_name
|
||||
}
|
||||
EVENT ||--o{ TICKET_TYPE : has
|
||||
EVENT {
|
||||
integer id
|
||||
integer user_id
|
||||
string name
|
||||
string slug
|
||||
text description
|
||||
string venue_name
|
||||
string venue_address
|
||||
decimal latitude
|
||||
decimal longitude
|
||||
datetime start_time
|
||||
datetime end_time
|
||||
string state
|
||||
boolean featured
|
||||
string image
|
||||
}
|
||||
TICKET_TYPE ||--o{ TICKET : defines
|
||||
TICKET_TYPE {
|
||||
integer id
|
||||
integer event_id
|
||||
string name
|
||||
text description
|
||||
integer price_cents
|
||||
integer quantity
|
||||
datetime sale_start_at
|
||||
datetime sale_end_at
|
||||
boolean requires_id
|
||||
integer minimum_age
|
||||
}
|
||||
TICKET {
|
||||
integer id
|
||||
integer user_id
|
||||
integer ticket_type_id
|
||||
string qr_code
|
||||
integer price_cents
|
||||
string status
|
||||
}
|
||||
```
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
### Prerequisites
|
||||
- Ruby 3.4+
|
||||
- Rails 8.0+
|
||||
- MySQL/MariaDB
|
||||
- Node.js 18+ (for asset compilation)
|
||||
- Stripe account (for payment processing)
|
||||
|
||||
### Installation
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/yourusername/aperonight.git
|
||||
cd aperonight
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
bundle install
|
||||
npm install
|
||||
```
|
||||
|
||||
3. **Database setup**
|
||||
```bash
|
||||
rails db:create
|
||||
rails db:migrate
|
||||
rails db:seed
|
||||
```
|
||||
|
||||
4. **Configure environment variables**
|
||||
Create a `.env` file or configure Rails credentials:
|
||||
```bash
|
||||
# Stripe configuration
|
||||
STRIPE_PUBLISHABLE_KEY=pk_test_your_key_here
|
||||
STRIPE_SECRET_KEY=sk_test_your_key_here
|
||||
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret_here
|
||||
|
||||
# Database configuration (if not using defaults)
|
||||
DATABASE_URL=mysql2://username:password@localhost/aperonight_development
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
```bash
|
||||
rails server
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000` to see the application running.
|
||||
|
||||
## 💳 Payment Configuration
|
||||
|
||||
### Setting up Stripe
|
||||
|
||||
1. Create a Stripe account at [stripe.com](https://stripe.com)
|
||||
2. Get your API keys from the Stripe Dashboard
|
||||
3. Add your keys to the Rails credentials or environment variables
|
||||
4. Configure webhook endpoints for payment confirmations:
|
||||
- Endpoint URL: `your-domain.com/stripe/webhooks`
|
||||
- Events: `checkout.session.completed`, `payment_intent.succeeded`
|
||||
|
||||
## 🎫 Core Functionality
|
||||
|
||||
### User Flow
|
||||
1. **Registration/Login** - Users create accounts or sign in
|
||||
2. **Event Discovery** - Browse events from the homepage or events page
|
||||
3. **Ticket Selection** - Choose ticket types and quantities
|
||||
4. **Checkout** - Secure payment through Stripe Checkout
|
||||
5. **Ticket Generation** - Automatic PDF ticket generation with QR codes
|
||||
6. **Download** - Instant ticket download after payment
|
||||
|
||||
### Event Management
|
||||
1. **Event Creation** - Create events with full details and images
|
||||
2. **Ticket Types** - Configure multiple ticket types with pricing
|
||||
3. **Sales Tracking** - Monitor ticket sales through the dashboard
|
||||
|
||||
### Dashboard Features
|
||||
- **Personal Metrics** - View booked events and upcoming events
|
||||
- **Event Sections** - Today's events, tomorrow's events, and upcoming events
|
||||
- **Quick Actions** - Easy navigation to event discovery and booking
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
### Key Files Structure
|
||||
```
|
||||
app/
|
||||
├── controllers/
|
||||
│ ├── events_controller.rb # Event listing, booking, checkout
|
||||
│ └── pages_controller.rb # Dashboard and static pages
|
||||
├── models/
|
||||
│ ├── user.rb # User authentication with Devise
|
||||
│ ├── event.rb # Event management and states
|
||||
│ ├── ticket_type.rb # Ticket configuration
|
||||
│ └── ticket.rb # Ticket generation with QR codes
|
||||
├── services/
|
||||
│ └── ticket_pdf_generator.rb # PDF ticket generation service
|
||||
└── views/
|
||||
├── events/
|
||||
│ ├── show.html.erb # Event details and booking
|
||||
│ └── payment_success.html.erb # Post-purchase confirmation
|
||||
└── pages/
|
||||
└── dashboard.html.erb # User dashboard with metrics
|
||||
```
|
||||
|
||||
### Key Routes
|
||||
- `GET /` - Homepage
|
||||
- `GET /dashboard` - User dashboard (authenticated)
|
||||
- `GET /events` - Event listings
|
||||
- `GET /events/:slug.:id` - Event details and booking
|
||||
- `POST /events/:slug.:id/checkout` - Stripe checkout initiation
|
||||
- `GET /payment/success` - Payment confirmation
|
||||
- `GET /tickets/:ticket_id/download` - PDF ticket download
|
||||
|
||||
0
app/assets/builds/.keep
Normal file → Executable file
0
app/assets/builds/.keep
Normal file → Executable file
0
app/assets/images/.keep
Normal file → Executable file
0
app/assets/images/.keep
Normal file → Executable file
185
app/assets/javascripts/qr_generator.js
Normal file
185
app/assets/javascripts/qr_generator.js
Normal file
@@ -0,0 +1,185 @@
|
||||
// Self-contained QR Code Generator
|
||||
// No external dependencies required
|
||||
|
||||
class QRCodeGenerator {
|
||||
constructor() {
|
||||
// QR Code error correction levels
|
||||
this.errorCorrectionLevels = {
|
||||
L: 1, // Low ~7%
|
||||
M: 0, // Medium ~15%
|
||||
Q: 3, // Quartile ~25%
|
||||
H: 2 // High ~30%
|
||||
};
|
||||
|
||||
// Mode indicators
|
||||
this.modes = {
|
||||
NUMERIC: 1,
|
||||
ALPHANUMERIC: 2,
|
||||
BYTE: 4,
|
||||
KANJI: 8
|
||||
};
|
||||
}
|
||||
|
||||
// Generate QR code as SVG
|
||||
generateSVG(text, options = {}) {
|
||||
const size = options.size || 200;
|
||||
const margin = options.margin || 4;
|
||||
const errorCorrection = options.errorCorrection || 'M';
|
||||
|
||||
try {
|
||||
const qrData = this.createQRData(text, errorCorrection);
|
||||
const moduleSize = (size - 2 * margin) / qrData.length;
|
||||
|
||||
let svg = `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">`;
|
||||
svg += `<rect width="${size}" height="${size}" fill="white"/>`;
|
||||
|
||||
for (let row = 0; row < qrData.length; row++) {
|
||||
for (let col = 0; col < qrData[row].length; col++) {
|
||||
if (qrData[row][col]) {
|
||||
const x = margin + col * moduleSize;
|
||||
const y = margin + row * moduleSize;
|
||||
svg += `<rect x="${x}" y="${y}" width="${moduleSize}" height="${moduleSize}" fill="black"/>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += '</svg>';
|
||||
return svg;
|
||||
} catch (error) {
|
||||
console.error('QR Code generation failed:', error);
|
||||
return this.createErrorSVG(size);
|
||||
}
|
||||
}
|
||||
|
||||
// Create QR code data matrix (simplified implementation)
|
||||
createQRData(text, errorCorrection) {
|
||||
// For simplicity, we'll create a basic QR code pattern
|
||||
// This is a minimal implementation - real QR codes are much more complex
|
||||
|
||||
const version = this.determineVersion(text.length);
|
||||
const size = 21 + (version - 1) * 4; // QR code size formula
|
||||
|
||||
// Initialize matrix
|
||||
const matrix = Array(size).fill().map(() => Array(size).fill(false));
|
||||
|
||||
// Add finder patterns (corners)
|
||||
this.addFinderPatterns(matrix);
|
||||
|
||||
// Add timing patterns
|
||||
this.addTimingPatterns(matrix);
|
||||
|
||||
// Add data (simplified - just create a pattern based on text)
|
||||
this.addDataPattern(matrix, text);
|
||||
|
||||
return matrix;
|
||||
}
|
||||
|
||||
determineVersion(length) {
|
||||
// Simplified version determination
|
||||
if (length <= 25) return 1;
|
||||
if (length <= 47) return 2;
|
||||
if (length <= 77) return 3;
|
||||
return 4; // Max we'll support in this simple implementation
|
||||
}
|
||||
|
||||
addFinderPatterns(matrix) {
|
||||
const size = matrix.length;
|
||||
const pattern = [
|
||||
[1,1,1,1,1,1,1],
|
||||
[1,0,0,0,0,0,1],
|
||||
[1,0,1,1,1,0,1],
|
||||
[1,0,1,1,1,0,1],
|
||||
[1,0,1,1,1,0,1],
|
||||
[1,0,0,0,0,0,1],
|
||||
[1,1,1,1,1,1,1]
|
||||
];
|
||||
|
||||
// Top-left
|
||||
this.placePattern(matrix, 0, 0, pattern);
|
||||
// Top-right
|
||||
this.placePattern(matrix, 0, size - 7, pattern);
|
||||
// Bottom-left
|
||||
this.placePattern(matrix, size - 7, 0, pattern);
|
||||
}
|
||||
|
||||
addTimingPatterns(matrix) {
|
||||
const size = matrix.length;
|
||||
|
||||
// Horizontal timing pattern
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
matrix[6][i] = i % 2 === 0;
|
||||
}
|
||||
|
||||
// Vertical timing pattern
|
||||
for (let i = 8; i < size - 8; i++) {
|
||||
matrix[i][6] = i % 2 === 0;
|
||||
}
|
||||
}
|
||||
|
||||
addDataPattern(matrix, text) {
|
||||
const size = matrix.length;
|
||||
|
||||
// Simple data pattern based on text hash
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
hash = ((hash << 5) - hash + text.charCodeAt(i)) & 0xffffffff;
|
||||
}
|
||||
|
||||
// Fill available spaces with pattern based on hash
|
||||
for (let row = 0; row < size; row++) {
|
||||
for (let col = 0; col < size; col++) {
|
||||
if (!this.isReserved(row, col, size)) {
|
||||
matrix[row][col] = ((hash >> ((row + col) % 32)) & 1) === 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
placePattern(matrix, startRow, startCol, pattern) {
|
||||
for (let row = 0; row < pattern.length; row++) {
|
||||
for (let col = 0; col < pattern[row].length; col++) {
|
||||
matrix[startRow + row][startCol + col] = pattern[row][col] === 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isReserved(row, col, size) {
|
||||
// Check if position is reserved for finder patterns, timing patterns, etc.
|
||||
|
||||
// Finder patterns
|
||||
if ((row < 9 && col < 9) || // Top-left
|
||||
(row < 9 && col >= size - 8) || // Top-right
|
||||
(row >= size - 8 && col < 9)) { // Bottom-left
|
||||
return true;
|
||||
}
|
||||
|
||||
// Timing patterns
|
||||
if (row === 6 || col === 6) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
createErrorSVG(size) {
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="${size}" height="${size}" fill="#f3f4f6"/>
|
||||
<text x="${size/2}" y="${size/2-10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">QR Code</text>
|
||||
<text x="${size/2}" y="${size/2+10}" text-anchor="middle" font-family="Arial, sans-serif" font-size="14" fill="#6b7280">Error</text>
|
||||
</svg>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Global function for easy access
|
||||
window.generateQRCode = function(text, containerId, options = {}) {
|
||||
const generator = new QRCodeGenerator();
|
||||
const container = document.getElementById(containerId);
|
||||
|
||||
if (!container) {
|
||||
console.error('Container not found:', containerId);
|
||||
return;
|
||||
}
|
||||
|
||||
const svg = generator.generateSVG(text, options);
|
||||
container.innerHTML = svg;
|
||||
};
|
||||
14
app/assets/stylesheets/application.postcss.css
Normal file → Executable file
14
app/assets/stylesheets/application.postcss.css
Normal file → Executable file
@@ -1 +1,15 @@
|
||||
/* Entry point for your PostCSS build */
|
||||
|
||||
/* Import Tailwind using PostCSS */
|
||||
@import "tailwindcss";
|
||||
|
||||
/* Import our custom theme */
|
||||
@import "theme";
|
||||
|
||||
/* Import components */
|
||||
@import "components/hero";
|
||||
@import "components/flash";
|
||||
@import "components/event-finder";
|
||||
|
||||
/* Import pages */
|
||||
@import "pages/home";
|
||||
|
||||
210
app/assets/stylesheets/components/event-finder.css
Executable file
210
app/assets/stylesheets/components/event-finder.css
Executable file
@@ -0,0 +1,210 @@
|
||||
.event-finder {
|
||||
background: white;
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
padding: var(--space-8);
|
||||
margin: var(--space-12) auto;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
backdrop-filter: blur(20px);
|
||||
}
|
||||
|
||||
.finder-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.finder-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.finder-subtitle {
|
||||
color: var(--color-neutral-600);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.finder-form {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.finder-form {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.finder-form {
|
||||
grid-template-columns: repeat(4, 1fr) auto;
|
||||
align-items: end;
|
||||
}
|
||||
}
|
||||
|
||||
.finder-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.finder-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 700;
|
||||
color: var(--color-neutral-700);
|
||||
margin-bottom: var(--space-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.finder-input,
|
||||
.finder-select {
|
||||
background: var(--color-neutral-50);
|
||||
border: 2px solid var(--color-neutral-200);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-4);
|
||||
font-size: var(--text-base);
|
||||
font-weight: 500;
|
||||
color: var(--color-neutral-900);
|
||||
transition: all var(--duration-normal) var(--ease-out);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
.finder-input:focus,
|
||||
.finder-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary-500);
|
||||
background: white;
|
||||
box-shadow: 0 0 0 4px rgb(168 85 247 / 0.1);
|
||||
}
|
||||
|
||||
.finder-input::placeholder {
|
||||
color: var(--color-neutral-500);
|
||||
}
|
||||
|
||||
.finder-select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='m6 8 4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 12px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px;
|
||||
padding-right: var(--space-12);
|
||||
}
|
||||
|
||||
.price-range {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.price-range-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.price-value {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 800;
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
.price-slider {
|
||||
width: 100%;
|
||||
height: 6px;
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-neutral-200);
|
||||
outline: none;
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.price-slider::-webkit-slider-thumb {
|
||||
appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
||||
cursor: pointer;
|
||||
border: 3px solid white;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.price-slider::-moz-range-thumb {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: var(--radius-full);
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
||||
cursor: pointer;
|
||||
border: 3px solid white;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.finder-search-btn {
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-5) var(--space-8);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
transition: all var(--duration-normal) var(--ease-out);
|
||||
box-shadow: var(--shadow-purple-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.finder-search-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-purple-lg);
|
||||
}
|
||||
|
||||
.finder-search-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.event-finder {
|
||||
margin: var(--space-8) auto;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.finder-form {
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.finder-search-btn {
|
||||
justify-content: center;
|
||||
padding: var(--space-4) var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced animations */
|
||||
.animate-fadeInUp {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all 0.6s var(--ease-out);
|
||||
}
|
||||
|
||||
.animate-fadeInUp.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Loading spinner animation */
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
70
app/assets/stylesheets/components/flash.css
Executable file
70
app/assets/stylesheets/components/flash.css
Executable file
@@ -0,0 +1,70 @@
|
||||
.notification {
|
||||
font-family: var(--font-sans, 'Plus Jakarta Sans', sans-serif);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border: 1px solid;
|
||||
transition: all var(--duration-normal, 0.3s) ease-out;
|
||||
}
|
||||
|
||||
.notification-icon {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.notification-icon i {
|
||||
color: currentColor !important;
|
||||
}
|
||||
|
||||
.notification-success {
|
||||
background: var(--color-success-light, #dcfce7);
|
||||
color: var(--color-success-dark, #15803d);
|
||||
border-color: var(--color-success, #22c55e);
|
||||
}
|
||||
|
||||
.notification-warning {
|
||||
background: var(--color-warning-light, #fef3c7);
|
||||
color: var(--color-warning-dark, #92400e);
|
||||
border-color: var(--color-warning, #f59e0b);
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
background: var(--color-danger-light, #fecaca);
|
||||
color: var(--color-danger-dark, #dc2626);
|
||||
border-color: var(--color-danger, #ef4444);
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
background: var(--color-primary-50, #f0f9ff);
|
||||
color: var(--color-primary-800, #1e40af);
|
||||
border-color: var(--color-primary-200, #bfdbfe);
|
||||
}
|
||||
|
||||
/* Fallback colors if CSS variables are not available */
|
||||
.notification-success {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
border-color: #22c55e;
|
||||
}
|
||||
|
||||
.notification-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.notification-error {
|
||||
background: #fecaca;
|
||||
color: #dc2626;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.notification-info {
|
||||
background: #f0f9ff;
|
||||
color: #1e40af;
|
||||
border-color: #bfdbfe;
|
||||
}
|
||||
|
||||
/* Animation for fade out */
|
||||
.flash-messages-container .notification.opacity-0 {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
0
app/assets/stylesheets/components/header.css
Executable file
0
app/assets/stylesheets/components/header.css
Executable file
238
app/assets/stylesheets/components/hero.css
Executable file
238
app/assets/stylesheets/components/hero.css
Executable file
@@ -0,0 +1,238 @@
|
||||
.hero {
|
||||
background: linear-gradient(135deg,
|
||||
rgba(168, 85, 247, 0.08) 0%,
|
||||
rgba(236, 72, 153, 0.08) 50%,
|
||||
rgba(168, 85, 247, 0.08) 100%);
|
||||
padding: var(--space-12) 0 var(--space-8);
|
||||
text-align: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, rgba(168, 85, 247, 0.1) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(236, 72, 153, 0.1) 0%, transparent 50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(2rem, 5vw, 3.5rem);
|
||||
font-weight: 900;
|
||||
margin-bottom: var(--space-4);
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.hero .subtitle {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-neutral-600);
|
||||
margin-bottom: var(--space-6);
|
||||
max-width: 700px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.cta-group {
|
||||
display: flex;
|
||||
gap: var(--space-4);
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.hero-stats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: var(--space-6);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-stat {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-stat-number {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-primary-600);
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-stat-label {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-neutral-500);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: var(--space-12) 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-8);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 3vw, 2.5rem);
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: var(--text-lg);
|
||||
color: var(--color-neutral-600);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.features-section {
|
||||
background: linear-gradient(135deg, var(--color-primary-50) 0%, var(--color-accent-50) 100%);
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.features-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
padding: var(--space-6);
|
||||
border-radius: var(--radius-xl);
|
||||
text-align: center;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
transition: all var(--duration-normal) var(--ease-out);
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background: linear-gradient(135deg, var(--color-primary-500) 0%, var(--color-accent-400) 100%);
|
||||
color: white;
|
||||
border-radius: var(--radius-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto var(--space-3);
|
||||
box-shadow: var(--shadow-purple-sm);
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-lg);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--color-neutral-900);
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: var(--color-neutral-600);
|
||||
line-height: 1.6;
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
background: var(--color-neutral-900);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
gap: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.stats-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 4vw, 2.5rem);
|
||||
font-weight: 900;
|
||||
color: var(--color-accent-400);
|
||||
display: block;
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--color-neutral-300);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.cta-section {
|
||||
background: linear-gradient(135deg, var(--color-primary-600) 0%, var(--color-accent-500) 100%);
|
||||
color: white;
|
||||
text-align: center;
|
||||
padding: var(--space-12) 0;
|
||||
}
|
||||
|
||||
.cta-content h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: clamp(1.5rem, 3vw, 2rem);
|
||||
font-weight: 800;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.cta-content p {
|
||||
font-size: var(--text-lg);
|
||||
margin-bottom: var(--space-6);
|
||||
opacity: 0.9;
|
||||
}
|
||||
816
app/assets/stylesheets/design_system.css
Normal file
816
app/assets/stylesheets/design_system.css
Normal file
@@ -0,0 +1,816 @@
|
||||
/**
|
||||
* Aperonight Design System
|
||||
* Generated from homepage analysis
|
||||
* A modern, professional design system for event platforms
|
||||
*/
|
||||
|
||||
/* === ROOT VARIABLES === */
|
||||
:root {
|
||||
/* Brand Colors */
|
||||
--brand-primary: #667eea;
|
||||
--brand-secondary: #764ba2;
|
||||
--brand-accent: #facc15; /* yellow-400 */
|
||||
--brand-accent-dark: #eab308; /* yellow-500 */
|
||||
|
||||
/* Neutral Colors */
|
||||
--color-white: #ffffff;
|
||||
--color-black: #000000;
|
||||
--color-gray-50: #f9fafb;
|
||||
--color-gray-100: #f3f4f6;
|
||||
--color-gray-200: #e5e7eb;
|
||||
--color-gray-300: #d1d5db;
|
||||
--color-gray-400: #9ca3af;
|
||||
--color-gray-500: #6b7280;
|
||||
--color-gray-600: #4b5563;
|
||||
--color-gray-700: #374151;
|
||||
--color-gray-800: #1f2937;
|
||||
--color-gray-900: #111827;
|
||||
|
||||
/* Purple Shades */
|
||||
--color-purple-600: #9333ea;
|
||||
--color-purple-700: #7c3aed;
|
||||
--color-purple-800: #6b21a8;
|
||||
|
||||
/* Blue Shades */
|
||||
--color-blue-600: #2563eb;
|
||||
--color-blue-700: #1d4ed8;
|
||||
|
||||
/* Typography */
|
||||
--font-family-sans: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
--font-family-mono: ui-monospace, SFMono-Regular, 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
|
||||
/* Font Sizes */
|
||||
--text-xs: 0.75rem; /* 12px */
|
||||
--text-sm: 0.875rem; /* 14px */
|
||||
--text-base: 1rem; /* 16px */
|
||||
--text-lg: 1.125rem; /* 18px */
|
||||
--text-xl: 1.25rem; /* 20px */
|
||||
--text-2xl: 1.5rem; /* 24px */
|
||||
--text-3xl: 1.875rem; /* 30px */
|
||||
--text-4xl: 2.25rem; /* 36px */
|
||||
--text-5xl: 3rem; /* 48px */
|
||||
--text-6xl: 3.75rem; /* 60px */
|
||||
|
||||
/* Font Weights */
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* Spacing Scale */
|
||||
--space-1: 0.25rem; /* 4px */
|
||||
--space-2: 0.5rem; /* 8px */
|
||||
--space-3: 0.75rem; /* 12px */
|
||||
--space-4: 1rem; /* 16px */
|
||||
--space-6: 1.5rem; /* 24px */
|
||||
--space-8: 2rem; /* 32px */
|
||||
--space-12: 3rem; /* 48px */
|
||||
--space-16: 4rem; /* 64px */
|
||||
--space-24: 6rem; /* 96px */
|
||||
|
||||
/* Border Radius */
|
||||
--radius-sm: 0.375rem; /* 6px */
|
||||
--radius-md: 0.5rem; /* 8px */
|
||||
--radius-lg: 0.75rem; /* 12px */
|
||||
--radius-xl: 1rem; /* 16px */
|
||||
--radius-2xl: 1.25rem; /* 20px */
|
||||
--radius-3xl: 1.5rem; /* 24px */
|
||||
--radius-full: 9999px;
|
||||
|
||||
/* Shadows */
|
||||
--shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05);
|
||||
--shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
--shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
|
||||
--shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1);
|
||||
--shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25);
|
||||
|
||||
/* Gradients */
|
||||
--gradient-primary: linear-gradient(135deg, var(--brand-primary) 0%, var(--brand-secondary) 100%);
|
||||
--gradient-overlay: rgba(0, 0, 0, 0.3);
|
||||
|
||||
/* Transitions */
|
||||
--transition-fast: all 0.2s ease;
|
||||
--transition-medium: all 0.3s ease;
|
||||
--transition-slow: all 0.5s ease;
|
||||
}
|
||||
|
||||
/* === BASE STYLES === */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
html {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-family: var(--font-family-sans);
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family-sans) !important;
|
||||
line-height: 1.6;
|
||||
color: var(--color-gray-900) !important;
|
||||
background-color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
/* === TYPOGRAPHY SYSTEM === */
|
||||
.text-xs { font-size: var(--text-xs); }
|
||||
.text-sm { font-size: var(--text-sm); }
|
||||
.text-base { font-size: var(--text-base); }
|
||||
.text-lg { font-size: var(--text-lg); }
|
||||
.text-xl { font-size: var(--text-xl); }
|
||||
.text-2xl { font-size: var(--text-2xl); }
|
||||
.text-3xl { font-size: var(--text-3xl); }
|
||||
.text-4xl { font-size: var(--text-4xl); }
|
||||
.text-5xl { font-size: var(--text-5xl); }
|
||||
.text-6xl { font-size: var(--text-6xl); }
|
||||
|
||||
.font-medium { font-weight: var(--font-medium); }
|
||||
.font-semibold { font-weight: var(--font-semibold); }
|
||||
.font-bold { font-weight: var(--font-bold); }
|
||||
|
||||
.leading-tight { line-height: 1.25; }
|
||||
.leading-normal { line-height: 1.5; }
|
||||
.leading-relaxed { line-height: 1.625; }
|
||||
|
||||
/* === BUTTON SYSTEM === */
|
||||
.btn {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-3) var(--space-6);
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
border-radius: var(--radius-full);
|
||||
transition: var(--transition-fast);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--color-white) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--color-gray-100) !important;
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: transparent !important;
|
||||
color: var(--color-white) !important;
|
||||
border: 2px solid var(--color-white) !important;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: var(--color-white) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
}
|
||||
|
||||
.btn-secondary-alt {
|
||||
background-color: transparent !important;
|
||||
color: var(--color-gray-700) !important;
|
||||
border: 2px solid var(--color-gray-300) !important;
|
||||
}
|
||||
|
||||
.btn-secondary-alt:hover {
|
||||
background-color: var(--color-gray-100) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
border-color: var(--color-gray-400) !important;
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
background-color: var(--color-purple-600) !important;
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
background-color: var(--color-purple-700) !important;
|
||||
}
|
||||
|
||||
.btn-dark {
|
||||
background-color: var(--color-gray-900) !important;
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
.btn-dark:hover {
|
||||
background-color: var(--color-gray-800) !important;
|
||||
}
|
||||
|
||||
/* Button Sizes */
|
||||
.btn-sm {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--space-4) var(--space-8);
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
/* === CARD SYSTEM === */
|
||||
.card {
|
||||
background-color: var(--color-white) !important;
|
||||
border-radius: var(--radius-2xl) !important;
|
||||
box-shadow: var(--shadow-sm);
|
||||
overflow: hidden;
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.card-event {
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-event-image {
|
||||
aspect-ratio: 4/3;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius-2xl);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.card-event-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.card-event:hover .card-event-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card-event-badge {
|
||||
position: absolute;
|
||||
top: var(--space-4);
|
||||
left: var(--space-4);
|
||||
background-color: var(--brand-accent) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.card-event-price {
|
||||
position: absolute;
|
||||
bottom: var(--space-4);
|
||||
right: var(--space-4);
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
backdrop-filter: blur(4px);
|
||||
color: var(--color-gray-900) !important;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
|
||||
.card-event-content {
|
||||
padding: var(--space-6);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.card-event-title {
|
||||
font-size: var(--text-2xl) !important;
|
||||
font-weight: var(--font-bold) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
margin-bottom: var(--space-2);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.card-event:hover .card-event-title {
|
||||
color: var(--color-purple-600) !important;
|
||||
}
|
||||
|
||||
.card-event-meta {
|
||||
color: var(--color-gray-600) !important;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.card-event-description {
|
||||
color: var(--color-gray-500) !important;
|
||||
font-size: var(--text-sm);
|
||||
line-height: var(--leading-relaxed);
|
||||
max-width: 20rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* === HERO SYSTEM === */
|
||||
.hero {
|
||||
background: var(--gradient-primary) !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--gradient-overlay);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: var(--text-4xl) !important;
|
||||
font-weight: var(--font-bold) !important;
|
||||
line-height: var(--leading-tight);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: var(--text-xl) !important;
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
margin-bottom: var(--space-8);
|
||||
max-width: 32rem;
|
||||
}
|
||||
|
||||
.hero-accent {
|
||||
color: var(--brand-accent) !important;
|
||||
}
|
||||
|
||||
/* Responsive Hero */
|
||||
@media (min-width: 1024px) {
|
||||
.hero-title {
|
||||
font-size: var(--text-6xl) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* === METRICS SYSTEM === */
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: var(--space-8);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.metric-number {
|
||||
font-size: var(--text-4xl) !important;
|
||||
font-weight: var(--font-bold) !important;
|
||||
color: var(--color-purple-600) !important;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.metric-number {
|
||||
font-size: var(--text-5xl) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.metric-label {
|
||||
color: var(--color-gray-600) !important;
|
||||
font-weight: var(--font-medium) !important;
|
||||
}
|
||||
|
||||
/* === SECTION SYSTEM === */
|
||||
.section {
|
||||
padding: var(--space-16) 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-12);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: var(--text-3xl) !important;
|
||||
font-weight: var(--font-bold) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.section-title {
|
||||
font-size: var(--text-4xl) !important;
|
||||
}
|
||||
}
|
||||
|
||||
.section-description {
|
||||
font-size: var(--text-xl) !important;
|
||||
color: var(--color-gray-600) !important;
|
||||
max-width: 40rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* === GRID SYSTEM === */
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
}
|
||||
|
||||
.grid-1 { grid-template-columns: 1fr; }
|
||||
.grid-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.grid-md-2 { grid-template-columns: repeat(2, 1fr); }
|
||||
.grid-md-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.grid-lg-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.grid-lg-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
|
||||
/* === UTILITY CLASSES === */
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
padding-left: var(--space-4);
|
||||
padding-right: var(--space-4);
|
||||
}
|
||||
|
||||
.text-center { text-align: center; }
|
||||
.text-left { text-align: left; }
|
||||
.text-right { text-align: right; }
|
||||
|
||||
.bg-white { background-color: var(--color-white) !important; }
|
||||
.bg-gray-50 { background-color: var(--color-gray-50) !important; }
|
||||
.bg-gray-900 { background-color: var(--color-gray-900) !important; }
|
||||
|
||||
.text-white { color: var(--color-white) !important; }
|
||||
.text-gray-600 { color: var(--color-gray-600) !important; }
|
||||
.text-gray-900 { color: var(--color-gray-900) !important; }
|
||||
|
||||
.rounded-full { border-radius: var(--radius-full) !important; }
|
||||
.rounded-2xl { border-radius: var(--radius-2xl) !important; }
|
||||
|
||||
.shadow-lg { box-shadow: var(--shadow-lg) !important; }
|
||||
.shadow-xl { box-shadow: var(--shadow-xl) !important; }
|
||||
|
||||
.mb-2 { margin-bottom: var(--space-2) !important; }
|
||||
.mb-4 { margin-bottom: var(--space-4) !important; }
|
||||
.mb-6 { margin-bottom: var(--space-6) !important; }
|
||||
.mb-8 { margin-bottom: var(--space-8) !important; }
|
||||
.mb-12 { margin-bottom: var(--space-12) !important; }
|
||||
|
||||
.p-4 { padding: var(--space-4) !important; }
|
||||
.p-6 { padding: var(--space-6) !important; }
|
||||
.p-8 { padding: var(--space-8) !important; }
|
||||
|
||||
.flex { display: flex !important; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-4 { gap: var(--space-4); }
|
||||
|
||||
.transition { transition: var(--transition-fast); }
|
||||
|
||||
.max-w-lg { max-width: 32rem; }
|
||||
.max-w-2xl { max-width: 42rem; }
|
||||
.max-w-4xl { max-width: 56rem; }
|
||||
|
||||
/* === BREADCRUMB SYSTEM === */
|
||||
.breadcrumb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
background-color: var(--color-white) !important;
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.breadcrumb-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.breadcrumb-item a {
|
||||
color: var(--color-gray-700) !important;
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.breadcrumb-item a:hover {
|
||||
color: var(--color-purple-600) !important;
|
||||
}
|
||||
|
||||
.breadcrumb-item:not(:last-child)::after {
|
||||
content: '';
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
margin-left: var(--space-2);
|
||||
background: url("data:image/svg+xml,%3csvg fill='%234b5563' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3e%3cpath fill-rule='evenodd' d='M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z' clip-rule='evenodd'/%3e%3c/svg%3e") center no-repeat;
|
||||
background-size: 1rem;
|
||||
}
|
||||
|
||||
.breadcrumb-current {
|
||||
color: var(--color-purple-600) !important;
|
||||
}
|
||||
|
||||
/* === PAGE HEADER SYSTEM === */
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: var(--space-8) 0;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-3xl) !important;
|
||||
font-weight: var(--font-bold) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
}
|
||||
|
||||
.page-meta {
|
||||
font-size: var(--text-sm) !important;
|
||||
color: var(--color-gray-500) !important;
|
||||
}
|
||||
|
||||
/* === EVENTS GRID SYSTEM === */
|
||||
.events-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-6);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.events-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.events-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.event-card {
|
||||
background-color: var(--color-white) !important;
|
||||
border-radius: var(--radius-xl) !important;
|
||||
box-shadow: var(--shadow-md);
|
||||
overflow: hidden;
|
||||
transition: var(--transition-medium);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-card:hover {
|
||||
box-shadow: var(--shadow-xl);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.event-card-image {
|
||||
height: 12rem;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.event-card-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
transition: var(--transition-medium);
|
||||
}
|
||||
|
||||
.event-card:hover .event-card-image img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.event-card-placeholder {
|
||||
height: 12rem;
|
||||
background: var(--gradient-primary) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.event-card-placeholder svg {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
color: rgba(255, 255, 255, 0.8) !important;
|
||||
}
|
||||
|
||||
.event-card-content {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.event-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: start;
|
||||
margin-bottom: var(--space-3);
|
||||
}
|
||||
|
||||
.event-card-title {
|
||||
font-size: var(--text-xl) !important;
|
||||
font-weight: var(--font-bold) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
margin-bottom: var(--space-1);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.event-card-venue {
|
||||
font-size: var(--text-xs) !important;
|
||||
color: var(--color-gray-500) !important;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.event-card-date {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--space-2) calc(var(--space-2) + var(--space-1));
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs) !important;
|
||||
font-weight: var(--font-medium) !important;
|
||||
background-color: rgba(147, 51, 234, 0.1) !important;
|
||||
color: var(--color-purple-800) !important;
|
||||
white-space: nowrap;
|
||||
margin-top: var(--space-2);
|
||||
}
|
||||
|
||||
.event-card-description {
|
||||
color: var(--color-gray-600) !important;
|
||||
font-size: var(--text-sm) !important;
|
||||
line-height: 1.4;
|
||||
margin-bottom: var(--space-4);
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.event-card-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.event-card-price {
|
||||
font-size: var(--text-sm) !important;
|
||||
font-weight: var(--font-medium) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
}
|
||||
|
||||
.event-card-price-unavailable {
|
||||
font-size: var(--text-sm) !important;
|
||||
color: var(--color-gray-500) !important;
|
||||
}
|
||||
|
||||
.event-card-link {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border: 1px solid transparent;
|
||||
font-size: var(--text-sm) !important;
|
||||
font-weight: var(--font-medium) !important;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
color: var(--color-white) !important;
|
||||
background: var(--gradient-primary) !important;
|
||||
text-decoration: none !important;
|
||||
transition: var(--transition-fast);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.event-card-link:hover {
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* === EMPTY STATE SYSTEM === */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: var(--space-16) var(--space-4);
|
||||
}
|
||||
|
||||
.empty-state-icon {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
margin: 0 auto var(--space-6);
|
||||
background: linear-gradient(135deg, rgba(147, 51, 234, 0.1) 0%, rgba(79, 70, 229, 0.1) 100%) !important;
|
||||
border-radius: var(--radius-full);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-state-icon svg {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
color: var(--color-purple-600) !important;
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg) !important;
|
||||
font-weight: var(--font-medium) !important;
|
||||
color: var(--color-gray-900) !important;
|
||||
margin-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
color: var(--color-gray-500) !important;
|
||||
margin-bottom: var(--space-6);
|
||||
max-width: 24rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
/* === PAGINATION SYSTEM === */
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-8);
|
||||
}
|
||||
|
||||
.pagination .page-item {
|
||||
margin: 0 var(--space-1);
|
||||
}
|
||||
|
||||
.pagination .page-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-2) var(--space-3);
|
||||
font-size: var(--text-sm) !important;
|
||||
font-weight: var(--font-medium) !important;
|
||||
color: var(--color-gray-600) !important;
|
||||
background-color: var(--color-white) !important;
|
||||
border: 1px solid var(--color-gray-200) !important;
|
||||
border-radius: var(--radius-md);
|
||||
text-decoration: none !important;
|
||||
transition: var(--transition-fast);
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
|
||||
.pagination .page-link:hover {
|
||||
background-color: var(--color-gray-50) !important;
|
||||
border-color: var(--color-purple-300) !important;
|
||||
color: var(--color-purple-600) !important;
|
||||
}
|
||||
|
||||
.pagination .page-item.active .page-link {
|
||||
background-color: var(--color-purple-600) !important;
|
||||
border-color: var(--color-purple-600) !important;
|
||||
color: var(--color-white) !important;
|
||||
}
|
||||
|
||||
.pagination .page-item.disabled .page-link {
|
||||
color: var(--color-gray-300) !important;
|
||||
background-color: var(--color-white) !important;
|
||||
border-color: var(--color-gray-200) !important;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* === RESPONSIVE UTILITIES === */
|
||||
@media (max-width: 640px) {
|
||||
.sm\:flex-col { flex-direction: column; }
|
||||
.sm\:text-center { text-align: center; }
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--text-2xl) !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.sm\:flex-row { flex-direction: row; }
|
||||
.sm\:flex-1 { flex: 1; }
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:justify-start { justify-content: flex-start; }
|
||||
.lg\:text-left { text-align: left; }
|
||||
}
|
||||
90
app/assets/stylesheets/pages/events.css
Executable file
90
app/assets/stylesheets/pages/events.css
Executable file
@@ -0,0 +1,90 @@
|
||||
/* Events page specific styles */
|
||||
|
||||
.events-page {
|
||||
background: linear-gradient(135deg, var(--color-neutral-50) 0%, var(--color-neutral-100) 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.events-page .breadcrumb {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.events-page .event-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-2xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-lg);
|
||||
transition: all var(--duration-slow) var(--ease-out);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.events-page .event-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
border-color: var(--color-primary-200);
|
||||
}
|
||||
|
||||
.events-page .event-date-badge {
|
||||
background: linear-gradient(135deg, var(--color-primary-100) 0%, var(--color-accent-100) 100%);
|
||||
color: var(--color-primary-800);
|
||||
font-weight: 700;
|
||||
padding: var(--space-1) var(--space-3);
|
||||
border-radius: var(--radius-full);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.events-page .price-highlight {
|
||||
color: var(--color-primary-600);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.events-page .pagination {
|
||||
margin-top: var(--space-12);
|
||||
}
|
||||
|
||||
.events-page .pagination .page,
|
||||
.events-page .pagination .next,
|
||||
.events-page .pagination .last,
|
||||
.events-page .pagination .prev,
|
||||
.events-page .pagination .first {
|
||||
padding: var(--space-2) var(--space-4);
|
||||
border-radius: var(--radius-lg);
|
||||
margin: 0 var(--space-1);
|
||||
transition: all var(--duration-normal);
|
||||
}
|
||||
|
||||
.events-page .pagination .page:hover,
|
||||
.events-page .pagination .next:hover,
|
||||
.events-page .pagination .last:hover,
|
||||
.events-page .pagination .prev:hover,
|
||||
.events-page .pagination .first:hover {
|
||||
background: var(--color-primary-100);
|
||||
color: var(--color-primary-700);
|
||||
}
|
||||
|
||||
.events-page .pagination .current {
|
||||
background: var(--color-primary-600);
|
||||
color: white;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.events-page .no-events-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-2xl);
|
||||
padding: var(--space-12);
|
||||
box-shadow: var(--shadow-lg);
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.events-page .event-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.events-page .no-events-card {
|
||||
padding: var(--space-8);
|
||||
}
|
||||
}
|
||||
171
app/assets/stylesheets/pages/home.css
Executable file
171
app/assets/stylesheets/pages/home.css
Executable file
@@ -0,0 +1,171 @@
|
||||
/* Updated Featured Events Grid - 3 Cards Side by Side */
|
||||
.featured-events-grid {
|
||||
display: grid;
|
||||
gap: var(--space-8);
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.featured-events-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.featured-events-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.featured-event-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all var(--duration-slow) var(--ease-out);
|
||||
border: 1px solid var(--color-neutral-200);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.featured-event-card:hover {
|
||||
transform: translateY(-8px) scale(1.02);
|
||||
box-shadow: var(--shadow-2xl);
|
||||
border-color: var(--color-primary-200);
|
||||
}
|
||||
|
||||
.featured-event-image {
|
||||
width: 100%;
|
||||
height: 240px;
|
||||
object-fit: cover;
|
||||
transition: transform var(--duration-slow) var(--ease-out);
|
||||
}
|
||||
|
||||
.featured-event-card:hover .featured-event-image {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.featured-event-content {
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.featured-event-badges {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.featured-event-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 700;
|
||||
margin-bottom: var(--space-3);
|
||||
color: var(--color-neutral-900);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.featured-event-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.featured-event-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-neutral-600);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.featured-event-description {
|
||||
color: var(--color-neutral-700);
|
||||
margin-bottom: var(--space-6);
|
||||
line-height: 1.6;
|
||||
font-size: var(--text-sm);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-event-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.featured-event-price {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 800;
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.featured-event-image {
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.featured-event-content {
|
||||
padding: var(--space-4);
|
||||
}
|
||||
}
|
||||
|
||||
/* Enhanced animations */
|
||||
.animate-slideInLeft {
|
||||
opacity: 0;
|
||||
transform: translateX(-30px);
|
||||
transition: all 0.5s var(--ease-out);
|
||||
}
|
||||
|
||||
.animate-slideInLeft.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.animate-slideInRight {
|
||||
opacity: 0;
|
||||
transform: translateX(30px);
|
||||
transition: all 0.5s var(--ease-out);
|
||||
}
|
||||
|
||||
.animate-slideInRight.visible {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Added missing animation for fadeInUp */
|
||||
.animate-fadeInUp {
|
||||
opacity: 0;
|
||||
transform: translateY(30px);
|
||||
transition: all 0.5s var(--ease-out);
|
||||
}
|
||||
|
||||
.animate-fadeInUp.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* Feature Stats Styling */
|
||||
.feature-stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
color: var(--color-primary-600);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--color-neutral-600);
|
||||
font-weight: 500;
|
||||
}
|
||||
1022
app/assets/stylesheets/theme.css
Executable file
1022
app/assets/stylesheets/theme.css
Executable file
File diff suppressed because it is too large
Load Diff
25
app/controllers/api/v1/carts_controller.rb
Normal file
25
app/controllers/api/v1/carts_controller.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
module Api
|
||||
module V1
|
||||
class CartsController < ApiController
|
||||
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||
skip_before_action :authenticate_api_key, only: [ :store ]
|
||||
|
||||
def store
|
||||
event_id = params[:event_id]
|
||||
@event = Event.find(event_id)
|
||||
|
||||
cart_data = params[:cart] || {}
|
||||
session[:pending_cart] = cart_data
|
||||
session[:event_id] = @event.id
|
||||
|
||||
render json: { status: "success", message: "Cart stored successfully" }
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { status: "error", message: "Event not found" }, status: :not_found
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Unknown error"
|
||||
Rails.logger.error "Error storing cart: #{error_message}"
|
||||
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
104
app/controllers/api/v1/events_controller.rb
Executable file
104
app/controllers/api/v1/events_controller.rb
Executable file
@@ -0,0 +1,104 @@
|
||||
# API Controller for managing event resources
|
||||
# Provides RESTful endpoints for CRUD operations on the Event model
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class EventsController < ApiController
|
||||
# Skip API key authentication for store_cart action (used by frontend forms)
|
||||
skip_before_action :authenticate_api_key, only: [ :store_cart ]
|
||||
|
||||
# Loads the event before certain actions to reduce duplications
|
||||
before_action :set_event, only: [ :show, :update, :destroy, :store_cart ]
|
||||
|
||||
# GET /api/v1/events
|
||||
# Retrieves all events sorted by creation date (most recent first)
|
||||
def index
|
||||
@events = Event.all.order(created_at: :desc)
|
||||
render json: @events, status: :ok
|
||||
end
|
||||
|
||||
# GET /api/v1/events/:id
|
||||
# Retrieves a single event by its ID
|
||||
# Returns 404 if the event is not found
|
||||
def show
|
||||
render json: @event, status: :ok
|
||||
end
|
||||
|
||||
# POST /api/v1/events
|
||||
# Creates a new event with the provided attributes
|
||||
# Returns 201 Created on success with the event data
|
||||
# Returns 422 Unprocessable Entity with error messages on failure
|
||||
def create
|
||||
@event = Event.new(event_params)
|
||||
if @event.save
|
||||
render json: @event, status: :created
|
||||
else
|
||||
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# PATCH/PUT /api/v1/events/:id
|
||||
# Updates an existing event with the provided attributes
|
||||
# Returns 200 OK with updated data on success
|
||||
# Returns 422 Unprocessable Entity with error messages on failure
|
||||
def update
|
||||
if @event.update(event_params)
|
||||
render json: @event, status: :ok
|
||||
else
|
||||
render json: { errors: @event.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# DELETE /api/v1/events/:id
|
||||
# Permanently deletes an event
|
||||
# Returns 204 No Content on success
|
||||
def destroy
|
||||
@event.destroy
|
||||
head :no_content
|
||||
end
|
||||
|
||||
# POST /api/v1/events/:id/store_cart
|
||||
# Store cart data in session (AJAX endpoint)
|
||||
def store_cart
|
||||
cart_data = params[:cart] || {}
|
||||
session[:pending_cart] = cart_data
|
||||
session[:event_id] = @event.id
|
||||
|
||||
render json: { status: "success", message: "Cart stored successfully" }
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Unknown error"
|
||||
Rails.logger.error "Error storing cart: #{error_message}"
|
||||
render json: { status: "error", message: "Failed to store cart" }, status: 500
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Finds an event by its ID or returns 404 Not Found
|
||||
# Used as before_action for the show, update, and destroy actions
|
||||
def set_event
|
||||
@event = Event.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "Event not found" }, status: :not_found
|
||||
end
|
||||
|
||||
# Strong parameters for creating and updating events
|
||||
# Whitelist of allowed attributes to avoid mass assignment vulnerabilities
|
||||
def event_params
|
||||
params.require(:event).permit(
|
||||
:name,
|
||||
:slug,
|
||||
:description,
|
||||
:state,
|
||||
:venue_name,
|
||||
:venue_address,
|
||||
:start_time,
|
||||
:end_time,
|
||||
:latitude,
|
||||
:longitude,
|
||||
:featured,
|
||||
:user_id
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
281
app/controllers/api/v1/orders_controller.rb
Normal file
281
app/controllers/api/v1/orders_controller.rb
Normal file
@@ -0,0 +1,281 @@
|
||||
# API controller for order management
|
||||
# Provides RESTful endpoints for order operations
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class OrdersController < ApiController
|
||||
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt ]
|
||||
before_action :set_event, only: [ :new, :create ]
|
||||
|
||||
# Skip API key authentication for increment_payment_attempt action (used by frontend forms)
|
||||
skip_before_action :authenticate_api_key, only: [ :increment_payment_attempt ]
|
||||
|
||||
# GET /api/v1/orders/new
|
||||
# Returns data needed for new order form
|
||||
def new
|
||||
cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||
|
||||
if cart_data.empty?
|
||||
render json: { error: "Veuillez d'abord sélectionner vos billets sur la page de l'événement" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
tickets_needing_names = []
|
||||
cart_data.each do |ticket_type_id, item|
|
||||
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
||||
next unless ticket_type
|
||||
|
||||
quantity = item["quantity"].to_i
|
||||
next if quantity <= 0
|
||||
|
||||
quantity.times do |i|
|
||||
tickets_needing_names << {
|
||||
ticket_type_id: ticket_type.id,
|
||||
ticket_type_name: ticket_type.name,
|
||||
ticket_type_price: ticket_type.price_cents,
|
||||
index: i
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
render json: { tickets_needing_names: tickets_needing_names }, status: :ok
|
||||
end
|
||||
|
||||
# POST /api/v1/orders
|
||||
# Creates a new order with tickets
|
||||
def create
|
||||
cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||
|
||||
if cart_data.empty?
|
||||
render json: { error: "Aucun billet sélectionné" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
success = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@order = current_user.orders.create!(event: @event, status: "draft")
|
||||
|
||||
order_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
||||
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
||||
|
||||
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
||||
|
||||
ticket = @order.tickets.build(
|
||||
ticket_type: ticket_type,
|
||||
first_name: ticket_attrs[:first_name],
|
||||
last_name: ticket_attrs[:last_name],
|
||||
status: "draft"
|
||||
)
|
||||
|
||||
unless ticket.save
|
||||
render json: { error: "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}" }, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
if @order.tickets.present?
|
||||
@order.calculate_total!
|
||||
success = true
|
||||
else
|
||||
render json: { error: "Aucun billet valide créé" }, status: :unprocessable_entity
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
if success
|
||||
session[:draft_order_id] = @order.id
|
||||
session.delete(:pending_cart)
|
||||
render json: { order: @order, redirect_to: checkout_order_path(@order) }, status: :created
|
||||
end
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||
render json: { error: "Une erreur est survenue: #{error_message}" }, status: :internal_server_error
|
||||
end
|
||||
|
||||
# GET /api/v1/orders/:id
|
||||
# Returns order summary
|
||||
def show
|
||||
tickets = @order.tickets.includes(:ticket_type)
|
||||
render json: { order: @order, tickets: tickets }, status: :ok
|
||||
end
|
||||
|
||||
# GET /api/v1/orders/:id/checkout
|
||||
# Returns checkout data for an order
|
||||
def checkout
|
||||
if @order.expired?
|
||||
@order.expire_if_overdue!
|
||||
render json: { error: "Votre commande a expiré. Veuillez recommencer." }, status: :gone
|
||||
return
|
||||
end
|
||||
|
||||
tickets = @order.tickets.includes(:ticket_type)
|
||||
total_amount = @order.total_amount_cents
|
||||
expiring_soon = @order.expiring_soon?
|
||||
|
||||
checkout_session = nil
|
||||
if Rails.application.config.stripe[:secret_key].present?
|
||||
begin
|
||||
checkout_session = create_stripe_session
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
|
||||
render json: { error: "Erreur lors de la création de la session de paiement" }, status: :internal_server_error
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
render json: {
|
||||
order: @order,
|
||||
tickets: tickets,
|
||||
total_amount: total_amount,
|
||||
expiring_soon: expiring_soon,
|
||||
checkout_session: checkout_session
|
||||
}, status: :ok
|
||||
end
|
||||
|
||||
# PATCH /api/v1/orders/:id/increment_payment_attempt
|
||||
# Increments payment attempt counter
|
||||
def increment_payment_attempt
|
||||
@order.increment_payment_attempt!
|
||||
render json: { success: true, attempts: @order.payment_attempts }, status: :ok
|
||||
end
|
||||
|
||||
# POST /api/v1/orders/:id/retry_payment
|
||||
# Allows retrying payment for failed orders
|
||||
def retry_payment
|
||||
unless @order.can_retry_payment?
|
||||
render json: { error: "Cette commande ne peut plus être payée" }, status: :forbidden
|
||||
return
|
||||
end
|
||||
|
||||
render json: { redirect_to: checkout_order_path(@order) }, status: :ok
|
||||
end
|
||||
|
||||
# GET /api/v1/orders/payment_success
|
||||
# Handles successful payment confirmation
|
||||
def payment_success
|
||||
session_id = params[:session_id]
|
||||
|
||||
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
||||
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
||||
|
||||
unless stripe_configured
|
||||
render json: { error: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur." }, status: :service_unavailable
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
|
||||
|
||||
if stripe_session.payment_status == "paid"
|
||||
order_id = stripe_session.metadata["order_id"]
|
||||
|
||||
unless order_id.present?
|
||||
render json: { error: "Informations de commande manquantes" }, status: :bad_request
|
||||
return
|
||||
end
|
||||
|
||||
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
|
||||
@order.mark_as_paid!
|
||||
|
||||
begin
|
||||
StripeInvoiceGenerationJob.perform_later(@order.id)
|
||||
Rails.logger.info "Scheduled Stripe invoice generation for order #{@order.id}"
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to schedule invoice generation for order #{@order.id}: #{e.message}"
|
||||
end
|
||||
|
||||
@order.tickets.each do |ticket|
|
||||
begin
|
||||
TicketMailer.purchase_confirmation(ticket).deliver_now
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to send confirmation email for ticket #{ticket.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
|
||||
session.delete(:pending_cart)
|
||||
session.delete(:ticket_names)
|
||||
session.delete(:draft_order_id)
|
||||
|
||||
render json: { order: @order, tickets: @order.tickets }, status: :ok
|
||||
else
|
||||
render json: { error: "Le paiement n'a pas été complété avec succès" }, status: :payment_required
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||
render json: { error: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}" }, status: :bad_request
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
|
||||
render json: { error: "Une erreur inattendue s'est produite : #{error_message}" }, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
# POST /api/v1/orders/payment_cancel
|
||||
# Handles payment cancellation
|
||||
def payment_cancel
|
||||
order_id = params[:order_id] || session[:draft_order_id]
|
||||
|
||||
if order_id.present?
|
||||
order = current_user.orders.find_by(id: order_id, status: "draft")
|
||||
|
||||
if order&.can_retry_payment?
|
||||
render json: { message: "Le paiement a été annulé. Vous pouvez réessayer.", redirect_to: checkout_order_path(order) }, status: :ok
|
||||
else
|
||||
session.delete(:draft_order_id)
|
||||
render json: { message: "Le paiement a été annulé et votre commande a expiré." }, status: :gone
|
||||
end
|
||||
else
|
||||
render json: { message: "Le paiement a été annulé" }, status: :ok
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_order
|
||||
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "Commande non trouvée" }, status: :not_found
|
||||
end
|
||||
|
||||
def set_event
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render json: { error: "Événement non trouvé" }, status: :not_found
|
||||
end
|
||||
|
||||
def order_params
|
||||
params.permit(tickets_attributes: [ :ticket_type_id, :first_name, :last_name ])
|
||||
end
|
||||
|
||||
def create_stripe_session
|
||||
line_items = @order.tickets.map do |ticket|
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: "#{@order.event.name} - #{ticket.ticket_type.name}",
|
||||
description: ticket.ticket_type.description
|
||||
},
|
||||
unit_amount: ticket.price_cents
|
||||
},
|
||||
quantity: 1
|
||||
}
|
||||
end
|
||||
|
||||
Stripe::Checkout::Session.create(
|
||||
payment_method_types: [ "card" ],
|
||||
line_items: line_items,
|
||||
mode: "payment",
|
||||
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: order_payment_cancel_url,
|
||||
metadata: {
|
||||
order_id: @order.id,
|
||||
user_id: current_user.id
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
26
app/controllers/api_controller.rb
Executable file
26
app/controllers/api_controller.rb
Executable file
@@ -0,0 +1,26 @@
|
||||
# Base controller for API endpoints
|
||||
# Provides authentication and common functionality for API controllers
|
||||
class ApiController < ApplicationController
|
||||
# Disable CSRF protection for API requests (token-based authentication instead)
|
||||
protect_from_forgery prepend: true
|
||||
|
||||
# Authenticate all API requests using API key
|
||||
# Must be called before any API action
|
||||
before_action :authenticate_api_key
|
||||
|
||||
private
|
||||
|
||||
# Authenticates API requests using X-API-Key header or api_key parameter
|
||||
# Returns 401 Unauthorized if key is invalid or missing
|
||||
def authenticate_api_key
|
||||
# Extract API key from header or query parameter
|
||||
api_key = request.headers["X-API-Key"] || params[:api_key]
|
||||
|
||||
# Validate against environment variable for security
|
||||
expected_key = Rails.application.credentials.api_key || ENV["API_KEY"]
|
||||
|
||||
unless expected_key.present? && api_key == expected_key
|
||||
render json: { error: "Unauthorized" }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
end
|
||||
43
app/controllers/application_controller.rb
Normal file → Executable file
43
app/controllers/application_controller.rb
Normal file → Executable file
@@ -1,4 +1,43 @@
|
||||
# Base controller for the application
|
||||
# Provides common functionality and security configurations for all controllers
|
||||
class ApplicationController < ActionController::Base
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
# Protect against Cross-Site Request Forgery (CSRF) attacks
|
||||
# Ensures that all non-GET requests include a valid authenticity token
|
||||
protect_from_forgery with: :exception
|
||||
|
||||
# Redirect authenticated users to onboarding if not completed
|
||||
before_action :require_onboarding_completion
|
||||
|
||||
# Restrict access to modern browsers only
|
||||
# Requires browsers to support modern web standards:
|
||||
# - WebP images for better compression
|
||||
# - Web Push notifications
|
||||
# - Badge API for notifications
|
||||
# - Import maps for JavaScript modules
|
||||
# - CSS nesting and :has() pseudo-class
|
||||
# allow_browser versions: :modern
|
||||
# allow_browser versions: { safari: 16.4, firefox: 121, ie: false }
|
||||
|
||||
private
|
||||
|
||||
def require_onboarding_completion
|
||||
# Skip onboarding check for these paths
|
||||
return if skip_onboarding_check?
|
||||
|
||||
# Only apply to signed-in users
|
||||
if user_signed_in? && current_user.needs_onboarding?
|
||||
redirect_to onboarding_path unless request.path == onboarding_path
|
||||
end
|
||||
end
|
||||
|
||||
def skip_onboarding_check?
|
||||
# Skip for devise controllers (login, signup, password reset, etc.)
|
||||
devise_controller? ||
|
||||
# Skip for onboarding controller itself
|
||||
controller_name == "onboarding" ||
|
||||
# Skip for API endpoints
|
||||
controller_name.start_with?("api/") ||
|
||||
# Skip for health checks
|
||||
controller_name == "rails/health"
|
||||
end
|
||||
end
|
||||
|
||||
30
app/controllers/auth/confirmations_controller.rb
Executable file
30
app/controllers/auth/confirmations_controller.rb
Executable file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::ConfirmationsController < Devise::ConfirmationsController
|
||||
# GET /resource/confirmation/new
|
||||
# def new
|
||||
# super
|
||||
# end
|
||||
|
||||
# POST /resource/confirmation
|
||||
# def create
|
||||
# super
|
||||
# end
|
||||
|
||||
# GET /resource/confirmation?confirmation_token=abcdef
|
||||
# def show
|
||||
# super
|
||||
# end
|
||||
|
||||
# protected
|
||||
|
||||
# The path used after resending confirmation instructions.
|
||||
# def after_resending_confirmation_instructions_path_for(resource_name)
|
||||
# super(resource_name)
|
||||
# end
|
||||
|
||||
# The path used after confirmation.
|
||||
# def after_confirmation_path_for(resource_name, resource)
|
||||
# super(resource_name, resource)
|
||||
# end
|
||||
end
|
||||
30
app/controllers/auth/omniauth_callbacks_controller.rb
Executable file
30
app/controllers/auth/omniauth_callbacks_controller.rb
Executable file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::OmniauthCallbacksController < Devise::OmniauthCallbacksController
|
||||
# You should configure your model like this:
|
||||
# devise :omniauthable, omniauth_providers: [:twitter]
|
||||
|
||||
# You should also create an action method in this controller like this:
|
||||
# def twitter
|
||||
# end
|
||||
|
||||
# More info at:
|
||||
# https://github.com/heartcombo/devise#omniauth
|
||||
|
||||
# GET|POST /resource/auth/twitter
|
||||
# def passthru
|
||||
# super
|
||||
# end
|
||||
|
||||
# GET|POST /users/auth/twitter/callback
|
||||
# def failure
|
||||
# super
|
||||
# end
|
||||
|
||||
# protected
|
||||
|
||||
# The path used when OmniAuth fails
|
||||
# def after_omniauth_failure_path_for(scope)
|
||||
# super(scope)
|
||||
# end
|
||||
end
|
||||
36
app/controllers/auth/passwords_controller.rb
Executable file
36
app/controllers/auth/passwords_controller.rb
Executable file
@@ -0,0 +1,36 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::PasswordsController < Devise::PasswordsController
|
||||
# GET /resource/password/new
|
||||
# def new
|
||||
# super
|
||||
# end
|
||||
|
||||
# POST /resource/password
|
||||
# def create
|
||||
# super
|
||||
# end
|
||||
|
||||
# GET /resource/password/edit?reset_password_token=abcdef
|
||||
# def edit
|
||||
# super
|
||||
# end
|
||||
|
||||
# PUT /resource/password
|
||||
# def update
|
||||
# super
|
||||
# end
|
||||
|
||||
# protected
|
||||
|
||||
# Override to set a flash message on successful password reset
|
||||
def after_resetting_password_path_for(resource)
|
||||
flash[:notice] = "Votre mot de passe a été changé avec succès. Vous êtes maintenant connecté."
|
||||
super(resource)
|
||||
end
|
||||
|
||||
# The path used after sending reset password instructions
|
||||
# def after_sending_reset_password_instructions_path_for(resource_name)
|
||||
# super(resource_name)
|
||||
# end
|
||||
end
|
||||
62
app/controllers/auth/registrations_controller.rb
Executable file
62
app/controllers/auth/registrations_controller.rb
Executable file
@@ -0,0 +1,62 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::RegistrationsController < Devise::RegistrationsController
|
||||
before_action :configure_sign_up_params, only: [ :create ]
|
||||
before_action :configure_account_update_params, only: [ :update ]
|
||||
|
||||
# GET /resource/sign_up
|
||||
# def new
|
||||
# super
|
||||
# end
|
||||
|
||||
# POST /resource
|
||||
# def create
|
||||
# super
|
||||
# end
|
||||
|
||||
# GET /resource/edit
|
||||
# def edit
|
||||
# super
|
||||
# end
|
||||
|
||||
# PUT /resource
|
||||
# def update
|
||||
# super
|
||||
# end
|
||||
|
||||
# DELETE /resource
|
||||
# def destroy
|
||||
# super
|
||||
# end
|
||||
|
||||
# GET /resource/cancel
|
||||
# Forces the session data which is usually expired after sign
|
||||
# in to be expired now. This is useful if the user wants to
|
||||
# cancel oauth signing in/up in the middle of the process,
|
||||
# removing all OAuth session data.
|
||||
# def cancel
|
||||
# super
|
||||
# end
|
||||
|
||||
# protected
|
||||
|
||||
# If you have extra params to permit, append them to the sanitizer.
|
||||
def configure_sign_up_params
|
||||
devise_parameter_sanitizer.permit(:sign_up, keys: [ :last_name, :first_name ])
|
||||
end
|
||||
|
||||
# If you have extra params to permit, append them to the sanitizer.
|
||||
def configure_account_update_params
|
||||
devise_parameter_sanitizer.permit(:account_update, keys: [ :last_name, :first_name, :is_professionnal ])
|
||||
end
|
||||
|
||||
# The path used after sign up.
|
||||
# def after_sign_up_path_for(resource)
|
||||
# super(resource)
|
||||
# end
|
||||
|
||||
# The path used after sign up for inactive accounts.
|
||||
# def after_inactive_sign_up_path_for(resource)
|
||||
# super(resource)
|
||||
# end
|
||||
end
|
||||
28
app/controllers/auth/sessions_controller.rb
Executable file
28
app/controllers/auth/sessions_controller.rb
Executable file
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::SessionsController < Devise::SessionsController
|
||||
# before_action :configure_sign_in_params, only: [:create]
|
||||
|
||||
# GET /resource/sign_in
|
||||
# def new
|
||||
# super
|
||||
# end
|
||||
|
||||
# POST /resource/sign_in
|
||||
def create
|
||||
super
|
||||
flash[:notice] = "Connexion réussie !" if resource.persisted?
|
||||
end
|
||||
|
||||
# DELETE /resource/sign_out
|
||||
# def destroy
|
||||
# super
|
||||
# end
|
||||
|
||||
# protected
|
||||
|
||||
# If you have extra params to permit, append them to the sanitizer.
|
||||
# def configure_sign_in_params
|
||||
# devise_parameter_sanitizer.permit(:sign_in, keys: [:attribute])
|
||||
# end
|
||||
end
|
||||
30
app/controllers/auth/unlocks_controller.rb
Executable file
30
app/controllers/auth/unlocks_controller.rb
Executable file
@@ -0,0 +1,30 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
class Auth::UnlocksController < Devise::UnlocksController
|
||||
# GET /resource/unlock/new
|
||||
# def new
|
||||
# super
|
||||
# end
|
||||
|
||||
# POST /resource/unlock
|
||||
# def create
|
||||
# super
|
||||
# end
|
||||
|
||||
# GET /resource/unlock?unlock_token=abcdef
|
||||
# def show
|
||||
# super
|
||||
# end
|
||||
|
||||
# protected
|
||||
|
||||
# The path used after sending unlock password instructions
|
||||
# def after_sending_unlock_instructions_path_for(resource)
|
||||
# super(resource)
|
||||
# end
|
||||
|
||||
# The path used after unlocking the resource
|
||||
# def after_unlock_path_for(resource)
|
||||
# super(resource)
|
||||
# end
|
||||
end
|
||||
0
app/controllers/concerns/.keep
Normal file → Executable file
0
app/controllers/concerns/.keep
Normal file → Executable file
18
app/controllers/concerns/stripe_concern.rb
Normal file
18
app/controllers/concerns/stripe_concern.rb
Normal file
@@ -0,0 +1,18 @@
|
||||
module StripeConcern
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
# Check if Stripe is properly configured
|
||||
def stripe_configured?
|
||||
Rails.application.config.stripe[:secret_key].present?
|
||||
end
|
||||
|
||||
# Stripe is now initialized at application startup, so this method is no longer needed
|
||||
# but kept for backward compatibility
|
||||
def initialize_stripe
|
||||
return false unless stripe_configured?
|
||||
|
||||
# Stripe is already initialized at application startup
|
||||
Rails.logger.debug "Stripe already initialized at application startup"
|
||||
true
|
||||
end
|
||||
end
|
||||
36
app/controllers/events_controller.rb
Executable file
36
app/controllers/events_controller.rb
Executable file
@@ -0,0 +1,36 @@
|
||||
# Events controller - Public event listings and individual event display
|
||||
#
|
||||
# This controller manages public event browsing and displays individual events
|
||||
# with their associated ticket types. No authentication required for public browsing.
|
||||
class EventsController < ApplicationController
|
||||
# No authentication required for public event viewing
|
||||
before_action :authenticate_user!, only: []
|
||||
before_action :set_event, only: [ :show ]
|
||||
|
||||
# Display paginated list of upcoming published events
|
||||
#
|
||||
# Shows events in published state, ordered by start time ascending
|
||||
# Includes event owner information and supports Kaminari pagination
|
||||
def index
|
||||
@events = Event.includes(:user).upcoming.page(params[:page]).per(12)
|
||||
end
|
||||
|
||||
# Display individual event with ticket type information
|
||||
#
|
||||
# Shows complete event details including venue information,
|
||||
# available ticket types, and allows users to add tickets to cart
|
||||
def show
|
||||
# Event is set by set_event callback with ticket types preloaded
|
||||
# Template will display event details and ticket selection interface
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Find and set the current event with eager-loaded associations
|
||||
#
|
||||
# Loads event with ticket types to avoid N+1 queries
|
||||
# Raises ActiveRecord::RecordNotFound if event doesn't exist
|
||||
def set_event
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
end
|
||||
end
|
||||
38
app/controllers/onboarding_controller.rb
Normal file
38
app/controllers/onboarding_controller.rb
Normal file
@@ -0,0 +1,38 @@
|
||||
class OnboardingController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :redirect_if_onboarding_complete, except: [ :complete ]
|
||||
|
||||
def index
|
||||
# Display the onboarding form
|
||||
end
|
||||
|
||||
def complete
|
||||
if onboarding_params_valid?
|
||||
current_user.update!(onboarding_params)
|
||||
current_user.complete_onboarding!
|
||||
|
||||
flash[:notice] = "Bienvenue sur #{Rails.application.config.app_name} ! Votre profil a été configuré avec succès."
|
||||
redirect_to dashboard_path
|
||||
else
|
||||
flash.now[:alert] = "Veuillez remplir tous les champs requis."
|
||||
render :index
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def onboarding_params
|
||||
params.require(:user).permit(:first_name, :last_name)
|
||||
end
|
||||
|
||||
def onboarding_params_valid?
|
||||
onboarding_params[:first_name].present? &&
|
||||
onboarding_params[:last_name].present?
|
||||
end
|
||||
|
||||
def redirect_if_onboarding_complete
|
||||
if current_user&.onboarding_completed?
|
||||
redirect_to dashboard_path
|
||||
end
|
||||
end
|
||||
end
|
||||
310
app/controllers/orders_controller.rb
Normal file
310
app/controllers/orders_controller.rb
Normal file
@@ -0,0 +1,310 @@
|
||||
# Handle order management and checkout process
|
||||
#
|
||||
# This controller manages the order lifecycle from checkout to payment completion
|
||||
# Orders group multiple tickets together for better transaction management
|
||||
class OrdersController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_order, only: [ :show, :checkout, :retry_payment, :increment_payment_attempt, :invoice ]
|
||||
before_action :set_event, only: [ :new, :create ]
|
||||
|
||||
# Display new order form with name collection
|
||||
#
|
||||
# On this page user can see order summary and complete the tickets details
|
||||
# (first name and last name) for each ticket ordered
|
||||
def new
|
||||
@cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||
|
||||
if @cart_data.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Veuillez d'abord sélectionner vos billets sur la page de l'événement"
|
||||
return
|
||||
end
|
||||
|
||||
# Build list of tickets requiring names
|
||||
@tickets_needing_names = []
|
||||
@cart_data.each do |ticket_type_id, item|
|
||||
ticket_type = @event.ticket_types.find_by(id: ticket_type_id)
|
||||
next unless ticket_type
|
||||
|
||||
quantity = item["quantity"].to_i
|
||||
next if quantity <= 0
|
||||
|
||||
quantity.times do |i|
|
||||
@tickets_needing_names << {
|
||||
ticket_type_id: ticket_type.id,
|
||||
ticket_type_name: ticket_type.name,
|
||||
ticket_type_price: ticket_type.price_cents,
|
||||
index: i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Create a new order with tickets
|
||||
#
|
||||
# Here a new order is created with associated tickets in draft state.
|
||||
# When user is ready they can proceed to payment via the order checkout
|
||||
def create
|
||||
@cart_data = params[:cart_data] || session[:pending_cart] || {}
|
||||
|
||||
if @cart_data.empty?
|
||||
redirect_to event_path(@event.slug, @event), alert: "Aucun billet sélectionné"
|
||||
return
|
||||
end
|
||||
|
||||
success = false
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
@order = current_user.orders.create!(event: @event, status: "draft")
|
||||
|
||||
order_params[:tickets_attributes]&.each do |index, ticket_attrs|
|
||||
next if ticket_attrs[:first_name].blank? || ticket_attrs[:last_name].blank?
|
||||
|
||||
ticket_type = @event.ticket_types.find(ticket_attrs[:ticket_type_id])
|
||||
|
||||
ticket = @order.tickets.build(
|
||||
ticket_type: ticket_type,
|
||||
first_name: ticket_attrs[:first_name],
|
||||
last_name: ticket_attrs[:last_name],
|
||||
status: "draft"
|
||||
)
|
||||
|
||||
unless ticket.save
|
||||
flash[:alert] = "Erreur lors de la création des billets: #{ticket.errors.full_messages.join(', ')}"
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
if @order.tickets.present?
|
||||
@order.calculate_total!
|
||||
success = true
|
||||
else
|
||||
flash[:alert] = "Aucun billet valide créé"
|
||||
raise ActiveRecord::Rollback
|
||||
end
|
||||
end
|
||||
|
||||
# Handle redirects outside transaction
|
||||
if success
|
||||
session[:draft_order_id] = @order.id
|
||||
session.delete(:pending_cart)
|
||||
redirect_to checkout_order_path(@order)
|
||||
else
|
||||
redirect_to event_order_new_path(@event.slug, @event.id)
|
||||
end
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||
flash[:alert] = "Une erreur est survenue: #{error_message}"
|
||||
redirect_to event_order_new_path(@event.slug, @event.id)
|
||||
end
|
||||
|
||||
# Display all user orders
|
||||
def index
|
||||
@orders = current_user.orders.includes(:event, tickets: :ticket_type)
|
||||
.where(status: [ "paid", "completed" ])
|
||||
.order(created_at: :desc)
|
||||
.page(params[:page])
|
||||
end
|
||||
|
||||
# Display order summary
|
||||
def show
|
||||
@tickets = @order.tickets.includes(:ticket_type)
|
||||
end
|
||||
|
||||
# Display payment page for an order
|
||||
#
|
||||
# Display a summary of all tickets in the order and permit user
|
||||
# to proceed to payment via Stripe
|
||||
def checkout
|
||||
# Handle expired orders
|
||||
if @order.expired?
|
||||
@order.expire_if_overdue!
|
||||
return redirect_to event_path(@order.event.slug, @order.event),
|
||||
alert: "Votre commande a expiré. Veuillez recommencer."
|
||||
end
|
||||
|
||||
@tickets = @order.tickets.includes(:ticket_type)
|
||||
@total_amount = @order.total_amount_cents
|
||||
@expiring_soon = @order.expiring_soon?
|
||||
|
||||
# Create Stripe checkout session if Stripe is configured
|
||||
if Rails.application.config.stripe[:secret_key].present?
|
||||
begin
|
||||
@checkout_session = create_stripe_session
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||
Rails.logger.error "Stripe checkout session creation failed: #{error_message}"
|
||||
flash[:alert] = "Erreur lors de la création de la session de paiement"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Increment payment attempt - called via AJAX when user clicks pay button
|
||||
def increment_payment_attempt
|
||||
@order.increment_payment_attempt!
|
||||
render json: { success: true, attempts: @order.payment_attempts }
|
||||
end
|
||||
|
||||
# Allow users to retry payment for failed/cancelled payments
|
||||
def retry_payment
|
||||
unless @order.can_retry_payment?
|
||||
redirect_to event_path(@order.event.slug, @order.event),
|
||||
alert: "Cette commande ne peut plus être payée"
|
||||
return
|
||||
end
|
||||
|
||||
# For POST requests, increment the payment attempt counter
|
||||
if request.post?
|
||||
@order.increment_payment_attempt!
|
||||
end
|
||||
|
||||
redirect_to checkout_order_path(@order)
|
||||
end
|
||||
|
||||
# Display invoice for an order
|
||||
def invoice
|
||||
unless @order.status == "paid" || @order.status == "completed"
|
||||
redirect_to order_path(@order), alert: "La facture n'est disponible qu'après le paiement de la commande"
|
||||
return
|
||||
end
|
||||
|
||||
@tickets = @order.tickets.includes(:ticket_type)
|
||||
|
||||
# Get the Stripe invoice if it exists
|
||||
begin
|
||||
@stripe_invoice_id = @order.create_stripe_invoice!
|
||||
@stripe_invoice_pdf_url = @order.stripe_invoice_pdf_url if @stripe_invoice_id
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to retrieve or create Stripe invoice for order #{@order.id}: #{e.message}"
|
||||
@stripe_invoice_id = nil
|
||||
@stripe_invoice_pdf_url = nil
|
||||
end
|
||||
end
|
||||
|
||||
# Handle successful payment
|
||||
def payment_success
|
||||
session_id = params[:session_id]
|
||||
|
||||
# Check if Stripe is properly configured
|
||||
stripe_configured = Rails.application.config.stripe[:secret_key].present?
|
||||
Rails.logger.debug "Payment success - Stripe configured: #{stripe_configured}"
|
||||
|
||||
unless stripe_configured
|
||||
redirect_to root_path, alert: "Le système de paiement n'est pas correctement configuré. Veuillez contacter l'administrateur."
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
stripe_session = Stripe::Checkout::Session.retrieve(session_id)
|
||||
|
||||
if stripe_session.payment_status == "paid"
|
||||
# Get order_id from session metadata
|
||||
order_id = stripe_session.metadata["order_id"]
|
||||
|
||||
unless order_id.present?
|
||||
redirect_to dashboard_path, alert: "Informations de commande manquantes"
|
||||
return
|
||||
end
|
||||
|
||||
# Find and update the order
|
||||
@order = current_user.orders.includes(tickets: :ticket_type).find(order_id)
|
||||
@order.mark_as_paid!
|
||||
|
||||
# Schedule Stripe invoice generation in background
|
||||
# This creates accounting records without blocking the payment success flow
|
||||
begin
|
||||
StripeInvoiceGenerationJob.perform_later(@order.id)
|
||||
Rails.logger.info "Scheduled Stripe invoice generation for order #{@order.id}"
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to schedule invoice generation for order #{@order.id}: #{e.message}"
|
||||
# Don't fail the payment process due to job scheduling issues
|
||||
end
|
||||
|
||||
# Email confirmation is handled by the order model's mark_as_paid! method
|
||||
# to avoid duplicate emails
|
||||
|
||||
# Clear session data
|
||||
session.delete(:pending_cart)
|
||||
session.delete(:ticket_names)
|
||||
session.delete(:draft_order_id)
|
||||
|
||||
render "payment_success"
|
||||
else
|
||||
redirect_to dashboard_path, alert: "Le paiement n'a pas été complété avec succès"
|
||||
end
|
||||
rescue Stripe::StripeError => e
|
||||
error_message = e.message.present? ? e.message : "Erreur Stripe inconnue"
|
||||
redirect_to dashboard_path, alert: "Erreur lors du traitement de votre confirmation de paiement : #{error_message}"
|
||||
rescue => e
|
||||
error_message = e.message.present? ? e.message : "Erreur inconnue"
|
||||
Rails.logger.error "Payment success error: #{e.class} - #{error_message}"
|
||||
redirect_to dashboard_path, alert: "Une erreur inattendue s'est produite : #{error_message}"
|
||||
end
|
||||
end
|
||||
|
||||
# Handle payment failure/cancellation
|
||||
def payment_cancel
|
||||
order_id = params[:order_id] || session[:draft_order_id]
|
||||
|
||||
if order_id.present?
|
||||
order = current_user.orders.find_by(id: order_id, status: "draft")
|
||||
|
||||
if order&.can_retry_payment?
|
||||
redirect_to checkout_order_path(order),
|
||||
alert: "Le paiement a été annulé. Vous pouvez réessayer."
|
||||
else
|
||||
session.delete(:draft_order_id)
|
||||
redirect_to root_path, alert: "Le paiement a été annulé et votre commande a expiré."
|
||||
end
|
||||
else
|
||||
redirect_to root_path, alert: "Le paiement a été annulé"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_order
|
||||
@order = current_user.orders.includes(:tickets, :event).find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to root_path, alert: "Commande non trouvée"
|
||||
end
|
||||
|
||||
def set_event
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to events_path, alert: "Événement non trouvé"
|
||||
end
|
||||
|
||||
def order_params
|
||||
params.permit(tickets_attributes: [ :ticket_type_id, :first_name, :last_name ])
|
||||
end
|
||||
|
||||
def create_stripe_session
|
||||
line_items = @order.tickets.map do |ticket|
|
||||
{
|
||||
price_data: {
|
||||
currency: "eur",
|
||||
product_data: {
|
||||
name: "#{@order.event.name} - #{ticket.ticket_type.name}",
|
||||
description: ticket.ticket_type.description
|
||||
},
|
||||
unit_amount: ticket.price_cents
|
||||
},
|
||||
quantity: 1
|
||||
}
|
||||
end
|
||||
|
||||
# No service fee added to customer; deducted from promoter payout
|
||||
|
||||
Stripe::Checkout::Session.create(
|
||||
payment_method_types: [ "card" ],
|
||||
line_items: line_items,
|
||||
mode: "payment",
|
||||
success_url: order_payment_success_url + "?session_id={CHECKOUT_SESSION_ID}",
|
||||
cancel_url: order_payment_cancel_url,
|
||||
metadata: {
|
||||
order_id: @order.id,
|
||||
user_id: current_user.id
|
||||
}
|
||||
)
|
||||
end
|
||||
end
|
||||
101
app/controllers/pages_controller.rb
Normal file → Executable file
101
app/controllers/pages_controller.rb
Normal file → Executable file
@@ -1,9 +1,104 @@
|
||||
# Controller for static pages and user dashboard
|
||||
# Handles basic page rendering and user-specific content
|
||||
class PagesController < ApplicationController
|
||||
# Display homepage
|
||||
before_action :authenticate_user!, only: [ :dashboard ]
|
||||
|
||||
# Homepage showing featured events as landing page
|
||||
#
|
||||
# Display homepage with featured events and site metrics for all users
|
||||
def home
|
||||
# Featured events for the main grid (6-9 events like Shotgun)
|
||||
@featured_events = Event.published.featured.includes(:ticket_types).limit(9)
|
||||
|
||||
# If no featured events, show latest published events
|
||||
if @featured_events.empty?
|
||||
@featured_events = Event.published.includes(:ticket_types).order(created_at: :desc).limit(9)
|
||||
end
|
||||
|
||||
# Display legal page
|
||||
def legals
|
||||
# Upcoming events for additional content
|
||||
@upcoming_events = Event.published.upcoming.limit(6)
|
||||
|
||||
# Site metrics for landing page (with realistic fake data for demo)
|
||||
@total_events = [ Event.published.count, 50 ].max # At least 50 events for demo
|
||||
@total_users = [ User.count, 2500 ].max # At least 2500 users for demo
|
||||
@events_this_month = [ Event.published.where(created_at: 1.month.ago..Time.current).count, 12 ].max # At least 12 this month
|
||||
@active_cities = 5 # Fixed number for demo
|
||||
end
|
||||
|
||||
# User dashboard showing personalized content
|
||||
# Accessible only to authenticated users
|
||||
def dashboard
|
||||
# User's orders with associated data
|
||||
@user_orders = current_user.orders.includes(:event, tickets: :ticket_type)
|
||||
.where(status: [ "paid", "completed" ])
|
||||
.order(created_at: :desc)
|
||||
.limit(10)
|
||||
|
||||
# Draft orders that can be retried
|
||||
@draft_orders = current_user.orders.includes(tickets: [ :ticket_type, :event ])
|
||||
.can_retry_payment
|
||||
.order(:expires_at)
|
||||
|
||||
# Promoter-specific data if user is a promoter
|
||||
if current_user.promoter?
|
||||
@promoter_events = current_user.events.includes(:orders, :tickets)
|
||||
.order(created_at: :desc)
|
||||
.limit(5)
|
||||
|
||||
# Revenue metrics for promoter
|
||||
@total_revenue = current_user.events
|
||||
.joins(:orders)
|
||||
.where(orders: { status: [ "paid", "completed" ] })
|
||||
.sum("orders.total_amount_cents") / 100.0
|
||||
|
||||
@total_tickets_sold = current_user.events
|
||||
.joins(:tickets)
|
||||
.where(tickets: { status: "active" })
|
||||
.count
|
||||
|
||||
@active_events_count = current_user.events.where(state: "published").count
|
||||
@draft_events_count = current_user.events.where(state: "draft").count
|
||||
|
||||
# Recent orders for promoter events
|
||||
@recent_orders = Order.joins(:event)
|
||||
.where(events: { user: current_user })
|
||||
.where(status: [ "paid", "completed" ])
|
||||
.includes(:event, :user, tickets: :ticket_type)
|
||||
.order(created_at: :desc)
|
||||
.limit(10)
|
||||
|
||||
# Monthly revenue trend (last 6 months)
|
||||
@monthly_revenue = (0..5).map do |months_ago|
|
||||
start_date = months_ago.months.ago.beginning_of_month
|
||||
end_date = months_ago.months.ago.end_of_month
|
||||
revenue = current_user.events
|
||||
.joins(:orders)
|
||||
.where(orders: { status: [ "paid", "completed" ] })
|
||||
.where(orders: { created_at: start_date..end_date })
|
||||
.sum("orders.total_amount_cents") / 100.0
|
||||
|
||||
{
|
||||
month: start_date.strftime("%B %Y"),
|
||||
revenue: revenue
|
||||
}
|
||||
end.reverse
|
||||
end
|
||||
|
||||
# Simplified upcoming events preview - only show if user has orders
|
||||
if @user_orders.any?
|
||||
ordered_event_ids = @user_orders.map(&:event).map(&:id)
|
||||
@upcoming_preview_events = Event.published
|
||||
.upcoming
|
||||
.where.not(id: ordered_event_ids)
|
||||
.order(start_time: :asc)
|
||||
.limit(6)
|
||||
else
|
||||
@upcoming_preview_events = []
|
||||
end
|
||||
end
|
||||
|
||||
# Events page showing all published events with pagination
|
||||
def events
|
||||
@events = Event.published.order(created_at: :desc).page(params[:page])
|
||||
end
|
||||
end
|
||||
|
||||
129
app/controllers/promoter/events_controller.rb
Normal file
129
app/controllers/promoter/events_controller.rb
Normal file
@@ -0,0 +1,129 @@
|
||||
# Promoter Events Controller
|
||||
#
|
||||
# Handles event management for promoters (event organizers)
|
||||
# Allows promoters to create, edit, delete and manage their events
|
||||
class Promoter::EventsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_can_manage_events!
|
||||
before_action :set_event, only: [ :show, :edit, :update, :destroy, :publish, :unpublish, :cancel, :mark_sold_out, :duplicate ]
|
||||
|
||||
# Display all events for the current promoter
|
||||
def index
|
||||
@events = current_user.events.order(created_at: :desc).page(params[:page]).per(10)
|
||||
end
|
||||
|
||||
# Display a specific event for the promoter
|
||||
def show
|
||||
# Event is set by set_event callback
|
||||
end
|
||||
|
||||
# Show form to create a new event
|
||||
def new
|
||||
@event = current_user.events.build
|
||||
end
|
||||
|
||||
# Create a new event
|
||||
def create
|
||||
@event = current_user.events.build(event_params)
|
||||
|
||||
if @event.save
|
||||
redirect_to promoter_event_path(@event), notice: "Event créé avec succès!"
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Show form to edit an existing event
|
||||
def edit
|
||||
# Event is set by set_event callback
|
||||
end
|
||||
|
||||
# Update an existing event
|
||||
def update
|
||||
if @event.update(event_params)
|
||||
redirect_to promoter_event_path(@event), notice: "Event mis à jour avec succès!"
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Delete an event
|
||||
def destroy
|
||||
@event.destroy
|
||||
redirect_to promoter_events_path, notice: "Event supprimé avec succès!"
|
||||
end
|
||||
|
||||
# Publish an event (make it visible to public)
|
||||
def publish
|
||||
if @event.draft?
|
||||
@event.update(state: :published)
|
||||
redirect_to promoter_event_path(@event), notice: "Event publié avec succès!"
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être publié."
|
||||
end
|
||||
end
|
||||
|
||||
# Unpublish an event (make it draft)
|
||||
def unpublish
|
||||
if @event.published?
|
||||
@event.update(state: :draft)
|
||||
redirect_to promoter_event_path(@event), notice: "Event dépublié avec succès!"
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être dépublié."
|
||||
end
|
||||
end
|
||||
|
||||
# Cancel an event
|
||||
def cancel
|
||||
if @event.published?
|
||||
@event.update(state: :canceled)
|
||||
redirect_to promoter_event_path(@event), notice: "Event annulé avec succès!"
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être annulé."
|
||||
end
|
||||
end
|
||||
|
||||
# Mark event as sold out
|
||||
def mark_sold_out
|
||||
if @event.published?
|
||||
@event.update(state: :sold_out)
|
||||
redirect_to promoter_event_path(@event), notice: "Event marqué comme complet!"
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Cet event ne peut pas être marqué comme complet."
|
||||
end
|
||||
end
|
||||
|
||||
# Duplicate an event and all its ticket types
|
||||
def duplicate
|
||||
clone_ticket_types = params[:clone_ticket_types] == "true"
|
||||
@new_event = @event.duplicate(clone_ticket_types: clone_ticket_types)
|
||||
|
||||
if @new_event
|
||||
redirect_to edit_promoter_event_path(@new_event), notice: "Événement dupliqué avec succès! Vous pouvez maintenant modifier les détails de l'événement copié."
|
||||
else
|
||||
redirect_to promoter_event_path(@event), alert: "Erreur lors de la duplication de l'événement."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_manage_events!
|
||||
unless current_user.can_manage_events?
|
||||
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
|
||||
end
|
||||
end
|
||||
|
||||
def set_event
|
||||
@event = current_user.events.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
|
||||
end
|
||||
|
||||
def event_params
|
||||
params.require(:event).permit(
|
||||
:name, :slug, :description, :image,
|
||||
:venue_name, :venue_address, :latitude, :longitude,
|
||||
:start_time, :end_time, :featured, :allow_booking_during_event
|
||||
)
|
||||
end
|
||||
end
|
||||
104
app/controllers/promoter/ticket_types_controller.rb
Normal file
104
app/controllers/promoter/ticket_types_controller.rb
Normal file
@@ -0,0 +1,104 @@
|
||||
# Promoter Ticket Types Controller
|
||||
#
|
||||
# Handles ticket type (bundle) management for promoters
|
||||
# Allows promoters to create, edit, delete and manage ticket types for their events
|
||||
class Promoter::TicketTypesController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :ensure_can_manage_events!
|
||||
before_action :set_event
|
||||
before_action :set_ticket_type, only: [ :show, :edit, :update, :destroy ]
|
||||
|
||||
# Display all ticket types for an event
|
||||
def index
|
||||
@ticket_types = @event.ticket_types.order(:created_at)
|
||||
end
|
||||
|
||||
# Display a specific ticket type
|
||||
def show
|
||||
# Ticket type is set by set_ticket_type callback
|
||||
end
|
||||
|
||||
# Show form to create a new ticket type
|
||||
def new
|
||||
@ticket_type = @event.ticket_types.build
|
||||
# Set default values
|
||||
@ticket_type.sale_start_at = Time.current
|
||||
@ticket_type.sale_end_at = @event.start_time || 1.week.from_now
|
||||
@ticket_type.requires_id = false
|
||||
end
|
||||
|
||||
# Create a new ticket type
|
||||
def create
|
||||
@ticket_type = @event.ticket_types.build(ticket_type_params)
|
||||
|
||||
if @ticket_type.save
|
||||
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet créé avec succès!"
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Show form to edit an existing ticket type
|
||||
def edit
|
||||
# Ticket type is set by set_ticket_type callback
|
||||
end
|
||||
|
||||
# Update an existing ticket type
|
||||
def update
|
||||
if @ticket_type.update(ticket_type_params)
|
||||
redirect_to promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet mis à jour avec succès!"
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
# Delete a ticket type
|
||||
def destroy
|
||||
if @ticket_type.tickets.any?
|
||||
redirect_to promoter_event_ticket_types_path(@event), alert: "Impossible de supprimer ce type de billet car des billets ont déjà été vendus."
|
||||
else
|
||||
@ticket_type.destroy
|
||||
redirect_to promoter_event_ticket_types_path(@event), notice: "Type de billet supprimé avec succès!"
|
||||
end
|
||||
end
|
||||
|
||||
# Duplicate an existing ticket type
|
||||
def duplicate
|
||||
original = @event.ticket_types.find(params[:id])
|
||||
@ticket_type = original.dup
|
||||
@ticket_type.name = "#{original.name} (Copie)"
|
||||
|
||||
if @ticket_type.save
|
||||
redirect_to edit_promoter_event_ticket_type_path(@event, @ticket_type), notice: "Type de billet dupliqué avec succès!"
|
||||
else
|
||||
redirect_to promoter_event_ticket_types_path(@event), alert: "Erreur lors de la duplication."
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_can_manage_events!
|
||||
unless current_user.can_manage_events?
|
||||
redirect_to dashboard_path, alert: "Vous n'avez pas les permissions nécessaires pour gérer des événements."
|
||||
end
|
||||
end
|
||||
|
||||
def set_event
|
||||
@event = current_user.events.find(params[:event_id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to promoter_events_path, alert: "Event non trouvé ou vous n'avez pas accès à cet event."
|
||||
end
|
||||
|
||||
def set_ticket_type
|
||||
@ticket_type = @event.ticket_types.find(params[:id])
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to promoter_event_ticket_types_path(@event), alert: "Type de billet non trouvé."
|
||||
end
|
||||
|
||||
def ticket_type_params
|
||||
params.require(:ticket_type).permit(
|
||||
:name, :description, :price_euros, :quantity,
|
||||
:sale_start_at, :sale_end_at, :minimum_age, :requires_id
|
||||
)
|
||||
end
|
||||
end
|
||||
26
app/controllers/settings_controller.rb
Normal file
26
app/controllers/settings_controller.rb
Normal file
@@ -0,0 +1,26 @@
|
||||
class SettingsController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_user
|
||||
|
||||
def show
|
||||
# Show settings page
|
||||
end
|
||||
|
||||
def update
|
||||
if @user.update(user_params)
|
||||
redirect_to settings_path, notice: "Vos informations ont été mises à jour avec succès."
|
||||
else
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_user
|
||||
@user = current_user
|
||||
end
|
||||
|
||||
def user_params
|
||||
params.require(:user).permit(:first_name, :last_name, :is_professionnal)
|
||||
end
|
||||
end
|
||||
115
app/controllers/tickets_controller.rb
Normal file
115
app/controllers/tickets_controller.rb
Normal file
@@ -0,0 +1,115 @@
|
||||
# Legacy tickets controller - redirects to new order system
|
||||
#
|
||||
# This controller now primarily handles legacy redirects and backward compatibility
|
||||
# Most ticket creation functionality has been moved to OrdersController
|
||||
class TicketsController < ApplicationController
|
||||
before_action :authenticate_user!, only: [ :payment_success, :payment_cancel, :show, :download ]
|
||||
before_action :set_event, only: [ :checkout, :retry_payment ]
|
||||
|
||||
|
||||
# Redirect to order-based checkout
|
||||
def checkout
|
||||
# Check for draft order
|
||||
if session[:draft_order_id].present?
|
||||
order = current_user.orders.find_by(id: session[:draft_order_id], status: "draft")
|
||||
if order.present?
|
||||
redirect_to order_checkout_path(order)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# No order found
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
redirect_to event_path(@event.slug, @event), alert: "Aucun billet en attente de paiement"
|
||||
end
|
||||
|
||||
# Redirect to order-based payment success
|
||||
def payment_success
|
||||
redirect_to order_payment_success_path(session_id: params[:session_id])
|
||||
end
|
||||
|
||||
# Redirect to order-based payment cancel
|
||||
def payment_cancel
|
||||
redirect_to order_payment_cancel_path
|
||||
end
|
||||
|
||||
# Redirect retry payment to order system
|
||||
def retry_payment
|
||||
@event = Event.includes(:ticket_types).find(params[:id])
|
||||
|
||||
# Look for draft order for this event
|
||||
order = current_user.orders.find_by(event: @event, status: "draft")
|
||||
|
||||
if order&.can_retry_payment?
|
||||
redirect_to retry_payment_order_path(order)
|
||||
else
|
||||
redirect_to event_path(@event.slug, @event),
|
||||
alert: "Aucune commande disponible pour un nouveau paiement"
|
||||
end
|
||||
end
|
||||
|
||||
# Display ticket details
|
||||
def show
|
||||
# Find ticket by qr code id
|
||||
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
|
||||
.find_by(tickets: { qr_code: params[:qr_code] })
|
||||
|
||||
if @ticket.nil?
|
||||
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||
return
|
||||
end
|
||||
|
||||
@event = @ticket.event
|
||||
@order = @ticket.order
|
||||
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||
end
|
||||
|
||||
# Download PDF ticket - only accessible by ticket owner
|
||||
# User must be authenticated to download ticket
|
||||
# TODO: change ID to an unique identifier (UUID)
|
||||
def download
|
||||
# Find ticket by qr code id
|
||||
@ticket = Ticket.joins(order: :user).includes(:event, :ticket_type, order: :user)
|
||||
.find_by(tickets: { qr_code: params[:qr_code] })
|
||||
|
||||
if @ticket.nil?
|
||||
redirect_to dashboard_path, alert: "Billet non trouvé ou vous n'avez pas l'autorisation d'accéder à ce billet"
|
||||
return
|
||||
end
|
||||
|
||||
# Generate PDF
|
||||
pdf_content = @ticket.to_pdf
|
||||
|
||||
# Send PDF as download
|
||||
send_data pdf_content,
|
||||
filename: "ticket_#{@ticket.id}_#{@ticket.event.name.parameterize}.pdf",
|
||||
type: "application/pdf",
|
||||
disposition: "attachment"
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
redirect_to dashboard_path, alert: "Billet non trouvé"
|
||||
rescue => e
|
||||
Rails.logger.error "Error generating ticket PDF: #{e.message}"
|
||||
redirect_to dashboard_path, alert: "Erreur lors de la génération du billet"
|
||||
end
|
||||
private
|
||||
|
||||
def set_event
|
||||
event_id = params[:id] || session[:event_id]
|
||||
|
||||
Rails.logger.debug "TicketsController#set_event - params[:id]: #{params[:id].inspect}, session[:event_id]: #{session[:event_id].inspect}"
|
||||
|
||||
unless event_id
|
||||
Rails.logger.error "TicketsController#set_event - No event ID found"
|
||||
redirect_to events_path, alert: "Aucun événement spécifié"
|
||||
return
|
||||
end
|
||||
|
||||
@event = Event.includes(:ticket_types).find(event_id)
|
||||
Rails.logger.debug "TicketsController#set_event - Found event: #{@event.id} - #{@event.name}"
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
Rails.logger.error "TicketsController#set_event - Event not found with ID: #{event_id}"
|
||||
redirect_to events_path, alert: "Événement non trouvé"
|
||||
end
|
||||
end
|
||||
10
app/helpers/application_helper.rb
Normal file → Executable file
10
app/helpers/application_helper.rb
Normal file → Executable file
@@ -1,2 +1,12 @@
|
||||
module ApplicationHelper
|
||||
# Convert price from cents to float
|
||||
def format_price(cents)
|
||||
(cents.to_f / 100).round(2)
|
||||
end
|
||||
|
||||
# Include flash message helpers
|
||||
include FlashMessagesHelper
|
||||
|
||||
# Include Stripe helper
|
||||
include StripeHelper
|
||||
end
|
||||
|
||||
51
app/helpers/flash_messages_helper.rb
Executable file
51
app/helpers/flash_messages_helper.rb
Executable file
@@ -0,0 +1,51 @@
|
||||
# Flash messages helper for consistent styling across the application
|
||||
#
|
||||
# Provides standardized CSS classes and icons for different types of flash messages
|
||||
# using Tailwind CSS classes and Lucide icons for consistent UI presentation
|
||||
module FlashMessagesHelper
|
||||
# Return appropriate Tailwind CSS classes for different flash message types
|
||||
#
|
||||
# @param type [String, Symbol] The flash message type (notice, error, warning, info)
|
||||
# @return [String] Tailwind CSS classes for styling the flash message container
|
||||
#
|
||||
# Examples:
|
||||
# flash_class('success') # => "bg-green-50 text-green-800 border-green-200"
|
||||
# flash_class('error') # => "bg-red-50 text-red-800 border-red-200"
|
||||
def flash_class(type)
|
||||
case type.to_s
|
||||
when "notice", "success"
|
||||
"bg-green-50 text-green-800 border-green-200"
|
||||
when "error", "alert"
|
||||
"bg-red-50 text-red-800 border-red-200"
|
||||
when "warning"
|
||||
"bg-yellow-50 text-yellow-800 border-yellow-200"
|
||||
when "info"
|
||||
"bg-blue-50 text-blue-800 border-blue-200"
|
||||
else
|
||||
"bg-gray-50 text-gray-800 border-gray-200"
|
||||
end
|
||||
end
|
||||
|
||||
# Return appropriate Lucide icon for different flash message types
|
||||
#
|
||||
# @param type [String, Symbol] The flash message type
|
||||
# @return [String] HTML content tag with Lucide icon data attribute
|
||||
#
|
||||
# Examples:
|
||||
# flash_icon('success') # => <i data-lucide="check-circle" class="..."></i>
|
||||
# flash_icon('error') # => <i data-lucide="x-circle" class="..."></i>
|
||||
def flash_icon(type)
|
||||
case type.to_s
|
||||
when "notice", "success"
|
||||
content_tag :i, "", "data-lucide": "check-circle", class: "w-5 h-5 flex-shrink-0"
|
||||
when "error", "alert"
|
||||
content_tag :i, "", "data-lucide": "x-circle", class: "w-5 h-5 flex-shrink-0"
|
||||
when "warning"
|
||||
content_tag :i, "", "data-lucide": "alert-triangle", class: "w-5 h-5 flex-shrink-0"
|
||||
when "info"
|
||||
content_tag :i, "", "data-lucide": "info", class: "w-5 h-5 flex-shrink-0"
|
||||
else
|
||||
content_tag :i, "", "data-lucide": "bell", class: "w-5 h-5 flex-shrink-0"
|
||||
end
|
||||
end
|
||||
end
|
||||
82
app/helpers/lucide_helper.rb
Normal file
82
app/helpers/lucide_helper.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
module LucideHelper
|
||||
# Create a Lucide icon element
|
||||
#
|
||||
# @param name [String] The name of the Lucide icon
|
||||
# @param options [Hash] Additional options
|
||||
# @option options [String] :class Additional CSS classes
|
||||
# @option options [String] :size Size class (e.g., 'w-4 h-4', 'w-6 h-6')
|
||||
# @option options [Hash] :data Additional data attributes
|
||||
#
|
||||
# @return [String] HTML string for the icon
|
||||
#
|
||||
# Usage:
|
||||
# lucide_icon('user')
|
||||
# lucide_icon('check-circle', class: 'text-green-500', size: 'w-5 h-5')
|
||||
# lucide_icon('menu', data: { action: 'click->header#toggleMenu' })
|
||||
def lucide_icon(name, options = {})
|
||||
css_classes = [ "lucide-icon" ]
|
||||
css_classes << options[:size] if options[:size]
|
||||
css_classes << options[:class] if options[:class]
|
||||
|
||||
data_attributes = { lucide: name }
|
||||
data_attributes.merge!(options[:data]) if options[:data]
|
||||
|
||||
content_tag :i, "",
|
||||
class: css_classes.join(" "),
|
||||
data: data_attributes,
|
||||
**options.except(:class, :size, :data)
|
||||
end
|
||||
|
||||
# Create a button with a Lucide icon
|
||||
#
|
||||
# @param name [String] The name of the Lucide icon
|
||||
# @param options [Hash] Button options
|
||||
# @option options [String] :text Button text (optional)
|
||||
# @option options [String] :class Additional CSS classes for button
|
||||
# @option options [String] :icon_class Additional CSS classes for icon
|
||||
# @option options [String] :icon_size Size class for icon
|
||||
#
|
||||
# Usage:
|
||||
# lucide_button('plus', text: 'Add Item', class: 'btn btn-primary')
|
||||
# lucide_button('trash-2', class: 'btn-danger', data: { confirm: 'Are you sure?' })
|
||||
def lucide_button(name, options = {})
|
||||
text = options.delete(:text)
|
||||
icon_class = options.delete(:icon_class)
|
||||
icon_size = options.delete(:icon_size) || "w-4 h-4"
|
||||
|
||||
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
||||
|
||||
content = if text.present?
|
||||
safe_join([ icon, " ", text ])
|
||||
else
|
||||
icon
|
||||
end
|
||||
|
||||
content_tag :button, content, options
|
||||
end
|
||||
|
||||
# Create a link with a Lucide icon
|
||||
#
|
||||
# @param name [String] The name of the Lucide icon
|
||||
# @param url [String] The URL for the link
|
||||
# @param options [Hash] Link options
|
||||
#
|
||||
# Usage:
|
||||
# lucide_link('edit', edit_user_path(user), text: 'Edit')
|
||||
# lucide_link('external-link', 'https://example.com', text: 'Visit', target: '_blank')
|
||||
def lucide_link(name, url, options = {})
|
||||
text = options.delete(:text)
|
||||
icon_class = options.delete(:icon_class)
|
||||
icon_size = options.delete(:icon_size) || "w-4 h-4"
|
||||
|
||||
icon = lucide_icon(name, class: icon_class, size: icon_size)
|
||||
|
||||
content = if text.present?
|
||||
safe_join([ icon, " ", text ])
|
||||
else
|
||||
icon
|
||||
end
|
||||
|
||||
link_to content, url, options
|
||||
end
|
||||
end
|
||||
2
app/helpers/onboarding_helper.rb
Normal file
2
app/helpers/onboarding_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module OnboardingHelper
|
||||
end
|
||||
0
app/helpers/pages_helper.rb
Normal file → Executable file
0
app/helpers/pages_helper.rb
Normal file → Executable file
17
app/helpers/stripe_helper.rb
Normal file
17
app/helpers/stripe_helper.rb
Normal file
@@ -0,0 +1,17 @@
|
||||
module StripeHelper
|
||||
# Safely call Stripe methods with error handling
|
||||
def safe_stripe_call(&block)
|
||||
# Check if Stripe is properly configured
|
||||
return nil unless Rails.application.config.stripe[:secret_key].present?
|
||||
|
||||
# Stripe is now initialized at application startup
|
||||
Rails.logger.debug "Using globally initialized Stripe"
|
||||
|
||||
begin
|
||||
yield if block_given?
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error "Stripe Error: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
2
app/helpers/tickets_helper.rb
Normal file
2
app/helpers/tickets_helper.rb
Normal file
@@ -0,0 +1,2 @@
|
||||
module TicketsHelper
|
||||
end
|
||||
24
app/javascript/application.js
Normal file → Executable file
24
app/javascript/application.js
Normal file → Executable file
@@ -1,3 +1,23 @@
|
||||
// Entry point for the build script in your package.json
|
||||
import "@hotwired/turbo-rails"
|
||||
import "./controllers"
|
||||
// This file initializes the Rails application with Turbo and Stimulus controllers
|
||||
|
||||
// Import Turbo Rails for SPA-like navigation
|
||||
import "@hotwired/turbo-rails";
|
||||
|
||||
// Import all Stimulus controllers
|
||||
import "./controllers";
|
||||
|
||||
// Import and initialize Lucide icons globally
|
||||
import { createIcons, icons } from 'lucide';
|
||||
|
||||
// Initialize icons globally
|
||||
function initializeLucideIcons() {
|
||||
createIcons({ icons });
|
||||
}
|
||||
|
||||
// Run on initial page load
|
||||
document.addEventListener('DOMContentLoaded', initializeLucideIcons);
|
||||
|
||||
// Run on Turbo navigation (Rails 7+ SPA behavior)
|
||||
document.addEventListener('turbo:render', initializeLucideIcons);
|
||||
document.addEventListener('turbo:frame-render', initializeLucideIcons);
|
||||
|
||||
21
app/javascript/controllers/application.js
Normal file → Executable file
21
app/javascript/controllers/application.js
Normal file → Executable file
@@ -1,9 +1,20 @@
|
||||
import { Application } from "@hotwired/stimulus"
|
||||
// Main Stimulus application controller
|
||||
// Initializes the Stimulus framework and makes it available globally
|
||||
import { Application } from "@hotwired/stimulus";
|
||||
|
||||
const application = Application.start()
|
||||
// Create and start the Stimulus application
|
||||
const application = Application.start();
|
||||
|
||||
// Configure Stimulus development experience
|
||||
application.debug = false
|
||||
window.Stimulus = application
|
||||
// Set to false in production to avoid unnecessary logging
|
||||
application.debug = false;
|
||||
|
||||
export { application }
|
||||
// Make Stimulus globally available for debugging purposes
|
||||
window.Stimulus = application;
|
||||
|
||||
// Configure Alpine js (commented out as it's not currently used)
|
||||
// import Alpine from "alpinejs";
|
||||
// window.Alpine = Alpine;
|
||||
// Alpine.start();
|
||||
|
||||
export { application };
|
||||
|
||||
71
app/javascript/controllers/countdown_controller.js
Normal file
71
app/javascript/controllers/countdown_controller.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Countdown controller for displaying remaining time until order expiration
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
expiresAt: String, // ISO timestamp when the order expires
|
||||
orderId: Number // Order ID for identification
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Parse the expiration timestamp
|
||||
this.expirationTime = new Date(this.expiresAtValue).getTime()
|
||||
|
||||
// Find the countdown element
|
||||
this.countdownElement = this.element.querySelector('.countdown-timer')
|
||||
|
||||
if (this.countdownElement && !isNaN(this.expirationTime)) {
|
||||
// Start the countdown
|
||||
this.updateCountdown()
|
||||
this.timer = setInterval(() => this.updateCountdown(), 1000)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up the interval when the controller disconnects
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
}
|
||||
|
||||
updateCountdown() {
|
||||
const now = new Date().getTime()
|
||||
const distance = this.expirationTime - now
|
||||
|
||||
// If the countdown is finished
|
||||
if (distance < 0) {
|
||||
this.countdownElement.innerHTML = "EXPIRÉ"
|
||||
this.countdownElement.classList.add("text-red-600", "font-bold")
|
||||
this.countdownElement.classList.remove("text-orange-600")
|
||||
|
||||
// Add a more urgent visual indicator
|
||||
this.element.classList.add("bg-red-50", "border-red-200")
|
||||
this.element.classList.remove("bg-orange-50", "border-orange-200")
|
||||
|
||||
// Stop the timer
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate time components
|
||||
const seconds = Math.floor(distance / 1000)
|
||||
|
||||
// Display the result
|
||||
this.countdownElement.innerHTML = `${seconds} secondes`
|
||||
|
||||
// Add urgency styling when time is running low
|
||||
if (seconds < 60) {
|
||||
this.countdownElement.classList.add("text-red-600", "font-bold")
|
||||
this.countdownElement.classList.remove("text-orange-600")
|
||||
|
||||
// Add background warning for extra urgency
|
||||
this.element.classList.add("bg-red-50", "border-red-200")
|
||||
this.element.classList.remove("bg-orange-50", "border-orange-200")
|
||||
} else if (seconds < 300) { // Less than 5 minutes
|
||||
this.countdownElement.classList.add("text-orange-600", "font-bold")
|
||||
this.element.classList.add("bg-orange-50", "border-orange-200")
|
||||
}
|
||||
}
|
||||
}
|
||||
85
app/javascript/controllers/counter_controller.js
Executable file
85
app/javascript/controllers/counter_controller.js
Executable file
@@ -0,0 +1,85 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Counter controller for animating number increments
|
||||
// Used for statistics and numerical displays that animate when they come into view
|
||||
export default class extends Controller {
|
||||
// Define controller values with defaults
|
||||
static values = {
|
||||
target: { type: Number, default: 0 }, // Target number to count to
|
||||
decimal: { type: Boolean, default: false }, // Whether to display decimal values
|
||||
duration: { type: Number, default: 2000 } // Animation duration in milliseconds
|
||||
}
|
||||
|
||||
// Set up the intersection observer when the controller connects
|
||||
connect() {
|
||||
// Create an intersection observer to trigger animation when element is visible
|
||||
this.observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
// Start animation when element is 50% visible
|
||||
if (entry.isIntersecting) {
|
||||
this.animate()
|
||||
// Stop observing after animation starts
|
||||
this.observer.unobserve(this.element)
|
||||
}
|
||||
})
|
||||
}, { threshold: 0.5 })
|
||||
|
||||
// Begin observing this element
|
||||
this.observer.observe(this.element)
|
||||
}
|
||||
|
||||
// Clean up the observer when the controller disconnects
|
||||
disconnect() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
// Animate the counter from 0 to the target value
|
||||
animate() {
|
||||
// Find the target element with data-target-value
|
||||
const targetElement = this.element.querySelector('.stat-number');
|
||||
if (!targetElement) return;
|
||||
|
||||
// Get the target value
|
||||
this.targetValue = parseInt(targetElement.getAttribute('data-target-value'), 10) || this.targetValue;
|
||||
|
||||
const startValue = 0;
|
||||
const startTime = performance.now();
|
||||
|
||||
// Update counter function using requestAnimationFrame for smooth animation
|
||||
const updateCounter = (currentTime) => {
|
||||
const elapsedTime = currentTime - startTime;
|
||||
const progress = Math.min(elapsedTime / this.durationValue, 1);
|
||||
|
||||
// Easing function for smooth animation (ease-out quartic)
|
||||
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
||||
|
||||
let currentValue = startValue + (this.targetValue - startValue) * easeOutQuart;
|
||||
|
||||
// Format value based on decimal setting
|
||||
if (this.decimalValue && this.targetValue < 10) {
|
||||
currentValue = currentValue.toFixed(1);
|
||||
} else {
|
||||
currentValue = Math.floor(currentValue);
|
||||
}
|
||||
|
||||
// Update only the text content of the target element
|
||||
targetElement.textContent = currentValue;
|
||||
|
||||
// Continue animation until complete
|
||||
if (progress < 1) {
|
||||
requestAnimationFrame(updateCounter);
|
||||
} else {
|
||||
// Ensure final value is exactly the target
|
||||
const finalValue = this.decimalValue && this.targetValue < 10
|
||||
? this.targetValue.toFixed(1)
|
||||
: this.targetValue;
|
||||
targetElement.textContent = finalValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Start the animation
|
||||
requestAnimationFrame(updateCounter);
|
||||
}
|
||||
}
|
||||
53
app/javascript/controllers/event_duplication_controller.js
Normal file
53
app/javascript/controllers/event_duplication_controller.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["cloneTicketTypes"]
|
||||
static values = {
|
||||
duplicateUrl: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
// Get modal element from the document
|
||||
this.modalElement = document.querySelector('[data-event-duplication-target="modal"]')
|
||||
}
|
||||
|
||||
open() {
|
||||
this.modalElement.classList.remove('hidden')
|
||||
document.body.classList.add('overflow-hidden')
|
||||
}
|
||||
|
||||
close() {
|
||||
this.modalElement.classList.add('hidden')
|
||||
document.body.classList.remove('overflow-hidden')
|
||||
}
|
||||
|
||||
duplicate() {
|
||||
const cloneTicketTypes = this.cloneTicketTypesTarget.checked
|
||||
|
||||
// Create form data
|
||||
const formData = new FormData()
|
||||
formData.append('clone_ticket_types', cloneTicketTypes)
|
||||
formData.append('authenticity_token', document.querySelector('meta[name="csrf-token"]').getAttribute('content'))
|
||||
|
||||
// Send request to duplicate endpoint
|
||||
fetch(this.duplicateUrlValue, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url
|
||||
} else {
|
||||
return response.json()
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error)
|
||||
alert('Erreur lors de la duplication de l\'événement.')
|
||||
this.close()
|
||||
})
|
||||
}
|
||||
}
|
||||
667
app/javascript/controllers/event_form_controller.js
Normal file
667
app/javascript/controllers/event_form_controller.js
Normal file
@@ -0,0 +1,667 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import slug from 'slug'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["name", "slug", "latitude", "longitude", "address", "mapLinksContainer", "geocodingSpinner", "getCurrentLocationBtn", "getCurrentLocationIcon", "getCurrentLocationText", "previewLocationBtn", "previewLocationIcon", "previewLocationText", "messagesContainer"]
|
||||
static values = {
|
||||
geocodeDelay: { type: Number, default: 1500 } // Delay before auto-geocoding
|
||||
}
|
||||
|
||||
static lastGeocodingRequest = 0
|
||||
|
||||
connect() {
|
||||
this.geocodeTimeout = null
|
||||
this.isManualGeocodingInProgress = false
|
||||
|
||||
// Initialize map links if we have an address and coordinates already exist
|
||||
if (this.hasAddressTarget && this.addressTarget.value.trim() &&
|
||||
this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||
this.updateMapLinks()
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.geocodeTimeout) {
|
||||
clearTimeout(this.geocodeTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate slug from name
|
||||
generateSlug() {
|
||||
const name = this.nameTarget.value
|
||||
|
||||
this.slugTarget.value = slug(name)
|
||||
}
|
||||
|
||||
// Handle address changes with debounced geocoding
|
||||
addressChanged() {
|
||||
// Clear any existing timeout
|
||||
if (this.geocodeTimeout) {
|
||||
clearTimeout(this.geocodeTimeout)
|
||||
}
|
||||
|
||||
const address = this.addressTarget.value.trim()
|
||||
|
||||
if (!address) {
|
||||
this.clearCoordinates()
|
||||
this.clearMapLinks()
|
||||
this.hideGeocodingSpinner()
|
||||
return
|
||||
}
|
||||
|
||||
// Show spinner after a brief delay to avoid flickering for very short typing
|
||||
const showSpinnerTimeout = setTimeout(() => {
|
||||
this.showGeocodingSpinner()
|
||||
}, 300)
|
||||
|
||||
// Debounce geocoding to avoid too many API calls
|
||||
this.geocodeTimeout = setTimeout(async () => {
|
||||
clearTimeout(showSpinnerTimeout) // Cancel spinner delay if still pending
|
||||
this.showGeocodingSpinner() // Show spinner for sure now
|
||||
|
||||
try {
|
||||
await this.geocodeAddressQuiet(address)
|
||||
} finally {
|
||||
this.hideGeocodingSpinner()
|
||||
}
|
||||
}, this.geocodeDelayValue)
|
||||
}
|
||||
|
||||
// Get user's current location and reverse geocode to address
|
||||
async getCurrentLocation() {
|
||||
if (!navigator.geolocation) {
|
||||
this.showLocationError("La géolocalisation n'est pas supportée par ce navigateur.")
|
||||
return
|
||||
}
|
||||
|
||||
this.showGetCurrentLocationLoading()
|
||||
this.showLocationLoading()
|
||||
|
||||
const options = {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 60000
|
||||
}
|
||||
|
||||
try {
|
||||
const position = await this.getCurrentPositionPromise(options)
|
||||
const lat = position.coords.latitude
|
||||
const lng = position.coords.longitude
|
||||
|
||||
// Set coordinates first
|
||||
this.latitudeTarget.value = lat.toFixed(6)
|
||||
this.longitudeTarget.value = lng.toFixed(6)
|
||||
|
||||
// Then reverse geocode to get address
|
||||
const address = await this.reverseGeocode(lat, lng)
|
||||
|
||||
if (address) {
|
||||
this.addressTarget.value = address
|
||||
this.showLocationSuccess("Position actuelle détectée et adresse mise à jour!")
|
||||
} else {
|
||||
this.showLocationSuccess("Position actuelle détectée!")
|
||||
}
|
||||
|
||||
this.updateMapLinks()
|
||||
this.hideGetCurrentLocationLoading()
|
||||
|
||||
} catch (error) {
|
||||
this.hideGetCurrentLocationLoading()
|
||||
this.hideLocationLoading()
|
||||
let message = "Erreur lors de la récupération de la localisation."
|
||||
|
||||
switch(error.code) {
|
||||
case error.PERMISSION_DENIED:
|
||||
message = "L'accès à la localisation a été refusé."
|
||||
break
|
||||
case error.POSITION_UNAVAILABLE:
|
||||
message = "Les informations de localisation ne sont pas disponibles."
|
||||
break
|
||||
case error.TIMEOUT:
|
||||
message = "La demande de localisation a expiré."
|
||||
break
|
||||
}
|
||||
|
||||
this.showLocationError(message)
|
||||
} finally {
|
||||
this.hideGetCurrentLocationLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// Promise wrapper for geolocation
|
||||
getCurrentPositionPromise(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, options)
|
||||
})
|
||||
}
|
||||
|
||||
// Reverse geocode coordinates to get address
|
||||
async reverseGeocode(lat, lng) {
|
||||
try {
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lng}&format=json&addressdetails=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
console.log('Reverse geocoding response:', data) // Debug log
|
||||
|
||||
if (data && data.display_name) {
|
||||
return data.display_name
|
||||
}
|
||||
|
||||
return null
|
||||
} catch (error) {
|
||||
console.log("Reverse geocoding failed:", error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// Preview location - same as updating map links but with user feedback
|
||||
previewLocation() {
|
||||
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||
this.showLocationError("Veuillez saisir une adresse pour la prévisualiser.")
|
||||
return
|
||||
}
|
||||
|
||||
// If we already have coordinates, just update map links
|
||||
if (this.hasLatitudeTarget && this.hasLongitudeTarget &&
|
||||
this.latitudeTarget.value && this.longitudeTarget.value) {
|
||||
this.updateMapLinks()
|
||||
this.showLocationSuccess("Liens de carte mis à jour!")
|
||||
} else {
|
||||
// Otherwise geocode the address first
|
||||
this.showPreviewLocationLoading()
|
||||
this.geocodeAddress().finally(() => {
|
||||
this.hidePreviewLocationLoading()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Geocode address manually (with user feedback)
|
||||
async geocodeAddress() {
|
||||
if (!this.hasAddressTarget || !this.addressTarget.value.trim()) {
|
||||
this.showLocationError("Veuillez saisir une adresse.")
|
||||
return
|
||||
}
|
||||
|
||||
const address = this.addressTarget.value.trim()
|
||||
|
||||
try {
|
||||
this.isManualGeocodingInProgress = true
|
||||
this.showLocationLoading()
|
||||
const result = await this.performGeocode(address)
|
||||
|
||||
if (result) {
|
||||
this.latitudeTarget.value = result.lat
|
||||
this.longitudeTarget.value = result.lng
|
||||
this.updateMapLinks()
|
||||
|
||||
if (result.accuracy === 'exact') {
|
||||
this.showLocationSuccess("Coordonnées exactes trouvées pour cette adresse!")
|
||||
} else {
|
||||
this.showLocationSuccess(`Coordonnées approximatives trouvées: ${result.display_name}`)
|
||||
}
|
||||
} else {
|
||||
this.showLocationError("Impossible de trouver les coordonnées pour cette adresse.")
|
||||
}
|
||||
} catch (error) {
|
||||
this.showLocationError("Erreur lors de la recherche de l'adresse.")
|
||||
} finally {
|
||||
this.isManualGeocodingInProgress = false
|
||||
this.hideLocationLoading()
|
||||
}
|
||||
}
|
||||
|
||||
// Geocode address quietly (no user feedback, for auto-geocoding)
|
||||
async geocodeAddressQuiet(address) {
|
||||
// Skip if address is too short or invalid
|
||||
if (!address || address.length < 5) {
|
||||
this.clearCoordinates()
|
||||
this.clearMapLinks()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.performGeocode(address)
|
||||
|
||||
if (result && result.lat && result.lng) {
|
||||
this.latitudeTarget.value = result.lat
|
||||
this.longitudeTarget.value = result.lng
|
||||
this.updateMapLinks()
|
||||
console.log(`Auto-geocoded "${address}" to ${result.lat}, ${result.lng}`)
|
||||
|
||||
// Show success message based on accuracy
|
||||
if (result.accuracy === 'exact') {
|
||||
this.showGeocodingSuccess("Adresse géolocalisée avec précision", result.display_name)
|
||||
} else {
|
||||
this.showGeocodingSuccess("Adresse géolocalisée approximativement", result.display_name)
|
||||
}
|
||||
} else {
|
||||
// If auto-geocoding fails, show a subtle warning
|
||||
this.showGeocodingWarning(address)
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Auto-geocoding failed:", error)
|
||||
this.showGeocodingWarning(address)
|
||||
}
|
||||
}
|
||||
|
||||
// Perform the actual geocoding request with fallback strategies
|
||||
async performGeocode(address) {
|
||||
// Rate limiting: ensure at least 1 second between requests
|
||||
const now = Date.now()
|
||||
const timeSinceLastRequest = now - (this.constructor.lastGeocodingRequest || 0)
|
||||
if (timeSinceLastRequest < 1000) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 - timeSinceLastRequest))
|
||||
}
|
||||
this.constructor.lastGeocodingRequest = Date.now()
|
||||
|
||||
// Try multiple geocoding strategies
|
||||
const strategies = [
|
||||
// Strategy 1: Exact address
|
||||
address,
|
||||
// Strategy 2: Street name + city (remove house number)
|
||||
address.replace(/^\d+\s*/, ''),
|
||||
// Strategy 3: Just city and postal code
|
||||
this.extractCityAndPostalCode(address)
|
||||
].filter(Boolean) // Remove null/undefined values
|
||||
|
||||
for (let i = 0; i < strategies.length; i++) {
|
||||
const searchAddress = strategies[i]
|
||||
console.log(`Geocoding attempt ${i + 1}: "${searchAddress}"`)
|
||||
|
||||
// Show progress for manual geocoding (not auto-geocoding)
|
||||
if (this.isManualGeocodingInProgress) {
|
||||
const strategyNames = ['adresse complète', 'rue et ville', 'ville seulement']
|
||||
this.showGeocodingProgress(strategyNames[i] || `stratégie ${i + 1}`, `${i + 1}/${strategies.length}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.tryGeocode(searchAddress)
|
||||
if (result) {
|
||||
console.log(`Geocoding successful with strategy ${i + 1}`)
|
||||
this.hideMessage("geocoding-progress")
|
||||
return result
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`Strategy ${i + 1} failed:`, error.message)
|
||||
}
|
||||
|
||||
// Add small delay between attempts
|
||||
if (i < strategies.length - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 500))
|
||||
}
|
||||
}
|
||||
|
||||
this.hideMessage("geocoding-progress")
|
||||
|
||||
console.log('All geocoding strategies failed')
|
||||
return null
|
||||
}
|
||||
|
||||
// Extract city and postal code from address
|
||||
extractCityAndPostalCode(address) {
|
||||
// Look for French postal code pattern (5 digits) + city
|
||||
const match = address.match(/(\d{5})\s+([^,]+)/);
|
||||
if (match) {
|
||||
return `${match[1]} ${match[2].trim()}`
|
||||
}
|
||||
|
||||
// Fallback: extract last part after comma (assume it's city)
|
||||
const parts = address.split(',')
|
||||
if (parts.length > 1) {
|
||||
return parts[parts.length - 1].trim()
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Try a single geocoding request
|
||||
async tryGeocode(address) {
|
||||
const encodedAddress = encodeURIComponent(address.trim())
|
||||
|
||||
const response = await fetch(`https://nominatim.openstreetmap.org/search?q=${encodedAddress}&format=json&limit=1&addressdetails=1`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'User-Agent': 'AperoNight Event Platform/1.0 (https://aperonight.com)',
|
||||
'Accept': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data && data.length > 0) {
|
||||
const result = data[0]
|
||||
return {
|
||||
lat: parseFloat(result.lat).toFixed(6),
|
||||
lng: parseFloat(result.lon).toFixed(6),
|
||||
display_name: result.display_name,
|
||||
accuracy: address === result.display_name ? 'exact' : 'approximate'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Update map links based on current coordinates
|
||||
updateMapLinks() {
|
||||
if (!this.hasMapLinksContainerTarget) return
|
||||
|
||||
const lat = parseFloat(this.latitudeTarget.value)
|
||||
const lng = parseFloat(this.longitudeTarget.value)
|
||||
const address = this.hasAddressTarget ? this.addressTarget.value.trim() : ""
|
||||
|
||||
if (isNaN(lat) || isNaN(lng) || !address) {
|
||||
this.clearMapLinks()
|
||||
return
|
||||
}
|
||||
|
||||
const links = this.generateMapLinks(lat, lng, address)
|
||||
this.mapLinksContainerTarget.innerHTML = links
|
||||
}
|
||||
|
||||
// Generate map links HTML
|
||||
generateMapLinks(lat, lng, address) {
|
||||
const encodedAddress = encodeURIComponent(address)
|
||||
|
||||
const providers = {
|
||||
google: {
|
||||
name: "Google Maps",
|
||||
url: `https://www.google.com/maps/search/${encodedAddress},16z`,
|
||||
icon: "🔍"
|
||||
},
|
||||
openstreetmap: {
|
||||
name: "OpenStreetMap",
|
||||
url: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lng}#map=16/${lat}/${lng}`,
|
||||
icon: "🗺️"
|
||||
},
|
||||
apple: {
|
||||
name: "Apple Plans",
|
||||
url: `https://maps.apple.com/?address=${encodedAddress}&ll=${lat},${lng}`,
|
||||
icon: "🍎"
|
||||
}
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<i data-lucide="map-pin" class="w-4 h-4 text-gray-500"></i>
|
||||
<span class="text-sm font-medium text-gray-700">Voir sur la carte :</span>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
${Object.entries(providers).map(([key, provider]) => `
|
||||
<a href="${provider.url}" target="_blank" rel="noopener"
|
||||
class="inline-flex items-center px-3 py-2 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors">
|
||||
<span class="mr-2">${provider.icon}</span>
|
||||
${provider.name}
|
||||
</a>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Clear coordinates
|
||||
clearCoordinates() {
|
||||
if (this.hasLatitudeTarget) this.latitudeTarget.value = ""
|
||||
if (this.hasLongitudeTarget) this.longitudeTarget.value = ""
|
||||
}
|
||||
|
||||
// Clear map links
|
||||
clearMapLinks() {
|
||||
if (this.hasMapLinksContainerTarget) {
|
||||
this.mapLinksContainerTarget.innerHTML = ""
|
||||
}
|
||||
}
|
||||
|
||||
// Show geocoding spinner in address input
|
||||
showGeocodingSpinner() {
|
||||
if (this.hasGeocodingSpinnerTarget) {
|
||||
this.geocodingSpinnerTarget.classList.remove('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
// Hide geocoding spinner in address input
|
||||
hideGeocodingSpinner() {
|
||||
if (this.hasGeocodingSpinnerTarget) {
|
||||
this.geocodingSpinnerTarget.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state on "Ma position" button
|
||||
showGetCurrentLocationLoading() {
|
||||
if (this.hasGetCurrentLocationBtnTarget) {
|
||||
this.getCurrentLocationBtnTarget.disabled = true
|
||||
}
|
||||
if (this.hasGetCurrentLocationIconTarget) {
|
||||
this.getCurrentLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-white border-t-transparent rounded-full animate-spin"></div>'
|
||||
}
|
||||
if (this.hasGetCurrentLocationTextTarget) {
|
||||
this.getCurrentLocationTextTarget.textContent = 'Localisation...'
|
||||
}
|
||||
}
|
||||
|
||||
// Hide loading state on "Ma position" button
|
||||
hideGetCurrentLocationLoading() {
|
||||
if (this.hasGetCurrentLocationBtnTarget) {
|
||||
this.getCurrentLocationBtnTarget.disabled = false
|
||||
}
|
||||
if (this.hasGetCurrentLocationIconTarget) {
|
||||
this.getCurrentLocationIconTarget.innerHTML = '<i data-lucide="map-pin" class="w-3 h-3 mr-1"></i>'
|
||||
// Re-initialize Lucide icons
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
if (this.hasGetCurrentLocationTextTarget) {
|
||||
this.getCurrentLocationTextTarget.textContent = 'Ma position'
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state on "Prévisualiser" button
|
||||
showPreviewLocationLoading() {
|
||||
if (this.hasPreviewLocationBtnTarget) {
|
||||
this.previewLocationBtnTarget.disabled = true
|
||||
}
|
||||
if (this.hasPreviewLocationIconTarget) {
|
||||
this.previewLocationIconTarget.innerHTML = '<div class="w-3 h-3 mr-1 border border-purple-700 border-t-transparent rounded-full animate-spin"></div>'
|
||||
}
|
||||
if (this.hasPreviewLocationTextTarget) {
|
||||
this.previewLocationTextTarget.textContent = 'Recherche...'
|
||||
}
|
||||
}
|
||||
|
||||
// Hide loading state on "Prévisualiser" button
|
||||
hidePreviewLocationLoading() {
|
||||
if (this.hasPreviewLocationBtnTarget) {
|
||||
this.previewLocationBtnTarget.disabled = false
|
||||
}
|
||||
if (this.hasPreviewLocationIconTarget) {
|
||||
this.previewLocationIconTarget.innerHTML = '<i data-lucide="map" class="w-3 h-3 mr-1"></i>'
|
||||
// Re-initialize Lucide icons
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
if (this.hasPreviewLocationTextTarget) {
|
||||
this.previewLocationTextTarget.textContent = 'Prévisualiser'
|
||||
}
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
showLocationLoading() {
|
||||
this.hideAllLocationMessages()
|
||||
this.showMessage("location-loading", "Géolocalisation en cours...", "loading")
|
||||
}
|
||||
|
||||
// Hide loading state
|
||||
hideLocationLoading() {
|
||||
this.hideMessage("location-loading")
|
||||
}
|
||||
|
||||
// Show success message
|
||||
showLocationSuccess(message) {
|
||||
this.hideAllLocationMessages()
|
||||
this.showMessage("location-success", message, "success")
|
||||
setTimeout(() => this.hideMessage("location-success"), 4000)
|
||||
}
|
||||
|
||||
// Show error message
|
||||
showLocationError(message) {
|
||||
this.hideAllLocationMessages()
|
||||
this.showMessage("location-error", message, "error")
|
||||
setTimeout(() => this.hideMessage("location-error"), 6000)
|
||||
}
|
||||
|
||||
// Show geocoding warning (less intrusive than error)
|
||||
showGeocodingWarning(address) {
|
||||
this.hideMessage("geocoding-warning")
|
||||
const message = "Les coordonnées n'ont pas pu être déterminées automatiquement. L'événement utilisera une localisation approximative."
|
||||
this.showMessage("geocoding-warning", message, "warning")
|
||||
setTimeout(() => this.hideMessage("geocoding-warning"), 8000)
|
||||
}
|
||||
|
||||
// Show info about approximate location
|
||||
showApproximateLocationInfo(foundLocation) {
|
||||
this.hideMessage("approximate-location-info")
|
||||
const message = `Localisation approximative trouvée: ${foundLocation}`
|
||||
this.showMessage("approximate-location-info", message, "info")
|
||||
setTimeout(() => this.hideMessage("approximate-location-info"), 6000)
|
||||
}
|
||||
|
||||
// Show geocoding success with location details
|
||||
showGeocodingSuccess(title, location) {
|
||||
this.hideMessage("geocoding-success")
|
||||
const message = `${title}<br><small class="opacity-75">${location}</small>`
|
||||
this.showMessage("geocoding-success", message, "success")
|
||||
setTimeout(() => this.hideMessage("geocoding-success"), 5000)
|
||||
}
|
||||
|
||||
// Show geocoding progress with strategy info
|
||||
showGeocodingProgress(strategy, attempt) {
|
||||
this.hideMessage("geocoding-progress")
|
||||
const message = `Recherche en cours... (${attempt}/${strategy})`
|
||||
this.showMessage("geocoding-progress", message, "loading")
|
||||
}
|
||||
|
||||
// Message template configurations
|
||||
getMessageTemplate(type) {
|
||||
const templates = {
|
||||
info: {
|
||||
bgColor: "bg-blue-50",
|
||||
borderColor: "border-blue-200",
|
||||
textColor: "text-blue-800",
|
||||
icon: "info",
|
||||
iconColor: "text-blue-500"
|
||||
},
|
||||
success: {
|
||||
bgColor: "bg-green-50",
|
||||
borderColor: "border-green-200",
|
||||
textColor: "text-green-800",
|
||||
icon: "check-circle",
|
||||
iconColor: "text-green-500"
|
||||
},
|
||||
error: {
|
||||
bgColor: "bg-red-50",
|
||||
borderColor: "border-red-200",
|
||||
textColor: "text-red-800",
|
||||
icon: "alert-circle",
|
||||
iconColor: "text-red-500"
|
||||
},
|
||||
warning: {
|
||||
bgColor: "bg-yellow-50",
|
||||
borderColor: "border-yellow-200",
|
||||
textColor: "text-yellow-800",
|
||||
icon: "alert-triangle",
|
||||
iconColor: "text-yellow-500"
|
||||
},
|
||||
loading: {
|
||||
bgColor: "bg-purple-50",
|
||||
borderColor: "border-purple-200",
|
||||
textColor: "text-purple-800",
|
||||
icon: "loader-2",
|
||||
iconColor: "text-purple-500",
|
||||
animated: true
|
||||
}
|
||||
}
|
||||
return templates[type] || templates.info
|
||||
}
|
||||
|
||||
// Create dynamic message HTML using template
|
||||
createMessageHTML(id, message, type) {
|
||||
const template = this.getMessageTemplate(type)
|
||||
const animationClass = template.animated ? 'animate-spin' : ''
|
||||
|
||||
return `
|
||||
<div id="${id}" class="flex items-start space-x-3 p-4 ${template.bgColor} ${template.borderColor} border rounded-lg shadow-sm transition-all duration-200 ease-in-out">
|
||||
<div class="flex-shrink-0">
|
||||
<i data-lucide="${template.icon}" class="w-5 h-5 ${template.iconColor} ${animationClass}"></i>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium ${template.textColor} leading-relaxed">${message}</p>
|
||||
</div>
|
||||
<button type="button" onclick="this.parentElement.remove()" class="flex-shrink-0 ${template.textColor} hover:opacity-70 transition-opacity">
|
||||
<i data-lucide="x" class="w-4 h-4"></i>
|
||||
</button>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
// Show a message with given type using template system
|
||||
showMessage(id, message, type) {
|
||||
// Remove existing message with same ID first
|
||||
this.hideMessage(id)
|
||||
|
||||
const messageHtml = this.createMessageHTML(id, message, type)
|
||||
|
||||
// Insert into the dedicated messages container in the venue section
|
||||
if (this.hasMessagesContainerTarget) {
|
||||
this.messagesContainerTarget.insertAdjacentHTML('beforeend', messageHtml)
|
||||
// Re-initialize Lucide icons for the new elements
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
} else {
|
||||
// Fallback: insert before the address input if messages container not found
|
||||
const addressInput = this.hasAddressTarget ? this.addressTarget.parentElement : null
|
||||
if (addressInput) {
|
||||
addressInput.insertAdjacentHTML('beforebegin', messageHtml)
|
||||
if (window.lucide) {
|
||||
window.lucide.createIcons()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide a specific message
|
||||
hideMessage(id) {
|
||||
const element = document.getElementById(id)
|
||||
if (element) {
|
||||
element.remove()
|
||||
}
|
||||
}
|
||||
|
||||
// Hide all location messages
|
||||
hideAllLocationMessages() {
|
||||
this.hideMessage("location-loading")
|
||||
this.hideMessage("location-success")
|
||||
this.hideMessage("location-error")
|
||||
this.hideMessage("geocoding-warning")
|
||||
this.hideMessage("approximate-location-info")
|
||||
this.hideMessage("geocoding-success")
|
||||
this.hideMessage("geocoding-progress")
|
||||
}
|
||||
}
|
||||
46
app/javascript/controllers/flash_message_controller.js
Executable file
46
app/javascript/controllers/flash_message_controller.js
Executable file
@@ -0,0 +1,46 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Controller for handling flash messages
|
||||
// Automatically dismisses messages after a timeout and handles manual closing
|
||||
export default class extends Controller {
|
||||
// Define targets for the controller
|
||||
static targets = ["message"];
|
||||
|
||||
// Initialize the controller when it connects to the DOM
|
||||
connect() {
|
||||
// console.log("FlashMessageController mounted", this.element);
|
||||
console.log("FlashMessageController mounted");
|
||||
|
||||
// Initialize Lucide icons for this element if available
|
||||
if (typeof lucide !== "undefined") {
|
||||
lucide.createIcons({ within: this.element });
|
||||
}
|
||||
|
||||
// Auto-dismiss after 2 seconds
|
||||
this.timeout = setTimeout(() => {
|
||||
this.close();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
// Clean up the timeout when the controller disconnects
|
||||
disconnect() {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
}
|
||||
}
|
||||
|
||||
// Close the flash message with a fade-out animation
|
||||
close() {
|
||||
// Add opacity transition classes
|
||||
this.element.classList.add(
|
||||
"opacity-0",
|
||||
"transition-opacity",
|
||||
"duration-300",
|
||||
);
|
||||
|
||||
// Remove element after transition completes
|
||||
setTimeout(() => {
|
||||
this.element.remove();
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
73
app/javascript/controllers/header_controller.js
Normal file
73
app/javascript/controllers/header_controller.js
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
// Controller for handling the header navigation
|
||||
// Manages mobile menu toggle and user dropdown menu
|
||||
export default class extends Controller {
|
||||
static targets = ["mobileMenu", "mobileMenuButton", "userMenu", "userMenuButton"]
|
||||
|
||||
connect() {
|
||||
// Initialize menu states
|
||||
this.mobileMenuOpen = false
|
||||
this.userMenuOpen = false
|
||||
|
||||
// Add click outside listener for user menu
|
||||
this.clickOutsideHandler = this.handleClickOutside.bind(this)
|
||||
document.addEventListener("click", this.clickOutsideHandler)
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Clean up event listener
|
||||
document.removeEventListener("click", this.clickOutsideHandler)
|
||||
}
|
||||
|
||||
// Toggle mobile menu visibility
|
||||
toggleMobileMenu() {
|
||||
this.mobileMenuOpen = !this.mobileMenuOpen
|
||||
this.mobileMenuTarget.classList.toggle("hidden", !this.mobileMenuOpen)
|
||||
|
||||
// Update button icon based on state
|
||||
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
|
||||
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
|
||||
|
||||
if (iconOpen && iconClose) {
|
||||
iconOpen.classList.toggle("hidden", this.mobileMenuOpen)
|
||||
iconClose.classList.toggle("hidden", !this.mobileMenuOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle user dropdown menu visibility
|
||||
toggleUserMenu() {
|
||||
this.userMenuOpen = !this.userMenuOpen
|
||||
if (this.hasUserMenuTarget) {
|
||||
this.userMenuTarget.classList.toggle("hidden", !this.userMenuOpen)
|
||||
}
|
||||
}
|
||||
|
||||
// Close menus when clicking outside
|
||||
handleClickOutside(event) {
|
||||
// Close user menu if clicked outside
|
||||
if (this.userMenuOpen && this.hasUserMenuTarget &&
|
||||
!this.userMenuTarget.contains(event.target) &&
|
||||
!this.userMenuButtonTarget.contains(event.target)) {
|
||||
this.userMenuOpen = false
|
||||
this.userMenuTarget.classList.add("hidden")
|
||||
}
|
||||
|
||||
// Close mobile menu if clicked outside
|
||||
if (this.mobileMenuOpen &&
|
||||
!this.mobileMenuTarget.contains(event.target) &&
|
||||
!this.mobileMenuButtonTarget.contains(event.target)) {
|
||||
this.mobileMenuOpen = false
|
||||
this.mobileMenuTarget.classList.add("hidden")
|
||||
|
||||
// Update button icon
|
||||
const iconOpen = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="open"]')
|
||||
const iconClose = this.mobileMenuButtonTarget.querySelector('[data-menu-icon="close"]')
|
||||
|
||||
if (iconOpen && iconClose) {
|
||||
iconOpen.classList.remove("hidden")
|
||||
iconClose.classList.add("hidden")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.element.textContent = "Hello World!"
|
||||
}
|
||||
}
|
||||
30
app/javascript/controllers/index.js
Normal file → Executable file
30
app/javascript/controllers/index.js
Normal file → Executable file
@@ -2,7 +2,31 @@
|
||||
// Run that command whenever you add a new controller or create them with
|
||||
// ./bin/rails generate stimulus controllerName
|
||||
|
||||
import { application } from "./application"
|
||||
import { application } from "./application";
|
||||
|
||||
import HelloController from "./hello_controller"
|
||||
application.register("hello", HelloController)
|
||||
import LogoutController from "./logout_controller";
|
||||
application.register("logout", LogoutController);
|
||||
|
||||
import CounterController from "./counter_controller";
|
||||
application.register("counter", CounterController);
|
||||
|
||||
import FlashMessageController from "./flash_message_controller";
|
||||
application.register("flash-message", FlashMessageController);
|
||||
|
||||
import TicketSelectionController from "./ticket_selection_controller";
|
||||
application.register("ticket-selection", TicketSelectionController);
|
||||
|
||||
import HeaderController from "./header_controller";
|
||||
application.register("header", HeaderController);
|
||||
|
||||
import QrCodeController from "./qr_code_controller";
|
||||
application.register("qr-code", QrCodeController);
|
||||
|
||||
import EventFormController from "./event_form_controller";
|
||||
application.register("event-form", EventFormController);
|
||||
|
||||
import CountdownController from "./countdown_controller";
|
||||
application.register("countdown", CountdownController);
|
||||
|
||||
import EventDuplicationController from "./event_duplication_controller";
|
||||
application.register("event-duplication", EventDuplicationController);
|
||||
|
||||
31
app/javascript/controllers/logout_controller.js
Normal file → Executable file
31
app/javascript/controllers/logout_controller.js
Normal file → Executable file
@@ -1,29 +1,36 @@
|
||||
// app/javascript/controllers/logout_controller.js
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Controller for handling user logout functionality
|
||||
// Sends a DELETE request to the server to sign out the user
|
||||
export default class extends Controller {
|
||||
// Define controller values
|
||||
static values = {
|
||||
url: String,
|
||||
url: String, // Optional URL for logout endpoint
|
||||
};
|
||||
|
||||
// Log when the controller is mounted
|
||||
connect() {
|
||||
// Optional: Add confirmation message
|
||||
//console.log("Hello LogoutController, Stimulus!", this.element);
|
||||
// this.element.dataset.confirm = "Êtes-vous sûr de vouloir vous déconnecter ?";
|
||||
// Display a message when the controller is mounted
|
||||
// console.log("LogoutController mounted", this.element);
|
||||
}
|
||||
|
||||
// Handle the sign out action
|
||||
signOut(event) {
|
||||
event.preventDefault();
|
||||
console.log("LogoutController#signOut mounted");
|
||||
console.log("User clicked on logout button.");
|
||||
|
||||
// Ensure user wants to disconnect with a confirmation request
|
||||
// if (this.hasUrlValue && !confirm(this.element.dataset.confirm)) { return; }
|
||||
|
||||
// Retrieve the csrf token from header
|
||||
// Retrieve the csrf token from header for security
|
||||
const csrfToken = document.querySelector("[name='csrf-token']").content;
|
||||
|
||||
// Define url to redirect user when action is valid
|
||||
const url = this.hasUrlValue ? this.urlValue : this.element.href;
|
||||
let url = this.hasUrlValue ? this.urlValue : this.element.href;
|
||||
// Ensure the URL is using the correct path prefix
|
||||
if (url && !url.includes('/auth/sign_out')) {
|
||||
url = url.replace('/users/sign_out', '/auth/sign_out');
|
||||
}
|
||||
|
||||
// Use fetch to send logout request
|
||||
fetch(url, {
|
||||
@@ -36,14 +43,14 @@ export default class extends Controller {
|
||||
credentials: "same-origin",
|
||||
})
|
||||
.then((response) => {
|
||||
// console.log(this.element.dataset.loginUrlValue); // By default, we does not return anything.
|
||||
// console.log(this.element.dataset.redirectUrlValue); // By default, we does not return anything.
|
||||
|
||||
// By default the response does not include any url.
|
||||
// Redirect to default login page (loginUrlValue)
|
||||
// Redirect to default login page (redirectUrlValue)
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
} else if (this.element.dataset.loginUrlValue) {
|
||||
window.location.href = this.element.dataset.loginUrlValue;
|
||||
} else if (this.element.dataset.redirectUrlValue) {
|
||||
window.location.href = this.element.dataset.redirectUrlValue;
|
||||
return;
|
||||
}
|
||||
window.location.href = "/";
|
||||
|
||||
56
app/javascript/controllers/qr_code_controller.js
Normal file
56
app/javascript/controllers/qr_code_controller.js
Normal file
@@ -0,0 +1,56 @@
|
||||
// QR Code generator controller using qrcode npm package
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
import QRCode from "qrcode"
|
||||
|
||||
export default class extends Controller {
|
||||
static values = { data: String }
|
||||
static targets = ["container", "loading"]
|
||||
|
||||
connect() {
|
||||
this.generateQRCode()
|
||||
}
|
||||
|
||||
async generateQRCode() {
|
||||
try {
|
||||
// Hide loading indicator
|
||||
if (this.hasLoadingTarget) {
|
||||
this.loadingTarget.style.display = 'none'
|
||||
}
|
||||
|
||||
// Create canvas element
|
||||
const canvas = document.createElement('canvas')
|
||||
|
||||
// Generate QR code using qrcode library
|
||||
await QRCode.toCanvas(canvas, this.dataValue, {
|
||||
width: 128,
|
||||
height: 128,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#FFFFFF'
|
||||
}
|
||||
})
|
||||
|
||||
// Clear container and add QR code
|
||||
this.containerTarget.innerHTML = ''
|
||||
this.containerTarget.appendChild(canvas)
|
||||
|
||||
console.log('QR code generated successfully')
|
||||
} catch (error) {
|
||||
console.error('Error generating QR code:', error)
|
||||
this.showFallback()
|
||||
}
|
||||
}
|
||||
|
||||
showFallback() {
|
||||
this.containerTarget.innerHTML = `
|
||||
<div class="w-32 h-32 bg-gray-100 rounded flex items-center justify-center text-gray-500 text-xs border-2 border-dashed border-gray-300">
|
||||
<div class="text-center">
|
||||
<div class="text-lg mb-1">📱</div>
|
||||
<div>QR Code</div>
|
||||
<div class="font-mono text-xs mt-1 break-all px-2">${this.dataValue}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
169
app/javascript/controllers/ticket_selection_controller.js
Normal file
169
app/javascript/controllers/ticket_selection_controller.js
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Controller for handling ticket selection on the event show page
|
||||
// Manages quantity inputs, calculates totals, and enables/disables the checkout button
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
"quantityInput",
|
||||
"totalQuantity",
|
||||
"totalAmount",
|
||||
"checkoutButton",
|
||||
"form",
|
||||
];
|
||||
static values = { eventSlug: String, eventId: String, orderNewUrl: String, storeCartUrl: String };
|
||||
|
||||
// Initialize the controller and update the cart summary
|
||||
connect() {
|
||||
this.updateCartSummary();
|
||||
this.bindFormSubmission();
|
||||
}
|
||||
|
||||
// Bind form submission to handle cart storage
|
||||
bindFormSubmission() {
|
||||
if (this.hasFormTarget) {
|
||||
this.formTarget.addEventListener("submit", this.submitCart.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
// Increment the quantity for a specific ticket type
|
||||
increment(event) {
|
||||
const ticketTypeId = event.currentTarget.dataset.target;
|
||||
const input = this.quantityInputTargets.find(
|
||||
(input) => input.dataset.target === ticketTypeId,
|
||||
);
|
||||
const value = parseInt(input.value) || 0;
|
||||
const max = parseInt(input.max) || 0;
|
||||
|
||||
if (value < max) {
|
||||
input.value = value + 1;
|
||||
this.updateCartSummary();
|
||||
}
|
||||
}
|
||||
|
||||
// Decrement the quantity for a specific ticket type
|
||||
decrement(event) {
|
||||
const ticketTypeId = event.currentTarget.dataset.target;
|
||||
const input = this.quantityInputTargets.find(
|
||||
(input) => input.dataset.target === ticketTypeId,
|
||||
);
|
||||
const value = parseInt(input.value) || 0;
|
||||
|
||||
if (value > 0) {
|
||||
input.value = value - 1;
|
||||
this.updateCartSummary();
|
||||
}
|
||||
}
|
||||
|
||||
// Update quantity when directly edited in the input field
|
||||
updateQuantity(event) {
|
||||
const input = event.currentTarget;
|
||||
let value = parseInt(input.value) || 0;
|
||||
const max = parseInt(input.max) || 0;
|
||||
|
||||
// Ensure value is within valid range (0 to max available)
|
||||
if (value < 0) value = 0;
|
||||
if (value > max) value = max;
|
||||
|
||||
input.value = value;
|
||||
this.updateCartSummary();
|
||||
}
|
||||
|
||||
// Calculate and update the cart summary (total quantity and amount)
|
||||
updateCartSummary() {
|
||||
let totalQuantity = 0;
|
||||
let totalAmount = 0;
|
||||
|
||||
// Sum up quantities and calculate total amount
|
||||
this.quantityInputTargets.forEach((input) => {
|
||||
const quantity = parseInt(input.value) || 0;
|
||||
const price = parseInt(input.dataset.price) || 0;
|
||||
|
||||
totalQuantity += quantity;
|
||||
totalAmount += quantity * price;
|
||||
});
|
||||
|
||||
// Update the displayed total quantity and amount
|
||||
this.totalQuantityTarget.textContent = totalQuantity;
|
||||
this.totalAmountTarget.textContent = `€${(totalAmount / 100).toFixed(2)}`;
|
||||
|
||||
// Enable/disable checkout button based on whether any tickets are selected
|
||||
if (totalQuantity > 0) {
|
||||
this.checkoutButtonTarget.classList.remove(
|
||||
"opacity-50",
|
||||
"cursor-not-allowed",
|
||||
);
|
||||
this.checkoutButtonTarget.disabled = false;
|
||||
} else {
|
||||
this.checkoutButtonTarget.classList.add(
|
||||
"opacity-50",
|
||||
"cursor-not-allowed",
|
||||
);
|
||||
this.checkoutButtonTarget.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle form submission - store cart in session before proceeding
|
||||
async submitCart(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const cartData = this.buildCartData();
|
||||
|
||||
if (Object.keys(cartData).length === 0) {
|
||||
alert("Veuillez sélectionner au moins un billet");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Store cart data in session
|
||||
await this.storeCartInSession(cartData);
|
||||
|
||||
// Redirect to event-scoped orders/new page
|
||||
const orderNewUrl = this.orderNewUrlValue;
|
||||
window.location.href = orderNewUrl;
|
||||
} catch (error) {
|
||||
console.error("Error storing cart:", error);
|
||||
alert("Une erreur est survenue. Veuillez réessayer.");
|
||||
}
|
||||
}
|
||||
|
||||
// Build cart data from current form state
|
||||
buildCartData() {
|
||||
const cartData = {};
|
||||
|
||||
this.quantityInputTargets.forEach((input) => {
|
||||
const quantity = parseInt(input.value) || 0;
|
||||
if (quantity > 0) {
|
||||
const ticketTypeId = input.dataset.target;
|
||||
cartData[ticketTypeId] = {
|
||||
quantity: quantity,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return cartData;
|
||||
}
|
||||
|
||||
// Store cart data in session via AJAX
|
||||
async storeCartInSession(cartData) {
|
||||
const storeCartUrl = this.storeCartUrlValue;
|
||||
|
||||
const response = await fetch(storeCartUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
.getAttribute("content"),
|
||||
},
|
||||
body: JSON.stringify({ cart: cartData, event_id: this.eventIdValue }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to store cart data: ${response.status} ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
0
app/jobs/application_job.rb
Normal file → Executable file
0
app/jobs/application_job.rb
Normal file → Executable file
33
app/jobs/cleanup_expired_drafts_job.rb
Normal file
33
app/jobs/cleanup_expired_drafts_job.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
# Background job to clean up expired draft tickets
|
||||
#
|
||||
# This job runs periodically to find and expire draft tickets that have
|
||||
# passed their expiry time (typically 30 minutes after creation).
|
||||
# Should be scheduled via cron or similar scheduling system.
|
||||
class CleanupExpiredDraftsJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# Find and expire all draft tickets that have passed their expiry time
|
||||
#
|
||||
# Uses find_each to process tickets in batches to avoid memory issues
|
||||
# with large datasets. Continues processing even if individual tickets fail.
|
||||
def perform
|
||||
expired_count = 0
|
||||
|
||||
# Process expired draft tickets in batches
|
||||
Ticket.expired_drafts.find_each do |ticket|
|
||||
begin
|
||||
Rails.logger.info "Expiring draft ticket #{ticket.id} for user #{ticket.user.id}"
|
||||
ticket.expire_if_overdue!
|
||||
expired_count += 1
|
||||
rescue => e
|
||||
# Log error but continue processing other tickets
|
||||
Rails.logger.error "Failed to expire ticket #{ticket.id}: #{e.message}"
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
# Log summary if any tickets were processed
|
||||
Rails.logger.info "Expired #{expired_count} draft tickets" if expired_count > 0
|
||||
Rails.logger.info "No expired draft tickets found" if expired_count == 0
|
||||
end
|
||||
end
|
||||
19
app/jobs/event_reminder_job.rb
Normal file
19
app/jobs/event_reminder_job.rb
Normal file
@@ -0,0 +1,19 @@
|
||||
class EventReminderJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform(event_id, days_before)
|
||||
event = Event.find(event_id)
|
||||
|
||||
# Find all users with active tickets for this event
|
||||
users_with_tickets = User.joins(orders: { tickets: :ticket_type })
|
||||
.where(ticket_types: { event: event })
|
||||
.where(tickets: { status: "active" })
|
||||
.distinct
|
||||
|
||||
users_with_tickets.find_each do |user|
|
||||
TicketMailer.event_reminder(user, event, days_before).deliver_now
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to send event reminder to user #{user.id} for event #{event.id}: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
44
app/jobs/event_reminder_scheduler_job.rb
Normal file
44
app/jobs/event_reminder_scheduler_job.rb
Normal file
@@ -0,0 +1,44 @@
|
||||
class EventReminderSchedulerJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
schedule_weekly_reminders
|
||||
schedule_daily_reminders
|
||||
schedule_day_of_reminders
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def schedule_weekly_reminders
|
||||
# Find events starting in exactly 7 days
|
||||
target_date = 7.days.from_now.beginning_of_day
|
||||
events = Event.published
|
||||
.where(start_time: target_date..(target_date + 1.day))
|
||||
|
||||
events.find_each do |event|
|
||||
EventReminderJob.perform_later(event.id, 7)
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_daily_reminders
|
||||
# Find events starting in exactly 1 day (tomorrow)
|
||||
target_date = 1.day.from_now.beginning_of_day
|
||||
events = Event.published
|
||||
.where(start_time: target_date..(target_date + 1.day))
|
||||
|
||||
events.find_each do |event|
|
||||
EventReminderJob.perform_later(event.id, 1)
|
||||
end
|
||||
end
|
||||
|
||||
def schedule_day_of_reminders
|
||||
# Find events starting today
|
||||
target_date = Time.current.beginning_of_day
|
||||
events = Event.published
|
||||
.where(start_time: target_date..(target_date + 1.day))
|
||||
|
||||
events.find_each do |event|
|
||||
EventReminderJob.perform_later(event.id, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
23
app/jobs/expired_orders_cleanup_job.rb
Normal file
23
app/jobs/expired_orders_cleanup_job.rb
Normal file
@@ -0,0 +1,23 @@
|
||||
class ExpiredOrdersCleanupJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
def perform
|
||||
# Find and expire all draft orders that have passed their expiry time
|
||||
expired_orders = Order.expired_drafts
|
||||
|
||||
Rails.logger.info "Found #{expired_orders.count} expired orders to process"
|
||||
|
||||
expired_orders.find_each do |order|
|
||||
begin
|
||||
order.expire_if_overdue!
|
||||
Rails.logger.info "Expired order ##{order.id} for user ##{order.user_id}"
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to expire order ##{order.id}: #{e.message}"
|
||||
# Continue processing other orders even if one fails
|
||||
next
|
||||
end
|
||||
end
|
||||
|
||||
Rails.logger.info "Completed expired orders cleanup job"
|
||||
end
|
||||
end
|
||||
49
app/jobs/stripe_invoice_generation_job.rb
Normal file
49
app/jobs/stripe_invoice_generation_job.rb
Normal file
@@ -0,0 +1,49 @@
|
||||
# Background job to create Stripe invoices for accounting records
|
||||
#
|
||||
# This job is responsible for creating post-payment invoices in Stripe
|
||||
# for accounting purposes after a successful payment
|
||||
class StripeInvoiceGenerationJob < ApplicationJob
|
||||
queue_as :default
|
||||
|
||||
# Retry up to 3 times with exponential backoff
|
||||
retry_on StandardError, wait: :exponentially_longer, attempts: 3
|
||||
|
||||
# Don't retry on Stripe authentication errors
|
||||
discard_on Stripe::AuthenticationError
|
||||
|
||||
def perform(order_id)
|
||||
order = Order.find(order_id)
|
||||
|
||||
unless order.status == "paid"
|
||||
Rails.logger.warn "Attempted to create invoice for unpaid order #{order_id}"
|
||||
return
|
||||
end
|
||||
|
||||
# Create the Stripe invoice
|
||||
service = StripeInvoiceService.new(order)
|
||||
stripe_invoice = service.create_post_payment_invoice
|
||||
|
||||
if stripe_invoice
|
||||
# Store the invoice ID (you might want to persist this in the database)
|
||||
order.instance_variable_set(:@stripe_invoice_id, stripe_invoice.id)
|
||||
|
||||
Rails.logger.info "Successfully created Stripe invoice #{stripe_invoice.id} for order #{order.id} via background job"
|
||||
|
||||
# Optionally send notification email about invoice availability
|
||||
# InvoiceMailer.invoice_ready(order, stripe_invoice.id).deliver_now
|
||||
else
|
||||
error_msg = service.errors.join(", ")
|
||||
Rails.logger.error "Failed to create Stripe invoice for order #{order.id}: #{error_msg}"
|
||||
raise StandardError, "Invoice generation failed: #{error_msg}"
|
||||
end
|
||||
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
Rails.logger.error "Order #{order_id} not found for invoice generation"
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error "Stripe error creating invoice for order #{order_id}: #{e.message}"
|
||||
raise e # Re-raise to trigger retry logic
|
||||
rescue => e
|
||||
Rails.logger.error "Unexpected error creating invoice for order #{order_id}: #{e.message}"
|
||||
raise e # Re-raise to trigger retry logic
|
||||
end
|
||||
end
|
||||
2
app/mailers/application_mailer.rb
Normal file → Executable file
2
app/mailers/application_mailer.rb
Normal file → Executable file
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
default from: ENV.fetch("MAILER_FROM_EMAIL", "no-reply@aperonight.fr")
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
80
app/mailers/ticket_mailer.rb
Executable file
80
app/mailers/ticket_mailer.rb
Executable file
@@ -0,0 +1,80 @@
|
||||
class TicketMailer < ApplicationMailer
|
||||
def purchase_confirmation_order(order)
|
||||
@order = order
|
||||
@user = order.user
|
||||
@event = order.event
|
||||
@tickets = order.tickets
|
||||
|
||||
# Generate PDF attachments for all tickets
|
||||
@tickets.each do |ticket|
|
||||
begin
|
||||
pdf = ticket.to_pdf
|
||||
attachments["ticket-#{@event.name.parameterize}-#{ticket.qr_code[0..7]}.pdf"] = {
|
||||
mime_type: "application/pdf",
|
||||
content: pdf
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to generate PDF for ticket #{ticket.id}: #{e.message}"
|
||||
# Continue without PDF attachment rather than failing the entire email
|
||||
end
|
||||
end
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "Confirmation d'achat - #{@event.name}",
|
||||
template_name: "purchase_confirmation"
|
||||
)
|
||||
end
|
||||
|
||||
def purchase_confirmation(ticket)
|
||||
@ticket = ticket
|
||||
@user = ticket.user
|
||||
@event = ticket.event
|
||||
|
||||
# Generate PDF attachment
|
||||
begin
|
||||
pdf = @ticket.to_pdf
|
||||
attachments["ticket-#{@event.name.parameterize}-#{@ticket.qr_code[0..7]}.pdf"] = {
|
||||
mime_type: "application/pdf",
|
||||
content: pdf
|
||||
}
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to generate PDF for ticket #{@ticket.id}: #{e.message}"
|
||||
# Continue without PDF attachment rather than failing the entire email
|
||||
end
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: "Confirmation d'achat - #{@event.name}"
|
||||
)
|
||||
end
|
||||
|
||||
def event_reminder(user, event, days_before)
|
||||
@user = user
|
||||
@event = event
|
||||
@days_before = days_before
|
||||
|
||||
# Get user's tickets for this event
|
||||
@tickets = Ticket.joins(:order, :ticket_type)
|
||||
.where(orders: { user: @user }, ticket_types: { event: @event }, status: "active")
|
||||
|
||||
return if @tickets.empty?
|
||||
|
||||
subject = case days_before
|
||||
when 7
|
||||
"Rappel : #{@event.name} dans une semaine"
|
||||
when 1
|
||||
"Rappel : #{@event.name} demain"
|
||||
when 0
|
||||
"C'est aujourd'hui : #{@event.name}"
|
||||
else
|
||||
"Rappel : #{@event.name} dans #{days_before} jours"
|
||||
end
|
||||
|
||||
mail(
|
||||
to: @user.email,
|
||||
subject: subject,
|
||||
template_name: "event_reminder"
|
||||
)
|
||||
end
|
||||
end
|
||||
3
app/models/application_record.rb
Normal file → Executable file
3
app/models/application_record.rb
Normal file → Executable file
@@ -1,3 +1,6 @@
|
||||
# Base class for all ActiveRecord models in the application
|
||||
# Provides common functionality and configuration for all models
|
||||
class ApplicationRecord < ActiveRecord::Base
|
||||
# Mark this as the primary abstract class to establish inheritance hierarchy
|
||||
primary_abstract_class
|
||||
end
|
||||
|
||||
0
app/models/concerns/.keep
Normal file → Executable file
0
app/models/concerns/.keep
Normal file → Executable file
272
app/models/event.rb
Executable file
272
app/models/event.rb
Executable file
@@ -0,0 +1,272 @@
|
||||
# Event model representing nightlife events and events
|
||||
# Manages event details, location data, and publication state
|
||||
require "net/http"
|
||||
require "json"
|
||||
|
||||
class Event < ApplicationRecord
|
||||
# Define states for Event lifecycle management
|
||||
# draft: Initial state when Event is being created
|
||||
# published: Event is visible to public and can be discovered
|
||||
# canceled: Event has been canceled by organizer
|
||||
# sold_out: Event has reached capacity and tickets are no longer available
|
||||
enum :state, {
|
||||
draft: 0,
|
||||
published: 1,
|
||||
canceled: 2,
|
||||
sold_out: 3
|
||||
}, default: :draft
|
||||
|
||||
# === Relations ===
|
||||
belongs_to :user
|
||||
has_many :ticket_types
|
||||
has_many :tickets, through: :ticket_types
|
||||
has_many :orders
|
||||
|
||||
# === Callbacks ===
|
||||
before_validation :geocode_address, if: :should_geocode_address?
|
||||
|
||||
# Validations for Event attributes
|
||||
# Basic information
|
||||
validates :name, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
validates :slug, presence: true, length: { minimum: 3, maximum: 100 }
|
||||
validates :description, presence: true, length: { minimum: 10, maximum: 2000 }
|
||||
validates :state, presence: true, inclusion: { in: states.keys }
|
||||
validates :image, length: { maximum: 500 } # URL or path to image
|
||||
|
||||
# Venue information
|
||||
validates :venue_name, presence: true, length: { maximum: 100 }
|
||||
validates :venue_address, presence: true, length: { maximum: 200 }
|
||||
|
||||
# Geographic coordinates for map display
|
||||
validates :latitude, presence: true, numericality: {
|
||||
greater_than_or_equal_to: -90,
|
||||
less_than_or_equal_to: 90
|
||||
}
|
||||
validates :longitude, presence: true, numericality: {
|
||||
greater_than_or_equal_to: -180,
|
||||
less_than_or_equal_to: 180
|
||||
}
|
||||
|
||||
# Scopes for querying events with common filters
|
||||
scope :featured, -> { where(featured: true) } # Get featured events for homepage
|
||||
scope :published, -> { where(state: :published) } # Get publicly visible events
|
||||
scope :search_by_name, ->(query) { where("name ILIKE ?", "%#{query}%") } # Search by name (case-insensitive)
|
||||
|
||||
# Scope for published events ordered by start time
|
||||
scope :upcoming, -> { published.where("start_time >= ?", Time.current).order(start_time: :asc) }
|
||||
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Check if coordinates were successfully geocoded or are fallback coordinates
|
||||
def geocoding_successful?
|
||||
coordinates_look_valid?
|
||||
end
|
||||
|
||||
# Get a user-friendly status message about geocoding
|
||||
def geocoding_status_message
|
||||
return nil if geocoding_successful?
|
||||
|
||||
"Les coordonnées exactes n'ont pas pu être déterminées automatiquement. Une localisation approximative a été utilisée."
|
||||
end
|
||||
|
||||
# Check if ticket booking is currently allowed for this event
|
||||
def booking_allowed?
|
||||
return false unless published?
|
||||
return false if sold_out?
|
||||
return false if canceled?
|
||||
|
||||
# Check if event has started and if booking during event is disabled
|
||||
if event_started? && !allow_booking_during_event?
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
# Check if the event has already started
|
||||
def event_started?
|
||||
return false if start_time.blank?
|
||||
Time.current >= start_time
|
||||
end
|
||||
|
||||
# Check if the event has ended
|
||||
def event_ended?
|
||||
return false if end_time.blank?
|
||||
Time.current >= end_time
|
||||
end
|
||||
|
||||
# Check if booking is allowed during the event
|
||||
# This is a simple attribute reader that defaults to false if nil
|
||||
def allow_booking_during_event?
|
||||
!!allow_booking_during_event
|
||||
end
|
||||
|
||||
# Duplicate an event with all its ticket types
|
||||
def duplicate(clone_ticket_types: true)
|
||||
# Duplicate the event
|
||||
new_event = self.dup
|
||||
new_event.name = "Copie de #{name}"
|
||||
new_event.slug = "#{slug}-copy-#{Time.current.to_i}"
|
||||
new_event.state = :draft
|
||||
new_event.created_at = Time.current
|
||||
new_event.updated_at = Time.current
|
||||
|
||||
Event.transaction do
|
||||
if new_event.save
|
||||
# Duplicate all ticket types if requested
|
||||
if clone_ticket_types
|
||||
ticket_types.each do |ticket_type|
|
||||
new_ticket_type = ticket_type.dup
|
||||
new_ticket_type.event = new_event
|
||||
new_ticket_type.save!
|
||||
end
|
||||
end
|
||||
new_event
|
||||
else
|
||||
nil
|
||||
end
|
||||
end
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Determine if we should perform server-side geocoding
|
||||
def should_geocode_address?
|
||||
# Don't geocode if address is blank
|
||||
return false if venue_address.blank?
|
||||
|
||||
# Don't geocode if we already have valid coordinates (likely from frontend)
|
||||
return false if coordinates_look_valid?
|
||||
|
||||
# Only geocode if address changed and we don't have coordinates
|
||||
venue_address_changed?
|
||||
end
|
||||
|
||||
# Check if the current coordinates look like they were set by frontend geocoding
|
||||
def coordinates_look_valid?
|
||||
return false if latitude.blank? || longitude.blank?
|
||||
|
||||
lat_f = latitude.to_f
|
||||
lng_f = longitude.to_f
|
||||
|
||||
# Basic sanity checks for coordinate ranges
|
||||
return false if lat_f < -90 || lat_f > 90
|
||||
return false if lng_f < -180 || lng_f > 180
|
||||
|
||||
# Check if coordinates are not the default fallback coordinates
|
||||
fallback_lat = 46.603354
|
||||
fallback_lng = 1.888334
|
||||
|
||||
# Check if coordinates are not exactly 0,0 (common invalid default)
|
||||
return false if lat_f == 0.0 && lng_f == 0.0
|
||||
|
||||
# Coordinates are valid if they're not exactly the fallback coordinates
|
||||
!(lat_f == fallback_lat && lng_f == fallback_lng)
|
||||
end
|
||||
|
||||
# Automatically geocode address to get latitude and longitude
|
||||
# This only runs when no valid coordinates are provided (fallback for non-JS users)
|
||||
def geocode_address
|
||||
Rails.logger.info "Running server-side geocoding for '#{venue_address}' (no frontend coordinates provided)"
|
||||
|
||||
# Store original coordinates in case we need to fall back
|
||||
original_lat = latitude
|
||||
original_lng = longitude
|
||||
|
||||
begin
|
||||
# Use OpenStreetMap Nominatim API for geocoding
|
||||
encoded_address = URI.encode_www_form_component(venue_address.strip)
|
||||
uri = URI("https://nominatim.openstreetmap.org/search?q=#{encoded_address}&format=json&limit=1&addressdetails=1")
|
||||
|
||||
http = Net::HTTP.new(uri.host, uri.port)
|
||||
http.use_ssl = true
|
||||
|
||||
request = Net::HTTP::Get.new(uri)
|
||||
request["User-Agent"] = "AperoNight Event Platform/1.0 (https://aperonight.com)"
|
||||
request["Accept"] = "application/json"
|
||||
|
||||
response = http.request(request)
|
||||
|
||||
if response.code == "200"
|
||||
data = JSON.parse(response.body)
|
||||
|
||||
if data.any?
|
||||
result = data.first
|
||||
self.latitude = result["lat"].to_f.round(6)
|
||||
self.longitude = result["lon"].to_f.round(6)
|
||||
Rails.logger.info "Server-side geocoded '#{venue_address}' to coordinates: #{latitude}, #{longitude}"
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
# If we reach here, geocoding failed
|
||||
handle_geocoding_failure(original_lat, original_lng)
|
||||
|
||||
rescue => e
|
||||
Rails.logger.error "Server-side geocoding failed for '#{venue_address}': #{e.message}"
|
||||
handle_geocoding_failure(original_lat, original_lng)
|
||||
end
|
||||
end
|
||||
|
||||
# Handle geocoding failure with fallback strategies
|
||||
def handle_geocoding_failure(original_lat, original_lng)
|
||||
# Strategy 1: Keep existing coordinates if this is an update
|
||||
if original_lat.present? && original_lng.present?
|
||||
self.latitude = original_lat
|
||||
self.longitude = original_lng
|
||||
Rails.logger.warn "Geocoding failed for '#{venue_address}', keeping existing coordinates: #{latitude}, #{longitude}"
|
||||
return
|
||||
end
|
||||
|
||||
# Strategy 2: Try to extract country/city and use approximate coordinates
|
||||
fallback_coordinates = get_fallback_coordinates_from_address
|
||||
if fallback_coordinates
|
||||
self.latitude = fallback_coordinates[:lat]
|
||||
self.longitude = fallback_coordinates[:lng]
|
||||
Rails.logger.warn "Using fallback coordinates for '#{venue_address}': #{latitude}, #{longitude}"
|
||||
return
|
||||
end
|
||||
|
||||
# Strategy 3: Use default coordinates (center of France) as last resort
|
||||
# This ensures the event can still be created
|
||||
self.latitude = 46.603354 # Center of France
|
||||
self.longitude = 1.888334
|
||||
Rails.logger.warn "Using default coordinates for '#{venue_address}' due to geocoding failure: #{latitude}, #{longitude}"
|
||||
end
|
||||
|
||||
# Extract country/city from address and return approximate coordinates
|
||||
def get_fallback_coordinates_from_address
|
||||
address_lower = venue_address.downcase
|
||||
|
||||
# Common French cities with approximate coordinates
|
||||
french_cities = {
|
||||
"paris" => { lat: 48.8566, lng: 2.3522 },
|
||||
"lyon" => { lat: 45.7640, lng: 4.8357 },
|
||||
"marseille" => { lat: 43.2965, lng: 5.3698 },
|
||||
"toulouse" => { lat: 43.6047, lng: 1.4442 },
|
||||
"nice" => { lat: 43.7102, lng: 7.2620 },
|
||||
"nantes" => { lat: 47.2184, lng: -1.5536 },
|
||||
"montpellier" => { lat: 43.6110, lng: 3.8767 },
|
||||
"strasbourg" => { lat: 48.5734, lng: 7.7521 },
|
||||
"bordeaux" => { lat: 44.8378, lng: -0.5792 },
|
||||
"lille" => { lat: 50.6292, lng: 3.0573 }
|
||||
}
|
||||
|
||||
# Check if any known city is mentioned in the address
|
||||
french_cities.each do |city, coords|
|
||||
if address_lower.include?(city)
|
||||
return coords
|
||||
end
|
||||
end
|
||||
|
||||
# Check for common country indicators
|
||||
if address_lower.include?("france") || address_lower.include?("french")
|
||||
return { lat: 46.603354, lng: 1.888334 } # Center of France
|
||||
end
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
160
app/models/order.rb
Normal file
160
app/models/order.rb
Normal file
@@ -0,0 +1,160 @@
|
||||
class Order < ApplicationRecord
|
||||
# === Constants ===
|
||||
DRAFT_EXPIRY_TIME = 15.minutes
|
||||
MAX_PAYMENT_ATTEMPTS = 3
|
||||
|
||||
# === Associations ===
|
||||
belongs_to :user
|
||||
belongs_to :event
|
||||
has_many :tickets, dependent: :destroy
|
||||
|
||||
# === Validations ===
|
||||
validates :user_id, presence: true
|
||||
validates :event_id, presence: true
|
||||
validates :status, presence: true, inclusion: {
|
||||
in: %w[draft pending_payment paid completed cancelled expired]
|
||||
}
|
||||
validates :total_amount_cents, presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
validates :payment_attempts, presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
# Stripe invoice ID for accounting records
|
||||
attr_accessor :stripe_invoice_id
|
||||
|
||||
# === Scopes ===
|
||||
scope :draft, -> { where(status: "draft") }
|
||||
scope :active, -> { where(status: %w[paid completed]) }
|
||||
scope :expired_drafts, -> { draft.where("expires_at < ?", Time.current) }
|
||||
scope :can_retry_payment, -> {
|
||||
draft.where("payment_attempts < ? AND expires_at > ?",
|
||||
MAX_PAYMENT_ATTEMPTS, Time.current)
|
||||
}
|
||||
|
||||
before_validation :set_expiry, on: :create
|
||||
|
||||
# === Instance Methods ===
|
||||
|
||||
# Total amount in euros (formatted)
|
||||
def total_amount_euros
|
||||
total_amount_cents / 100.0
|
||||
end
|
||||
|
||||
# Check if order can be retried for payment
|
||||
def can_retry_payment?
|
||||
draft? && payment_attempts < MAX_PAYMENT_ATTEMPTS && !expired?
|
||||
end
|
||||
|
||||
# Check if order is expired
|
||||
def expired?
|
||||
expires_at.present? && expires_at < Time.current
|
||||
end
|
||||
|
||||
# Mark order as expired if it's past expiry time
|
||||
def expire_if_overdue!
|
||||
return unless draft? && expired?
|
||||
update!(status: "expired")
|
||||
end
|
||||
|
||||
# Increment payment attempt counter
|
||||
def increment_payment_attempt!
|
||||
update!(
|
||||
payment_attempts: payment_attempts + 1,
|
||||
last_payment_attempt_at: Time.current
|
||||
)
|
||||
end
|
||||
|
||||
# Check if draft is about to expire (within 5 minutes)
|
||||
def expiring_soon?
|
||||
return false unless draft? && expires_at.present?
|
||||
expires_at <= 5.minutes.from_now
|
||||
end
|
||||
|
||||
# Mark order as paid and activate all tickets
|
||||
def mark_as_paid!
|
||||
transaction do
|
||||
update!(status: "paid")
|
||||
tickets.update_all(status: "active")
|
||||
end
|
||||
|
||||
# Send purchase confirmation email outside the transaction
|
||||
# so that payment completion isn't affected by email failures
|
||||
begin
|
||||
TicketMailer.purchase_confirmation_order(self).deliver_now
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to send purchase confirmation email for order #{id}: #{e.message}"
|
||||
Rails.logger.error e.backtrace.join("\n")
|
||||
# Don't re-raise the error - payment should still succeed
|
||||
end
|
||||
end
|
||||
|
||||
# Calculate total from ticket prices only (platform fee deducted from promoter payout)
|
||||
def calculate_total!
|
||||
ticket_total = tickets.sum(:price_cents)
|
||||
update!(total_amount_cents: ticket_total)
|
||||
end
|
||||
|
||||
# Platform fee: €0.50 fixed + 1.5% of ticket price, per ticket
|
||||
def platform_fee_cents
|
||||
tickets.sum do |ticket|
|
||||
fixed_fee = 50 # €0.50 in cents
|
||||
percentage_fee = (ticket.price_cents * 0.015).to_i
|
||||
fixed_fee + percentage_fee
|
||||
end
|
||||
end
|
||||
|
||||
# Promoter payout amount after platform fee deduction
|
||||
def promoter_payout_cents
|
||||
total_amount_cents - platform_fee_cents
|
||||
end
|
||||
|
||||
def platform_fee_euros
|
||||
platform_fee_cents / 100.0
|
||||
end
|
||||
|
||||
def promoter_payout_euros
|
||||
promoter_payout_cents / 100.0
|
||||
end
|
||||
|
||||
# Create Stripe invoice for accounting records
|
||||
#
|
||||
# This method creates a post-payment invoice in Stripe for accounting purposes
|
||||
# It should only be called after the order has been paid
|
||||
#
|
||||
# @return [String, nil] The Stripe invoice ID or nil if creation failed
|
||||
def create_stripe_invoice!
|
||||
return nil unless status == "paid"
|
||||
return @stripe_invoice_id if @stripe_invoice_id.present?
|
||||
|
||||
service = StripeInvoiceService.new(self)
|
||||
stripe_invoice = service.create_post_payment_invoice
|
||||
|
||||
if stripe_invoice
|
||||
@stripe_invoice_id = stripe_invoice.id
|
||||
Rails.logger.info "Created Stripe invoice #{stripe_invoice.id} for order #{id}"
|
||||
stripe_invoice.id
|
||||
else
|
||||
Rails.logger.error "Failed to create Stripe invoice for order #{id}: #{service.errors.join(', ')}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Get the Stripe invoice PDF URL if available
|
||||
#
|
||||
# @return [String, nil] The PDF URL or nil if not available
|
||||
def stripe_invoice_pdf_url
|
||||
return nil unless @stripe_invoice_id.present?
|
||||
StripeInvoiceService.get_invoice_pdf_url(@stripe_invoice_id)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_expiry
|
||||
return unless status == "draft"
|
||||
self.expires_at = DRAFT_EXPIRY_TIME.from_now if expires_at.blank?
|
||||
end
|
||||
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
end
|
||||
76
app/models/ticket.rb
Executable file
76
app/models/ticket.rb
Executable file
@@ -0,0 +1,76 @@
|
||||
class Ticket < ApplicationRecord
|
||||
# === Associations ===
|
||||
belongs_to :order
|
||||
belongs_to :ticket_type
|
||||
has_one :event, through: :ticket_type
|
||||
has_one :user, through: :order
|
||||
|
||||
# === Validations ===
|
||||
validates :qr_code, presence: true, uniqueness: true
|
||||
validates :order_id, presence: true
|
||||
validates :ticket_type_id, presence: true
|
||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
||||
validates :status, presence: true, inclusion: { in: %w[draft active used expired refunded] }
|
||||
validates :first_name, presence: true
|
||||
validates :last_name, presence: true
|
||||
|
||||
# === Scopes ===
|
||||
scope :draft, -> { where(status: "draft") }
|
||||
scope :active, -> { where(status: "active") }
|
||||
scope :expired_drafts, -> { joins(:order).where(status: "draft").where("orders.expires_at < ?", Time.current) }
|
||||
|
||||
before_validation :set_price_from_ticket_type, on: :create
|
||||
before_validation :generate_qr_code, on: :create
|
||||
|
||||
# Generate PDF ticket
|
||||
def to_pdf
|
||||
TicketPdfGenerator.new(self).generate
|
||||
end
|
||||
|
||||
# Price in euros (formatted)
|
||||
def price_euros
|
||||
price_cents / 100.0
|
||||
end
|
||||
|
||||
# Delegate payment methods to order
|
||||
def can_retry_payment?
|
||||
order.can_retry_payment?
|
||||
end
|
||||
|
||||
def expired?
|
||||
order.expired?
|
||||
end
|
||||
|
||||
def expiring_soon?
|
||||
order.expiring_soon?
|
||||
end
|
||||
|
||||
# Mark ticket as expired if it's past expiry time
|
||||
def expire_if_overdue!
|
||||
order.expire_if_overdue!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_price_from_ticket_type
|
||||
return unless ticket_type
|
||||
self.price_cents = ticket_type.price_cents
|
||||
end
|
||||
|
||||
def generate_qr_code
|
||||
return if qr_code.present?
|
||||
|
||||
loop do
|
||||
self.qr_code = SecureRandom.uuid
|
||||
break unless Ticket.exists?(qr_code: qr_code)
|
||||
end
|
||||
rescue => e
|
||||
Rails.logger.error "Failed to generate QR code for ticket: #{e.message}"
|
||||
# Generate a simple fallback QR code
|
||||
self.qr_code = "#{id || 'temp'}-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
|
||||
end
|
||||
|
||||
def draft?
|
||||
status == "draft"
|
||||
end
|
||||
end
|
||||
89
app/models/ticket_type.rb
Executable file
89
app/models/ticket_type.rb
Executable file
@@ -0,0 +1,89 @@
|
||||
class TicketType < ApplicationRecord
|
||||
# Associations
|
||||
belongs_to :event
|
||||
has_many :tickets, dependent: :destroy # Cannot delete ticket types if already tickets sold
|
||||
|
||||
# Validations
|
||||
validates :name, presence: true, length: { minimum: 3, maximum: 50 }
|
||||
validates :description, presence: true, length: { minimum: 10, maximum: 500 }
|
||||
validates :price_cents, presence: true, numericality: { greater_than: 0 }
|
||||
validates :quantity, presence: true, numericality: { only_integer: true, greater_than: 0 }
|
||||
validates :sale_start_at, presence: true
|
||||
validates :sale_end_at, presence: true
|
||||
validates :minimum_age, numericality: { only_integer: true, greater_than_or_equal_to: 0, less_than_or_equal_to: 120 }, allow_nil: true
|
||||
validates :event_id, presence: true
|
||||
validates :requires_id, inclusion: { in: [ true, false ] }
|
||||
|
||||
# Custom validations
|
||||
validate :sale_end_after_start
|
||||
validate :sale_times_within_event_period
|
||||
|
||||
# Scopes
|
||||
scope :available_now, -> { where("sale_start_at <= ? AND sale_end_at >= ?", Time.current, Time.current) }
|
||||
scope :upcoming, -> { where("sale_start_at > ?", Time.current) }
|
||||
scope :expired, -> { where("sale_end_at < ?", Time.current) }
|
||||
|
||||
# Helper methods
|
||||
def price_euros
|
||||
return 0.0 if price_cents.nil?
|
||||
price_cents / 100.0
|
||||
end
|
||||
|
||||
def price_euros=(value)
|
||||
self.price_cents = (value.to_f * 100).to_i
|
||||
end
|
||||
|
||||
def available?
|
||||
return false if sale_start_at.nil? || sale_end_at.nil?
|
||||
sale_start_at <= Time.current && sale_end_at >= Time.current
|
||||
end
|
||||
|
||||
def sold_out?
|
||||
return false if quantity.nil?
|
||||
tickets.count >= quantity
|
||||
end
|
||||
|
||||
def available_quantity
|
||||
return 0 if quantity.nil?
|
||||
[ quantity - tickets.count, 0 ].max
|
||||
end
|
||||
|
||||
def sales_status
|
||||
return :draft if sale_start_at.nil? || sale_end_at.nil?
|
||||
return :expired if sale_end_at < Time.current
|
||||
return :upcoming if sale_start_at > Time.current
|
||||
return :sold_out if sold_out?
|
||||
:available
|
||||
end
|
||||
|
||||
def total_potential_revenue
|
||||
return 0.0 if quantity.nil? || price_cents.nil?
|
||||
quantity * price_euros
|
||||
end
|
||||
|
||||
def current_revenue
|
||||
return 0.0 if price_cents.nil?
|
||||
tickets.count * price_euros
|
||||
end
|
||||
|
||||
def remaining_potential_revenue
|
||||
return 0.0 if quantity.nil? || price_cents.nil?
|
||||
available_quantity * price_euros
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def sale_end_after_start
|
||||
return unless sale_start_at && sale_end_at
|
||||
errors.add(:sale_end_at, "must be after sale start") if sale_end_at <= sale_start_at
|
||||
end
|
||||
|
||||
def sale_times_within_event_period
|
||||
return unless event&.start_time && sale_end_at
|
||||
|
||||
# Only enforce this restriction if booking during event is not allowed
|
||||
unless event.allow_booking_during_event?
|
||||
errors.add(:sale_end_at, "cannot be after the event starts") if sale_end_at > event.start_time
|
||||
end
|
||||
end
|
||||
end
|
||||
51
app/models/user.rb
Executable file
51
app/models/user.rb
Executable file
@@ -0,0 +1,51 @@
|
||||
# User model for authentication and user management
|
||||
# Handles user accounts, authentication, and authorization using Devise
|
||||
class User < ApplicationRecord
|
||||
# Include default devise modules. Others available are:
|
||||
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
|
||||
#
|
||||
# Include default devise modules for authentication
|
||||
# :database_authenticatable - encrypts and stores password in database
|
||||
# :registerable - allows users to sign up and edit their accounts
|
||||
# :recoverable - handles password reset functionality
|
||||
# :rememberable - manages token-based user remembering
|
||||
# :validatable - provides email and password validation
|
||||
# Other available modules are:
|
||||
# :confirmable - requires email confirmation
|
||||
# :lockable - locks account after failed login attempts
|
||||
# :timeoutable - expires sessions after inactivity
|
||||
# :trackable - tracks sign-in count, timestamps, and IP
|
||||
# :omniauthable - allows authentication via OAuth providers
|
||||
devise :database_authenticatable, :registerable,
|
||||
:recoverable, :rememberable, :validatable
|
||||
|
||||
# Relationships
|
||||
has_many :events, dependent: :destroy
|
||||
has_many :tickets, dependent: :destroy
|
||||
has_many :orders, dependent: :destroy
|
||||
|
||||
# Validations - allow reasonable name lengths
|
||||
validates :last_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||
validates :first_name, length: { minimum: 2, maximum: 50, allow_blank: true }
|
||||
validates :company_name, length: { minimum: 2, maximum: 100, allow_blank: true }
|
||||
|
||||
# Onboarding methods
|
||||
def needs_onboarding?
|
||||
!onboarding_completed?
|
||||
end
|
||||
|
||||
def complete_onboarding!
|
||||
update!(onboarding_completed: true)
|
||||
end
|
||||
|
||||
# Authorization methods
|
||||
def can_manage_events?
|
||||
# Only professional users can manage events
|
||||
is_professionnal?
|
||||
end
|
||||
|
||||
def promoter?
|
||||
# Alias for can_manage_events? to make views more semantic
|
||||
can_manage_events?
|
||||
end
|
||||
end
|
||||
209
app/services/stripe_invoice_service.rb
Normal file
209
app/services/stripe_invoice_service.rb
Normal file
@@ -0,0 +1,209 @@
|
||||
# Service to create Stripe invoices for accounting records after successful payment
|
||||
#
|
||||
# This service creates post-payment invoices in Stripe for accounting purposes.
|
||||
# Unlike regular Stripe invoices which are used for collection, these are
|
||||
# created after payment via Checkout Sessions as accounting records.
|
||||
class StripeInvoiceService
|
||||
attr_reader :order, :errors
|
||||
|
||||
def initialize(order)
|
||||
@order = order
|
||||
@errors = []
|
||||
end
|
||||
|
||||
# Create a post-payment invoice in Stripe
|
||||
#
|
||||
# Returns the created Stripe invoice object or nil if creation failed
|
||||
def create_post_payment_invoice
|
||||
return nil unless valid_for_invoice_creation?
|
||||
|
||||
begin
|
||||
customer = find_or_create_stripe_customer
|
||||
return nil unless customer
|
||||
|
||||
invoice = create_stripe_invoice(customer)
|
||||
return nil unless invoice
|
||||
|
||||
add_line_items_to_invoice(customer, invoice)
|
||||
finalize_invoice(invoice)
|
||||
|
||||
Rails.logger.info "Successfully created Stripe invoice #{invoice.id} for order #{@order.id}"
|
||||
invoice
|
||||
rescue Stripe::StripeError => e
|
||||
handle_stripe_error(e)
|
||||
nil
|
||||
rescue => e
|
||||
handle_generic_error(e)
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Get the PDF URL for a Stripe invoice
|
||||
#
|
||||
# @param invoice_id [String] The Stripe invoice ID
|
||||
# @return [String, nil] The invoice PDF URL or nil if not available
|
||||
def self.get_invoice_pdf_url(invoice_id)
|
||||
return nil if invoice_id.blank?
|
||||
|
||||
begin
|
||||
invoice = Stripe::Invoice.retrieve(invoice_id)
|
||||
invoice.invoice_pdf
|
||||
rescue Stripe::StripeError => e
|
||||
Rails.logger.error "Failed to retrieve Stripe invoice PDF URL: #{e.message}"
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def valid_for_invoice_creation?
|
||||
unless @order.present?
|
||||
@errors << "Order is required"
|
||||
return false
|
||||
end
|
||||
|
||||
unless @order.status == "paid"
|
||||
@errors << "Order must be paid to create invoice"
|
||||
return false
|
||||
end
|
||||
|
||||
unless @order.user.present?
|
||||
@errors << "Order must have an associated user"
|
||||
return false
|
||||
end
|
||||
|
||||
unless @order.tickets.any?
|
||||
@errors << "Order must have tickets to create invoice"
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def find_or_create_stripe_customer
|
||||
if @order.user.stripe_customer_id.present?
|
||||
retrieve_existing_customer
|
||||
else
|
||||
create_new_customer
|
||||
end
|
||||
end
|
||||
|
||||
def retrieve_existing_customer
|
||||
Stripe::Customer.retrieve(@order.user.stripe_customer_id)
|
||||
rescue Stripe::InvalidRequestError
|
||||
# Customer doesn't exist, create a new one
|
||||
Rails.logger.warn "Stripe customer #{@order.user.stripe_customer_id} not found, creating new customer"
|
||||
@order.user.update(stripe_customer_id: nil)
|
||||
create_new_customer
|
||||
end
|
||||
|
||||
def create_new_customer
|
||||
customer = Stripe::Customer.create({
|
||||
email: @order.user.email,
|
||||
name: customer_name,
|
||||
metadata: {
|
||||
user_id: @order.user.id,
|
||||
created_by: "#{ENV.fetch('INVOICE_COMPANY_NAME', 'aperonight').downcase}_system"
|
||||
}
|
||||
})
|
||||
|
||||
@order.user.update(stripe_customer_id: customer.id)
|
||||
Rails.logger.info "Created new Stripe customer #{customer.id} for user #{@order.user.id}"
|
||||
customer
|
||||
end
|
||||
|
||||
def customer_name
|
||||
parts = []
|
||||
parts << @order.user.first_name if @order.user.first_name.present?
|
||||
parts << @order.user.last_name if @order.user.last_name.present?
|
||||
|
||||
if parts.empty?
|
||||
@order.user.email.split("@").first.humanize
|
||||
else
|
||||
parts.join(" ")
|
||||
end
|
||||
end
|
||||
|
||||
def create_stripe_invoice(customer)
|
||||
invoice_data = {
|
||||
customer: customer.id,
|
||||
collection_method: "send_invoice", # Don't auto-charge
|
||||
auto_advance: false, # Don't automatically finalize
|
||||
metadata: {
|
||||
order_id: @order.id,
|
||||
user_id: @order.user.id,
|
||||
event_name: @order.event.name,
|
||||
created_by: "#{ENV.fetch('INVOICE_COMPANY_NAME', 'aperonight').downcase}_system",
|
||||
payment_method: "checkout_session"
|
||||
},
|
||||
description: "Invoice for #{@order.event.name} - Order ##{@order.id}",
|
||||
footer: "Thank you for your purchase! This invoice is for your records as payment was already processed."
|
||||
}
|
||||
|
||||
# Add due date (same day since it's already paid)
|
||||
invoice_data[:due_date] = Time.current.to_i
|
||||
|
||||
Stripe::Invoice.create(invoice_data)
|
||||
end
|
||||
|
||||
def add_line_items_to_invoice(customer, invoice)
|
||||
# Add ticket line items
|
||||
@order.tickets.group_by(&:ticket_type).each do |ticket_type, tickets|
|
||||
quantity = tickets.count
|
||||
|
||||
Stripe::InvoiceItem.create({
|
||||
customer: customer.id,
|
||||
invoice: invoice.id,
|
||||
amount: ticket_type.price_cents * quantity,
|
||||
currency: "eur",
|
||||
description: build_line_item_description(ticket_type, tickets),
|
||||
metadata: {
|
||||
ticket_type_id: ticket_type.id,
|
||||
ticket_type_name: ticket_type.name,
|
||||
quantity: quantity,
|
||||
unit_price_cents: ticket_type.price_cents
|
||||
}
|
||||
})
|
||||
end
|
||||
|
||||
# No service fee on customer invoice; platform fee deducted from promoter payout
|
||||
end
|
||||
|
||||
def build_line_item_description(ticket_type, tickets)
|
||||
quantity = tickets.count
|
||||
unit_price = ticket_type.price_cents / 100.0
|
||||
|
||||
description_parts = [
|
||||
"#{@order.event.name}",
|
||||
"#{ticket_type.name}",
|
||||
"(#{quantity}x €#{unit_price})"
|
||||
]
|
||||
|
||||
description_parts.join(" - ")
|
||||
end
|
||||
|
||||
def finalize_invoice(invoice)
|
||||
# Mark as paid since payment was already processed via checkout
|
||||
finalized_invoice = invoice.finalize_invoice
|
||||
|
||||
# Mark the invoice as paid
|
||||
finalized_invoice.pay({
|
||||
paid_out_of_band: true, # Payment was made outside of Stripe invoicing
|
||||
payment_method: nil # No payment method needed for out-of-band payment
|
||||
})
|
||||
|
||||
finalized_invoice
|
||||
end
|
||||
|
||||
def handle_stripe_error(error)
|
||||
error_message = "Stripe invoice creation failed: #{error.message}"
|
||||
@errors << error_message
|
||||
Rails.logger.error "#{error_message} (Order: #{@order.id})"
|
||||
end
|
||||
|
||||
def handle_generic_error(error)
|
||||
error_message = "Invoice creation failed: #{error.message}"
|
||||
@errors << error_message
|
||||
Rails.logger.error "#{error_message} (Order: #{@order.id})"
|
||||
end
|
||||
end
|
||||
297
app/services/ticket_pdf_generator.rb
Executable file
297
app/services/ticket_pdf_generator.rb
Executable file
@@ -0,0 +1,297 @@
|
||||
require "prawn"
|
||||
require "prawn/qrcode"
|
||||
require "rqrcode"
|
||||
|
||||
# Service de génération de billets PDF utilisant Prawn
|
||||
#
|
||||
# Génère des billets PDF simples et compacts avec codes QR pour la validation d'entrée
|
||||
# Design propre et minimaliste qui tient sur une seule page
|
||||
class TicketPdfGenerator
|
||||
# Suppress Prawn's internationalization warning for built-in fonts
|
||||
Prawn::Fonts::AFM.hide_m17n_warning = true
|
||||
attr_reader :ticket
|
||||
|
||||
def initialize(ticket)
|
||||
@ticket = ticket
|
||||
end
|
||||
|
||||
def generate
|
||||
Prawn::Document.new(page_size: [ 350, 600 ], margin: 20) do |pdf|
|
||||
# Header
|
||||
pdf.fill_color "2D1B69"
|
||||
pdf.font "Helvetica", style: :bold, size: 24
|
||||
pdf.text ENV.fetch("APP_NAME", "Aperonight"), align: :center
|
||||
pdf.move_down 10
|
||||
|
||||
# Event name
|
||||
pdf.fill_color "000000"
|
||||
pdf.font "Helvetica", style: :bold, size: 18
|
||||
pdf.text ticket.event.name, align: :center
|
||||
pdf.move_down 10
|
||||
|
||||
# Ticket info box
|
||||
pdf.stroke_color "E5E7EB"
|
||||
pdf.fill_color "F9FAFB"
|
||||
pdf.rounded_rectangle [ 0, pdf.cursor ], 310, 150, 10
|
||||
pdf.fill_and_stroke
|
||||
|
||||
pdf.move_down 10
|
||||
pdf.fill_color "000000"
|
||||
pdf.font "Helvetica", size: 12
|
||||
|
||||
# Customer name
|
||||
pdf.indent 10 do
|
||||
pdf.text "Titulaire du billet :", style: :bold
|
||||
pdf.text "#{ticket.first_name} #{ticket.last_name}"
|
||||
end
|
||||
pdf.move_down 8
|
||||
|
||||
# Ticket details
|
||||
pdf.indent 10 do
|
||||
pdf.text "Type de billet :", style: :bold
|
||||
pdf.text ticket.ticket_type.name
|
||||
end
|
||||
pdf.move_down 8
|
||||
|
||||
pdf.indent 10 do
|
||||
pdf.text "Prix :", style: :bold
|
||||
pdf.text "#{ticket.price_euros} €"
|
||||
end
|
||||
pdf.move_down 8
|
||||
|
||||
pdf.indent 10 do
|
||||
pdf.text "Date et heure :", style: :bold
|
||||
pdf.text ticket.event.start_time.strftime("%d %B %Y à %H:%M")
|
||||
end
|
||||
pdf.move_down 20
|
||||
|
||||
# Informations sur le lieu
|
||||
pdf.fill_color "374151"
|
||||
pdf.font "Helvetica", style: :bold, size: 14
|
||||
pdf.text "Informations sur le lieu"
|
||||
pdf.move_down 8
|
||||
|
||||
pdf.font "Helvetica", size: 11
|
||||
pdf.text ticket.event.venue_name, style: :bold
|
||||
pdf.text ticket.event.venue_address
|
||||
pdf.move_down 20
|
||||
|
||||
# Code QR
|
||||
pdf.fill_color "000000"
|
||||
pdf.font "Helvetica", style: :bold, size: 14
|
||||
pdf.text "Code QR", align: :center
|
||||
pdf.move_down 10
|
||||
|
||||
# Ensure all required data is present before generating QR code
|
||||
if ticket.qr_code.blank?
|
||||
raise "Ticket QR code is missing"
|
||||
end
|
||||
|
||||
# Build QR code data with safe association loading
|
||||
qr_code_data = build_qr_code_data(ticket)
|
||||
|
||||
# Validate QR code data before creating QR code
|
||||
if qr_code_data.blank? || qr_code_data == "{}"
|
||||
Rails.logger.error "QR code data is empty: ticket_id=#{ticket.id}, qr_code=#{ticket.qr_code}, event_id=#{ticket.ticket_type&.event_id}, user_id=#{ticket.order&.user_id}"
|
||||
raise "QR code data is empty or invalid"
|
||||
end
|
||||
|
||||
# Ensure qr_code_data is a proper string for QR code generation
|
||||
unless qr_code_data.is_a?(String) && qr_code_data.length > 2
|
||||
Rails.logger.error "QR code data is not a valid string: #{qr_code_data.inspect} (class: #{qr_code_data.class})"
|
||||
raise "QR code data must be a valid string"
|
||||
end
|
||||
|
||||
# Generate QR code - prawn-qrcode expects the data string directly
|
||||
pdf.print_qr_code(qr_code_data, extent: 120, align: :center)
|
||||
|
||||
pdf.move_down 15
|
||||
|
||||
# QR code text
|
||||
pdf.font "Helvetica", size: 8
|
||||
pdf.fill_color "6B7280"
|
||||
pdf.text "#{ticket.qr_code}", align: :center
|
||||
|
||||
|
||||
# Ticket ID
|
||||
pdf.font "Helvetica", size: 8
|
||||
pdf.fill_color "6B7280"
|
||||
pdf.text "ID du billet : #{ticket.id}", align: :center
|
||||
|
||||
# Footer
|
||||
pdf.move_down 30
|
||||
pdf.stroke_color "E5E7EB"
|
||||
pdf.horizontal_line 0, 310
|
||||
pdf.move_down 6
|
||||
|
||||
pdf.font "Helvetica", size: 8
|
||||
pdf.fill_color "6B7280"
|
||||
pdf.text "Ce billet est valable pour une seule entrée.", align: :center
|
||||
pdf.text "Présentez ce billet à l'entrée du lieu.", align: :center
|
||||
pdf.move_down 5
|
||||
pdf.text "Généré le #{Time.current.strftime('%d %B %Y à %H:%M')}", align: :center
|
||||
end.render
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_simple_header(pdf)
|
||||
# Nom de la marque
|
||||
pdf.fill_color "6366F1"
|
||||
pdf.font "Helvetica", style: :bold, size: 24
|
||||
pdf.text "AperoNight", align: :center
|
||||
|
||||
pdf.move_down 5
|
||||
pdf.font "Helvetica", size: 10
|
||||
pdf.fill_color "64748B"
|
||||
pdf.text "Billet d'entree", align: :center
|
||||
|
||||
pdf.move_down 20
|
||||
|
||||
# Simple divider line
|
||||
pdf.stroke_color "E5E7EB"
|
||||
pdf.horizontal_line 0, pdf.bounds.width
|
||||
pdf.move_down 20
|
||||
end
|
||||
|
||||
def create_ticket_info(pdf)
|
||||
# Nom de l'événement - proéminent
|
||||
pdf.fill_color "1F2937"
|
||||
pdf.font "Helvetica", style: :bold, size: 18
|
||||
pdf.text ticket.event.name, align: :center
|
||||
pdf.move_down 15
|
||||
|
||||
# Two-column layout for ticket details
|
||||
pdf.bounding_box([ 0, pdf.cursor ], width: pdf.bounds.width, height: 120) do
|
||||
# Left column
|
||||
pdf.bounding_box([ 0, pdf.cursor ], width: pdf.bounds.width / 2 - 20, height: 120) do
|
||||
create_info_item(pdf, "Date", ticket.event.start_time.strftime("%d %B %Y"))
|
||||
create_info_item(pdf, "Heure", ticket.event.start_time.strftime("%H:%M"))
|
||||
create_info_item(pdf, "Lieu", ticket.event.venue_name)
|
||||
end
|
||||
|
||||
# Right column
|
||||
pdf.bounding_box([ pdf.bounds.width / 2 + 20, pdf.cursor ], width: pdf.bounds.width / 2 - 20, height: 120) do
|
||||
create_info_item(pdf, "Type", ticket.ticket_type.name)
|
||||
create_info_item(pdf, "Prix", "#{sprintf('%.2f', ticket.price_euros)} €")
|
||||
create_info_item(pdf, "Titulaire", "#{ticket.first_name} #{ticket.last_name}")
|
||||
end
|
||||
end
|
||||
|
||||
pdf.move_down 30
|
||||
end
|
||||
|
||||
def create_info_item(pdf, label, value)
|
||||
pdf.font "Helvetica", style: :bold, size: 9
|
||||
pdf.fill_color "64748B"
|
||||
pdf.text label.upcase
|
||||
|
||||
pdf.move_down 2
|
||||
pdf.font "Helvetica", size: 11
|
||||
pdf.fill_color "1F2937"
|
||||
pdf.text value
|
||||
pdf.move_down 12
|
||||
end
|
||||
|
||||
def create_qr_section(pdf)
|
||||
# Center the QR code horizontally
|
||||
qr_size = 120
|
||||
x_position = (pdf.bounds.width - qr_size) / 2
|
||||
|
||||
pdf.bounding_box([ x_position, pdf.cursor ], width: qr_size, height: qr_size + 40) do
|
||||
# QR Code title
|
||||
pdf.font "Helvetica", style: :bold, size: 12
|
||||
pdf.fill_color "1F2937"
|
||||
pdf.text "Code d'entree", align: :center
|
||||
pdf.move_down 10
|
||||
|
||||
# Generate QR code
|
||||
generate_simple_qr_code(pdf, qr_size)
|
||||
|
||||
pdf.move_down 10
|
||||
|
||||
# QR code ID
|
||||
pdf.font "Helvetica", size: 8
|
||||
pdf.fill_color "64748B"
|
||||
pdf.text "ID: #{ticket.qr_code[0..15]}...", align: :center
|
||||
end
|
||||
|
||||
pdf.move_down 40
|
||||
end
|
||||
|
||||
def generate_simple_qr_code(pdf, size)
|
||||
# Ensure all required data is present before generating QR code
|
||||
if ticket.qr_code.blank?
|
||||
raise "Ticket QR code is missing"
|
||||
end
|
||||
|
||||
# Build QR code data with safe association loading
|
||||
qr_code_data = build_qr_code_data(ticket)
|
||||
|
||||
# Validate QR code data before creating QR code
|
||||
if qr_code_data.blank? || qr_code_data == "{}"
|
||||
Rails.logger.error "QR code data is empty: ticket_id=#{ticket.id}, qr_code=#{ticket.qr_code}, event_id=#{ticket.ticket_type&.event_id}, user_id=#{ticket.order&.user_id}"
|
||||
raise "QR code data is empty or invalid"
|
||||
end
|
||||
|
||||
# Ensure qr_code_data is a proper string for QR code generation
|
||||
unless qr_code_data.is_a?(String) && qr_code_data.length > 2
|
||||
Rails.logger.error "QR code data is not a valid string: #{qr_code_data.inspect} (class: #{qr_code_data.class})"
|
||||
raise "QR code data must be a valid string"
|
||||
end
|
||||
|
||||
# Generate QR code
|
||||
pdf.print_qr_code(qr_code_data, extent: size, align: :center)
|
||||
end
|
||||
|
||||
def create_simple_footer(pdf)
|
||||
# Security notice
|
||||
pdf.font "Helvetica", size: 8
|
||||
pdf.fill_color "64748B"
|
||||
pdf.text "Ce billet est valable pour une seule entree.", align: :center
|
||||
pdf.text "Presentez ce code QR a l'entree de l'evenement.", align: :center
|
||||
|
||||
pdf.move_down 10
|
||||
|
||||
# Divider line
|
||||
pdf.stroke_color "E5E7EB"
|
||||
pdf.horizontal_line 0, pdf.bounds.width
|
||||
pdf.move_down 5
|
||||
|
||||
# Generation timestamp
|
||||
pdf.font "Helvetica", size: 7
|
||||
pdf.fill_color "9CA3AF"
|
||||
timestamp = "Genere le #{Time.current.strftime('%d/%m/%Y a %H:%M')}"
|
||||
pdf.text timestamp, align: :center
|
||||
end
|
||||
|
||||
def build_qr_code_data(ticket)
|
||||
# Try multiple approaches to get valid QR code data
|
||||
begin
|
||||
# Primary approach: full JSON with all data
|
||||
data = {
|
||||
ticket_id: ticket.id,
|
||||
qr_code: ticket.qr_code,
|
||||
event_id: ticket.ticket_type&.event_id,
|
||||
user_id: ticket.order&.user_id
|
||||
}.compact
|
||||
|
||||
# Ensure we have the minimum required data
|
||||
if data[:ticket_id] && data[:qr_code]
|
||||
return data.to_json
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to build complex QR data: #{e.message}"
|
||||
end
|
||||
|
||||
# Fallback approach: just use the ticket's QR code string
|
||||
begin
|
||||
return ticket.qr_code.to_s if ticket.qr_code.present?
|
||||
rescue StandardError => e
|
||||
Rails.logger.warn "Failed to use ticket QR code: #{e.message}"
|
||||
end
|
||||
|
||||
# Final fallback: simple ticket identifier
|
||||
"TICKET-#{ticket.id}"
|
||||
end
|
||||
end
|
||||
46
app/views/components/_breadcrumb.html.erb
Normal file
46
app/views/components/_breadcrumb.html.erb
Normal file
@@ -0,0 +1,46 @@
|
||||
<%# Dynamic breadcrumb navigation component %>
|
||||
<%# Usage: render 'components/breadcrumb', crumbs: [ %>
|
||||
<%# { name: 'Home', path: root_path }, %>
|
||||
<%# { name: 'Events', path: events_path }, %>
|
||||
<%# { name: 'Current Event', path: nil } %>
|
||||
<%# ] %>
|
||||
|
||||
<!-- Breadcrumb -->
|
||||
<nav class="w-full bg-white px-3 sm:px-4 py-3 rounded-xl shadow-sm border border-gray-100 mb-6 sm:mb-8 overflow-hidden" aria-label="Breadcrumb">
|
||||
<div class="flex items-center gap-1 sm:gap-2 min-w-0">
|
||||
<% crumbs.each_with_index do |crumb, index| %>
|
||||
<% if crumb[:path].present? %>
|
||||
<%# Crumb with link %>
|
||||
<%= link_to crumb[:path], class: "inline-flex items-center text-xs sm:text-sm font-medium text-gray-700 hover:text-primary-600 transition-colors duration-200 flex-shrink-0" do %>
|
||||
<% if index == 0 %>
|
||||
<i data-lucide="home" class="w-3 h-3 sm:w-4 sm:h-4 mr-1 sm:mr-2 flex-shrink-0"></i>
|
||||
<% end %>
|
||||
<span class="<%= 'hidden sm:inline' if index > 0 && index < crumbs.length - 2 %>">
|
||||
<%= crumb[:name] %>
|
||||
</span>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<%# Current page (no link) %>
|
||||
<span class="text-xs sm:text-sm font-medium text-primary-600 truncate min-w-0 flex-1" aria-current="page">
|
||||
<%= crumb[:name] %>
|
||||
</span>
|
||||
<% end %>
|
||||
|
||||
<%# Separator (except for the last item) %>
|
||||
<% if index < crumbs.length - 1 %>
|
||||
<% if index == 0 || index >= crumbs.length - 2 %>
|
||||
<i data-lucide="chevron-right" class="w-3 h-3 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0"></i>
|
||||
<% else %>
|
||||
<span class="hidden sm:inline">
|
||||
<i data-lucide="chevron-right" class="w-4 h-4 text-gray-400 flex-shrink-0"></i>
|
||||
</span>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<%# Show ellipsis on mobile when there are more than 3 items %>
|
||||
<% if crumbs.length > 3 %>
|
||||
<span class="text-gray-400 text-xs font-medium sm:hidden flex-shrink-0">...</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</nav>
|
||||
17
app/views/components/_delete_account.html.erb
Normal file
17
app/views/components/_delete_account.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<!-- Delete Account Section -->
|
||||
<div class="bg-white py-8 px-6 shadow-xl rounded-2xl">
|
||||
<h3 class="text-xl font-semibold text-gray-900 mb-4">Supprimer mon compte</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Vous êtes certain de vouloir supprimer votre compte ? Cette action est irréversible.
|
||||
</p>
|
||||
<%= button_to registration_path(resource_name),
|
||||
data: {
|
||||
confirm: "Êtes-vous certain ?",
|
||||
turbo_confirm: "Êtes-vous certain ?"
|
||||
},
|
||||
method: :delete,
|
||||
class: "group relative w-full flex justify-center items-center py-3 px-4 border border-red-300 text-sm font-semibold rounded-xl text-red-700 bg-red-50 hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 transition-all duration-200" do %>
|
||||
<i data-lucide="trash-2" class="w-4 h-4 mr-2"></i>
|
||||
Supprimer mon compte
|
||||
<% end %>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user