Compare commits
39 Commits
083bf63e18
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c403f350c2 | ||
|
|
fe69b4b39a | ||
|
|
5bd12e4482 | ||
|
|
6fd6b5dc8a | ||
|
|
c0a4cd2b45 | ||
|
|
1e4edb6de2 | ||
|
|
371be069db | ||
|
|
a7d9ff9c3f | ||
|
|
dae2bedca4 | ||
|
|
c01dcaf4bc | ||
|
|
4fff12648f | ||
|
|
99ecfa9a53 | ||
|
|
5eb2c3b41f | ||
|
|
c35206664d | ||
|
|
e6fd4e6f4c | ||
|
|
e79c3f79a2 | ||
|
|
3763847dd6 | ||
|
|
7dc89880a7 | ||
|
|
60b87a3609 | ||
|
|
b981b6998f | ||
|
|
62d8f15919 | ||
|
|
bfc2662efb | ||
|
|
4d8f808d97 | ||
|
|
ac9e7227e6 | ||
|
|
dc5029e8cb | ||
|
|
2a7c77d7be | ||
|
|
2491be9809 | ||
|
|
92b243e3ad | ||
|
|
e7895cb70c | ||
|
|
22e2b2457a | ||
|
|
011ca2a408 | ||
|
|
e73342f1fb | ||
|
|
41c1e7ad54 | ||
|
|
996ae20bbb | ||
|
|
bdc1f299da | ||
|
|
f9cc3efb1a | ||
|
|
b417c5bac0 | ||
|
|
524575f790 | ||
|
|
6e59c1f922 |
101
.agents/skills/bento-landing-page-generator/SKILL.md
Normal file
101
.agents/skills/bento-landing-page-generator/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: bento-landing-page-generator
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the native Laravel Boost tech stack (Laravel 12, Livewire 3, Alpine.js, Tailwind, GSAP).
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder (Livewire Edition)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application using the TALL stack. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's native ecosystem conventions.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, you must execute the following steps in exact order:
|
||||||
|
|
||||||
|
1. **Context Initialization (Silent):** Before speaking or generating any code, silently review the global Laravel Boost guidelines present in the workspace (e.g., `.cursorrules`, `.ai/rules`, or `.ai/architecture`). Ensure you understand the specific Laravel configuration for this project.
|
||||||
|
2. **Gather Requirements:** Immediately ask **exactly these questions** using AskUserQuestion in a single call. Do not ask follow-ups.
|
||||||
|
* "What is the SaaS product name and one-line elevator pitch?" (Example: "imail — High-speed ephemeral email API for developers.")
|
||||||
|
* "Is your primary audience Developers or Marketers/Creators?" (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
* "Pick an aesthetic preset: 'DevTool Dark' or 'Bento Light'."
|
||||||
|
* "What are 4 key features we can put into an asymmetric Bento Grid?"
|
||||||
|
* "What is the primary CTA?"
|
||||||
|
3. **Execution:** Build the full site based strictly on the user's answers, the chosen aesthetic preset, and the global Laravel Boost guidelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions & State
|
||||||
|
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Snappy UI (Alpine.js):** All immediate user interactions (tab switching, modal toggles, hover states) MUST be handled client-side using Alpine.js (`x-data`, `x-on:click`, `x-transition`). The UI must never wait for a server round-trip to update visually.
|
||||||
|
- **Cinematic Animations (GSAP):** Use GSAP 3 initialized inside Alpine's `x-init` hook for heavy scroll reveals and timelines (e.g., `x-init="gsap.from($el, { opacity: 0, y: 50, scrollTrigger: $el })"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the native TALL stack. Do not use React, Vue, or Inertia.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Livewire 3, Alpine.js, Tailwind CSS v3.4+, GSAP 3 (with ScrollTrigger), Blade Icons (Lucide).
|
||||||
|
- **Routing:** Define the route in `routes/web.php` pointing to a full-page Livewire component (e.g., `Route::get('/', App\Livewire\LandingPage::class);`).
|
||||||
|
- **Frontend Directory:** - Main page: `app/Livewire/LandingPage.php` and `resources/views/livewire/landing-page.blade.php`.
|
||||||
|
- Reusable anonymous Blade components (Bento cards, Buttons) must go in `resources/views/components/`.
|
||||||
|
- **Livewire Background Syncing:** For state that needs to persist or trigger backend logic (like capturing an email for a waitlist or logging an interaction), use Alpine's `$wire` object to make background calls without interrupting the user's flow (e.g., `@click="$wire.submitEmail(email)"` or using `@entangle`).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Livewire page component.
|
||||||
|
2. Create the Livewire class (`app/Livewire/LandingPage.php`) and its corresponding Blade view.
|
||||||
|
3. Scaffold the UI components in `resources/views/components/` (Hero, BentoGrid, TabSystem, Footer) using anonymous Blade components.
|
||||||
|
4. Wire up Alpine.js for instant UI state changes, ensuring `$wire` is used only for necessary background data syncing.
|
||||||
|
5. Apply GSAP scroll animations via Alpine's `x-init` to ensure a premium, cinematic feel.
|
||||||
|
6. Provide the complete Laravel/Livewire code ready to run.
|
||||||
202
.agents/skills/cinematic-landing-page-builder/SKILL.md
Normal file
202
.agents/skills/cinematic-landing-page-builder/SKILL.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
name: cinematic-landing-page-builder
|
||||||
|
description: Act as a World-Class Senior Creative Technologist to build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Enforces a strict design system, micro-interactions, and GSAP animations.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Cinematic Landing Page Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a World-Class Senior Creative Technologist and Lead Frontend Engineer. You build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Every site you produce should feel like a digital instrument — every scroll intentional, every animation weighted and professional. Eradicate all generic AI patterns.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a site (or this file is loaded into a fresh project), immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Do not over-discuss. Build.
|
||||||
|
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
|
||||||
|
1. **"What's the brand name and one-line purpose?"** — Free text. Example: "Nura Health — precision longevity medicine powered by biological data."
|
||||||
|
2. **"Pick an aesthetic direction"** — Single-select from the presets below. Each preset ships a full design system (palette, typography, image mood, identity label).
|
||||||
|
3. **"What are your 3 key value propositions?"** — Free text. Brief phrases. These become the Features section cards.
|
||||||
|
4. **"What should visitors do?"** — Free text. The primary CTA. Example: "Join the waitlist", "Book a consultation", "Start free trial".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
Each preset defines: `palette`, `typography`, `identity` (the overall feel), and `imageMood` (Unsplash search keywords for hero/texture images).
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "Organic Tech" (Clinical Boutique)
|
||||||
|
|
||||||
|
- **Identity:** A bridge between a biological research lab and an avant-garde luxury magazine.
|
||||||
|
- **Palette:** Moss `#2E4036` (Primary), Clay `#CC5833` (Accent), Cream `#F2F0E9` (Background), Charcoal `#1A1A1A` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Plus Jakarta Sans" + "Outfit" (tight tracking). Drama: "Cormorant Garamond" Italic. Data: `"IBM Plex Mono"`.
|
||||||
|
- **Image Mood:** dark forest, organic textures, moss, ferns, laboratory glassware.
|
||||||
|
- **Hero line pattern:** "[Concept noun] is the" (Bold Sans) / "[Power word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Midnight Luxe" (Dark Editorial)
|
||||||
|
|
||||||
|
- **Identity:** A private members' club meets a high-end watchmaker's atelier.
|
||||||
|
- **Palette:** Obsidian `#0D0D12` (Primary), Champagne `#C9A84C` (Accent), Ivory `#FAF8F5` (Background), Slate `#2A2A35` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Inter" (tight tracking). Drama: "Playfair Display" Italic. Data: `"JetBrains Mono"`.
|
||||||
|
- **Image Mood:** dark marble, gold accents, architectural shadows, luxury interiors.
|
||||||
|
- **Hero line pattern:** "[Aspirational noun] meets" (Bold Sans) / "[Precision word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset C — "Brutalist Signal" (Raw Precision)
|
||||||
|
|
||||||
|
- **Identity:** A control room for the future — no decoration, pure information density.
|
||||||
|
- **Palette:** Paper `#E8E4DD` (Primary), Signal Red `#E63B2E` (Accent), Off-white `#F5F3EE` (Background), Black `#111111` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Space Grotesk" (tight tracking). Drama: "DM Serif Display" Italic. Data: `"Space Mono"`.
|
||||||
|
- **Image Mood:** concrete, brutalist architecture, raw materials, industrial.
|
||||||
|
- **Hero line pattern:** "[Direct verb] the" (Bold Sans) / "[System noun]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset D — "Vapor Clinic" (Neon Biotech)
|
||||||
|
|
||||||
|
- **Identity:** A genome sequencing lab inside a Tokyo nightclub.
|
||||||
|
- **Palette:** Deep Void `#0A0A14` (Primary), Plasma `#7B61FF` (Accent), Ghost `#F0EFF4` (Background), Graphite `#18181B` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Sora" (tight tracking). Drama: "Instrument Serif" Italic. Data: `"Fira Code"`.
|
||||||
|
- **Image Mood:** bioluminescence, dark water, neon reflections, microscopy.
|
||||||
|
- **Hero line pattern:** "[Tech noun] beyond" (Bold Sans) / "[Boundary word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
These rules apply to ALL presets. They are what make the output premium.
|
||||||
|
|
||||||
|
|
||||||
|
### Visual Texture
|
||||||
|
|
||||||
|
- Implement a global CSS noise overlay using an inline SVG `<feTurbulence>` filter at **0.05 opacity** to eliminate flat digital gradients.
|
||||||
|
- Use a `rounded-[2rem]` to `rounded-[3rem]` radius system for all containers. No sharp corners anywhere.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
|
||||||
|
- All buttons must have a **"magnetic" feel**: subtle `scale(1.03)` on hover with `cubic-bezier(0.25, 0.46, 0.45, 0.94)`.
|
||||||
|
- Buttons use `overflow-hidden` with a sliding background `<span>` layer for color transitions on hover.
|
||||||
|
- Links and interactive elements get a `translateY(-1px)` lift on hover.
|
||||||
|
|
||||||
|
|
||||||
|
### Animation Lifecycle
|
||||||
|
|
||||||
|
- Use `gsap.context()` within `useEffect` for ALL animations. Return `ctx.revert()` in the cleanup function.
|
||||||
|
- Default easing: `power3.out` for entrances, `power2.inOut` for morphs.
|
||||||
|
- Stagger value: `0.08` for text, `0.15` for cards/containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Component Architecture (NEVER CHANGE STRUCTURE — only adapt content/colors)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### A. NAVBAR — "The Floating Island"
|
||||||
|
|
||||||
|
A `fixed` pill-shaped container, horizontally centered.
|
||||||
|
- **Morphing Logic:** Transparent with light text at hero top. Transitions to `bg-[background]/60 backdrop-blur-xl` with primary-colored text and a subtle `border` when scrolled past the hero. Use `IntersectionObserver` or ScrollTrigger.
|
||||||
|
- Contains: Logo (brand name as text), 3-4 nav links, CTA button (accent color).
|
||||||
|
|
||||||
|
|
||||||
|
### B. HERO SECTION — "The Opening Shot"
|
||||||
|
|
||||||
|
- `100dvh` height. Full-bleed background image (sourced from Unsplash matching preset's `imageMood`) with a heavy **primary-to-black gradient overlay** (`bg-gradient-to-t`).
|
||||||
|
- **Layout:** Content pushed to the **bottom-left third** using flex + padding.
|
||||||
|
- **Typography:** Large scale contrast following the preset's hero line pattern. First part in bold sans heading font. Second part in massive serif italic drama font (3-5x size difference).
|
||||||
|
- **Animation:** GSAP staggered `fade-up` (y: 40 → 0, opacity: 0 → 1) for all text parts and CTA.
|
||||||
|
- CTA button below the headline, using the accent color.
|
||||||
|
|
||||||
|
|
||||||
|
### C. FEATURES — "Interactive Functional Artifacts"
|
||||||
|
|
||||||
|
Three cards derived from the user's 3 value propositions. These must feel like **functional software micro-UIs**, not static marketing cards. Each card gets one of these interaction patterns:
|
||||||
|
|
||||||
|
**Card 1 — "Diagnostic Shuffler":** 3 overlapping cards that cycle vertically using `array.unshift(array.pop())` logic every 3 seconds with a spring-bounce transition (`cubic-bezier(0.34, 1.56, 0.64, 1)`). Labels derived from user's first value prop (generate 3 sub-labels).
|
||||||
|
|
||||||
|
**Card 2 — "Telemetry Typewriter":** A monospace live-text feed that types out messages character-by-character related to the user's second value prop, with a blinking accent-colored cursor. Include a "Live Feed" label with a pulsing dot.
|
||||||
|
|
||||||
|
**Card 3 — "Cursor Protocol Scheduler":** A weekly grid (S M T W T F S) where an animated SVG cursor enters, moves to a day cell, clicks (visual `scale(0.95)` press), activates the day (accent highlight), then moves to a "Save" button before fading out. Labels from user's third value prop.
|
||||||
|
|
||||||
|
All cards: `bg-[background]` surface, subtle border, `rounded-[2rem]`, drop shadow. Each card has a heading (sans bold) and a brief descriptor.
|
||||||
|
|
||||||
|
|
||||||
|
### D. PHILOSOPHY — "The Manifesto"
|
||||||
|
|
||||||
|
- Full-width section with the **dark color** as background.
|
||||||
|
- A parallaxing organic texture image (Unsplash, `imageMood` keywords) at low opacity behind the text.
|
||||||
|
- **Typography:** Two contrasting statements. Pattern:
|
||||||
|
- "Most [industry] focuses on: [common approach]." — neutral, smaller.
|
||||||
|
- "We focus on: [differentiated approach]." — massive, drama serif italic, accent-colored keyword.
|
||||||
|
- **Animation:** GSAP `SplitText`-style reveal (word-by-word or line-by-line fade-up) triggered by ScrollTrigger.
|
||||||
|
|
||||||
|
|
||||||
|
### E. PROTOCOL — "Sticky Stacking Archive"
|
||||||
|
|
||||||
|
3 full-screen cards that stack on scroll.
|
||||||
|
- **Stacking Interaction:** Using GSAP ScrollTrigger with `pin: true`. As a new card scrolls into view, the card underneath scales to `0.9`, blurs to `20px`, and fades to `0.5`.
|
||||||
|
- **Each card gets a unique canvas/SVG animation:**
|
||||||
|
1. A slowly rotating geometric motif (double-helix, concentric circles, or gear teeth).
|
||||||
|
2. A scanning horizontal laser-line moving across a grid of dots/cells.
|
||||||
|
3. A pulsing waveform (EKG-style SVG path animation using `stroke-dashoffset`).
|
||||||
|
- Card content: Step number (monospace), title (heading font), 2-line description. Derive from user's brand purpose.
|
||||||
|
|
||||||
|
|
||||||
|
### F. MEMBERSHIP / PRICING
|
||||||
|
|
||||||
|
- Three-tier pricing grid. Card names: "Essential", "Performance", "Enterprise" (adjust to fit brand).
|
||||||
|
- **Middle card pops:** Primary-colored background with an accent CTA button. Slightly larger scale or `ring` border.
|
||||||
|
- If pricing doesn't apply, convert this into a "Get Started" section with a single large CTA.
|
||||||
|
|
||||||
|
|
||||||
|
### G. FOOTER
|
||||||
|
|
||||||
|
- Deep dark-colored background, `rounded-t-[4rem]`.
|
||||||
|
- Grid layout: Brand name + tagline, navigation columns, legal links.
|
||||||
|
- **"System Operational" status indicator** with a pulsing green dot and monospace label.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
- **Stack:** React 19, Tailwind CSS v3.4.17, GSAP 3 (with ScrollTrigger plugin), Lucide React for icons.
|
||||||
|
- **Fonts:** Load via Google Fonts `<link>` tags in `index.html` based on the selected preset.
|
||||||
|
- **Images:** Use real Unsplash URLs. Select images matching the preset's `imageMood`. Never use placeholder URLs.
|
||||||
|
- **File structure:** Single `App.jsx` with components defined in the same file (or split into `components/` if >600 lines). Single `index.css` for Tailwind directives + noise overlay + custom utilities.
|
||||||
|
- **No placeholders.** Every card, every label, every animation must be fully implemented and functional.
|
||||||
|
- **Responsive:** Mobile-first. Stack cards vertically on mobile. Reduce hero font sizes. Collapse navbar into a minimal version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 4 questions:
|
||||||
|
|
||||||
|
1. Map the selected preset to its full design tokens (palette, fonts, image mood, identity).
|
||||||
|
2. Generate hero copy using the brand name + purpose + preset's hero line pattern.
|
||||||
|
3. Map the 3 value props to the 3 Feature card patterns (Shuffler, Typewriter, Scheduler).
|
||||||
|
4. Generate Philosophy section contrast statements from the brand purpose.
|
||||||
|
5. Generate Protocol steps from the brand's process/methodology.
|
||||||
|
6. Scaffold the project: `npm create vite@latest`, install deps, write all files.
|
||||||
|
7. Ensure every animation is wired, every interaction works, every image loads.
|
||||||
|
|
||||||
|
**Execution Directive:** "Do not build a website; build a digital instrument. Every scroll should feel intentional, every animation should feel weighted and professional. Eradicate all generic AI patterns."
|
||||||
98
.agents/skills/laravel-bento-saas-builder/SKILL.md
Normal file
98
.agents/skills/laravel-bento-saas-builder/SKILL.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: laravel-bento-saas-builder
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the Laravel Boost tech stack (Laravel 12, Inertia.js v2, React, Tailwind).
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's ecosystem conventions.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Build.
|
||||||
|
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
|
||||||
|
1. **"What is the SaaS product name and one-line elevator pitch?"**
|
||||||
|
2. **"Is your primary audience Developers or Marketers/Creators?"** (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
3. **"Pick an aesthetic preset:"** "DevTool Dark" (Neon/Dark Mode) or "Bento Light" (Clean/Playful Light Mode).
|
||||||
|
4. **"What are 4 key features we can put into an asymmetric Bento Grid?"**
|
||||||
|
5. **"What is the primary CTA?"**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Component Lifecycles:** Use `framer-motion` for all layout animations. Scroll reveals must use `whileInView={{ opacity: 1, y: 0 }}`. Stagger children components using `transition={{ staggerChildren: 0.1 }}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the following stack and structure. Do not create a standalone Vite React app.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Inertia.js v2, React, Tailwind CSS, Framer Motion, Lucide React.
|
||||||
|
- **Routing:** Define the landing page route in `routes/web.php` using `Route::get('/', function () { return Inertia::render('Welcome'); });`.
|
||||||
|
- **Frontend Directory:** All UI components must be placed in `resources/js/Pages/` (for the main views) and `resources/js/Components/` (for reusable UI parts like the Navbar, BentoGrid, and Footer).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Inertia page.
|
||||||
|
2. Create the main page component at `resources/js/Pages/Welcome.jsx`.
|
||||||
|
3. Scaffold the UI components in `resources/js/Components/` (Hero, BentoGrid, TabSystem, Footer).
|
||||||
|
4. Apply Framer Motion layout animations to the Bento Grid to ensure a premium feel.
|
||||||
|
5. Provide the complete Laravel/Inertia code ready to run.
|
||||||
25
.ai/guidelines/design-system.md
Normal file
25
.ai/guidelines/design-system.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Design System (CRITICAL — Auto-Read Required)
|
||||||
|
|
||||||
|
This project has a comprehensive Design System documented in `DESIGN.md` at the project root.
|
||||||
|
|
||||||
|
## Before Any UI/UX Work
|
||||||
|
|
||||||
|
**BEFORE** making ANY frontend, UI, UX, styling, component, animation, Blade template, or Tailwind-related change, you MUST read `DESIGN.md` using your file reading tool to understand the established:
|
||||||
|
- Design tokens and color system
|
||||||
|
- Typography scale and font conventions
|
||||||
|
- Component patterns (toasts, modals, buttons, indicators)
|
||||||
|
- Spacing constants and layout architecture
|
||||||
|
- Animation and transition conventions
|
||||||
|
- Responsive breakpoint rules
|
||||||
|
|
||||||
|
This applies to: Blade views, CSS files, Alpine.js interactions, GSAP animations, Livewire component UI, toast notifications, modals, buttons, indicators, and any other visual element.
|
||||||
|
|
||||||
|
## After Completing UI/UX Work
|
||||||
|
|
||||||
|
**AFTER** completing any UI/UX change, you MUST update `DESIGN.md` per its Section 15 (Maintenance Protocol):
|
||||||
|
- Add a new entry to Section 14 (Iteration History) using the provided template.
|
||||||
|
- Update Section 9 (Component Library) if a component was created or modified.
|
||||||
|
- Update Sections 5/6/7 if design tokens, fonts, or colors were added/changed.
|
||||||
|
- Bump the version number and "Last updated" date at the top of the file.
|
||||||
|
|
||||||
|
Failure to read and follow `DESIGN.md` will result in inconsistent UI, visual regressions, and wasted iteration cycles.
|
||||||
79
.ai/skills/bento-landing-page-generator/SKILL.md
Normal file
79
.ai/skills/bento-landing-page-generator/SKILL.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
---
|
||||||
|
name: bento-landing-page-generator
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the native Laravel Boost tech stack (Laravel 12, Livewire 3, Alpine.js, Tailwind, GSAP).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder (Livewire Edition)
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application using the TALL stack. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's native ecosystem conventions.
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, you must execute the following steps in exact order:
|
||||||
|
|
||||||
|
1. **Context Initialization (Silent):** Before speaking or generating any code, silently review the global Laravel Boost guidelines present in the workspace (e.g., `.cursorrules`, `.ai/rules`, or `.ai/architecture`). Ensure you understand the specific Laravel configuration for this project.
|
||||||
|
2. **Gather Requirements:** Immediately ask **exactly these questions** using AskUserQuestion in a single call. Do not ask follow-ups.
|
||||||
|
* "What is the SaaS product name and one-line elevator pitch?" (Example: "imail — High-speed ephemeral email API for developers.")
|
||||||
|
* "Is your primary audience Developers or Marketers/Creators?" (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
* "Pick an aesthetic preset: 'DevTool Dark' or 'Bento Light'."
|
||||||
|
* "What are 4 key features we can put into an asymmetric Bento Grid?"
|
||||||
|
* "What is the primary CTA?"
|
||||||
|
3. **Execution:** Build the full site based strictly on the user's answers, the chosen aesthetic preset, and the global Laravel Boost guidelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
### Micro-Interactions & State
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Snappy UI (Alpine.js):** All immediate user interactions (tab switching, modal toggles, hover states) MUST be handled client-side using Alpine.js (`x-data`, `x-on:click`, `x-transition`). The UI must never wait for a server round-trip to update visually.
|
||||||
|
- **Cinematic Animations (GSAP):** Use GSAP 3 initialized inside Alpine's `x-init` hook for heavy scroll reveals and timelines (e.g., `x-init="gsap.from($el, { opacity: 0, y: 50, scrollTrigger: $el })"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the native TALL stack. Do not use React, Vue, or Inertia.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Livewire 3, Alpine.js, Tailwind CSS v3.4+, GSAP 3 (with ScrollTrigger), Blade Icons (Lucide).
|
||||||
|
- **Routing:** Define the route in `routes/web.php` pointing to a full-page Livewire component (e.g., `Route::get('/', App\Livewire\LandingPage::class);`).
|
||||||
|
- **Frontend Directory:** - Main page: `app/Livewire/LandingPage.php` and `resources/views/livewire/landing-page.blade.php`.
|
||||||
|
- Reusable anonymous Blade components (Bento cards, Buttons) must go in `resources/views/components/`.
|
||||||
|
- **Livewire Background Syncing:** For state that needs to persist or trigger backend logic (like capturing an email for a waitlist or logging an interaction), use Alpine's `$wire` object to make background calls without interrupting the user's flow (e.g., `@click="$wire.submitEmail(email)"` or using `@entangle`).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Livewire page component.
|
||||||
|
2. Create the Livewire class (`app/Livewire/LandingPage.php`) and its corresponding Blade view.
|
||||||
|
3. Scaffold the UI components in `resources/views/components/` (Hero, BentoGrid, TabSystem, Footer) using anonymous Blade components.
|
||||||
|
4. Wire up Alpine.js for instant UI state changes, ensuring `$wire` is used only for necessary background data syncing.
|
||||||
|
5. Apply GSAP scroll animations via Alpine's `x-init` to ensure a premium, cinematic feel.
|
||||||
|
6. Provide the complete Laravel/Livewire code ready to run.
|
||||||
156
.ai/skills/cinematic-landing-page-builder/SKILL.md
Normal file
156
.ai/skills/cinematic-landing-page-builder/SKILL.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
---
|
||||||
|
name: cinematic-landing-page-builder
|
||||||
|
description: Act as a World-Class Senior Creative Technologist to build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Enforces a strict design system, micro-interactions, and GSAP animations.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Cinematic Landing Page Builder
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
Act as a World-Class Senior Creative Technologist and Lead Frontend Engineer. You build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Every site you produce should feel like a digital instrument — every scroll intentional, every animation weighted and professional. Eradicate all generic AI patterns.
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
When the user asks to build a site (or this file is loaded into a fresh project), immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Do not over-discuss. Build.
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
1. **"What's the brand name and one-line purpose?"** — Free text. Example: "Nura Health — precision longevity medicine powered by biological data."
|
||||||
|
2. **"Pick an aesthetic direction"** — Single-select from the presets below. Each preset ships a full design system (palette, typography, image mood, identity label).
|
||||||
|
3. **"What are your 3 key value propositions?"** — Free text. Brief phrases. These become the Features section cards.
|
||||||
|
4. **"What should visitors do?"** — Free text. The primary CTA. Example: "Join the waitlist", "Book a consultation", "Start free trial".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
Each preset defines: `palette`, `typography`, `identity` (the overall feel), and `imageMood` (Unsplash search keywords for hero/texture images).
|
||||||
|
|
||||||
|
### Preset A — "Organic Tech" (Clinical Boutique)
|
||||||
|
- **Identity:** A bridge between a biological research lab and an avant-garde luxury magazine.
|
||||||
|
- **Palette:** Moss `#2E4036` (Primary), Clay `#CC5833` (Accent), Cream `#F2F0E9` (Background), Charcoal `#1A1A1A` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Plus Jakarta Sans" + "Outfit" (tight tracking). Drama: "Cormorant Garamond" Italic. Data: `"IBM Plex Mono"`.
|
||||||
|
- **Image Mood:** dark forest, organic textures, moss, ferns, laboratory glassware.
|
||||||
|
- **Hero line pattern:** "[Concept noun] is the" (Bold Sans) / "[Power word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
### Preset B — "Midnight Luxe" (Dark Editorial)
|
||||||
|
- **Identity:** A private members' club meets a high-end watchmaker's atelier.
|
||||||
|
- **Palette:** Obsidian `#0D0D12` (Primary), Champagne `#C9A84C` (Accent), Ivory `#FAF8F5` (Background), Slate `#2A2A35` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Inter" (tight tracking). Drama: "Playfair Display" Italic. Data: `"JetBrains Mono"`.
|
||||||
|
- **Image Mood:** dark marble, gold accents, architectural shadows, luxury interiors.
|
||||||
|
- **Hero line pattern:** "[Aspirational noun] meets" (Bold Sans) / "[Precision word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
### Preset C — "Brutalist Signal" (Raw Precision)
|
||||||
|
- **Identity:** A control room for the future — no decoration, pure information density.
|
||||||
|
- **Palette:** Paper `#E8E4DD` (Primary), Signal Red `#E63B2E` (Accent), Off-white `#F5F3EE` (Background), Black `#111111` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Space Grotesk" (tight tracking). Drama: "DM Serif Display" Italic. Data: `"Space Mono"`.
|
||||||
|
- **Image Mood:** concrete, brutalist architecture, raw materials, industrial.
|
||||||
|
- **Hero line pattern:** "[Direct verb] the" (Bold Sans) / "[System noun]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
### Preset D — "Vapor Clinic" (Neon Biotech)
|
||||||
|
- **Identity:** A genome sequencing lab inside a Tokyo nightclub.
|
||||||
|
- **Palette:** Deep Void `#0A0A14` (Primary), Plasma `#7B61FF` (Accent), Ghost `#F0EFF4` (Background), Graphite `#18181B` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Sora" (tight tracking). Drama: "Instrument Serif" Italic. Data: `"Fira Code"`.
|
||||||
|
- **Image Mood:** bioluminescence, dark water, neon reflections, microscopy.
|
||||||
|
- **Hero line pattern:** "[Tech noun] beyond" (Bold Sans) / "[Boundary word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
These rules apply to ALL presets. They are what make the output premium.
|
||||||
|
|
||||||
|
### Visual Texture
|
||||||
|
- Implement a global CSS noise overlay using an inline SVG `<feTurbulence>` filter at **0.05 opacity** to eliminate flat digital gradients.
|
||||||
|
- Use a `rounded-[2rem]` to `rounded-[3rem]` radius system for all containers. No sharp corners anywhere.
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
- All buttons must have a **"magnetic" feel**: subtle `scale(1.03)` on hover with `cubic-bezier(0.25, 0.46, 0.45, 0.94)`.
|
||||||
|
- Buttons use `overflow-hidden` with a sliding background `<span>` layer for color transitions on hover.
|
||||||
|
- Links and interactive elements get a `translateY(-1px)` lift on hover.
|
||||||
|
|
||||||
|
### Animation Lifecycle
|
||||||
|
- Use `gsap.context()` within `useEffect` for ALL animations. Return `ctx.revert()` in the cleanup function.
|
||||||
|
- Default easing: `power3.out` for entrances, `power2.inOut` for morphs.
|
||||||
|
- Stagger value: `0.08` for text, `0.15` for cards/containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Architecture (NEVER CHANGE STRUCTURE — only adapt content/colors)
|
||||||
|
|
||||||
|
### A. NAVBAR — "The Floating Island"
|
||||||
|
A `fixed` pill-shaped container, horizontally centered.
|
||||||
|
- **Morphing Logic:** Transparent with light text at hero top. Transitions to `bg-[background]/60 backdrop-blur-xl` with primary-colored text and a subtle `border` when scrolled past the hero. Use `IntersectionObserver` or ScrollTrigger.
|
||||||
|
- Contains: Logo (brand name as text), 3-4 nav links, CTA button (accent color).
|
||||||
|
|
||||||
|
### B. HERO SECTION — "The Opening Shot"
|
||||||
|
- `100dvh` height. Full-bleed background image (sourced from Unsplash matching preset's `imageMood`) with a heavy **primary-to-black gradient overlay** (`bg-gradient-to-t`).
|
||||||
|
- **Layout:** Content pushed to the **bottom-left third** using flex + padding.
|
||||||
|
- **Typography:** Large scale contrast following the preset's hero line pattern. First part in bold sans heading font. Second part in massive serif italic drama font (3-5x size difference).
|
||||||
|
- **Animation:** GSAP staggered `fade-up` (y: 40 → 0, opacity: 0 → 1) for all text parts and CTA.
|
||||||
|
- CTA button below the headline, using the accent color.
|
||||||
|
|
||||||
|
### C. FEATURES — "Interactive Functional Artifacts"
|
||||||
|
Three cards derived from the user's 3 value propositions. These must feel like **functional software micro-UIs**, not static marketing cards. Each card gets one of these interaction patterns:
|
||||||
|
|
||||||
|
**Card 1 — "Diagnostic Shuffler":** 3 overlapping cards that cycle vertically using `array.unshift(array.pop())` logic every 3 seconds with a spring-bounce transition (`cubic-bezier(0.34, 1.56, 0.64, 1)`). Labels derived from user's first value prop (generate 3 sub-labels).
|
||||||
|
|
||||||
|
**Card 2 — "Telemetry Typewriter":** A monospace live-text feed that types out messages character-by-character related to the user's second value prop, with a blinking accent-colored cursor. Include a "Live Feed" label with a pulsing dot.
|
||||||
|
|
||||||
|
**Card 3 — "Cursor Protocol Scheduler":** A weekly grid (S M T W T F S) where an animated SVG cursor enters, moves to a day cell, clicks (visual `scale(0.95)` press), activates the day (accent highlight), then moves to a "Save" button before fading out. Labels from user's third value prop.
|
||||||
|
|
||||||
|
All cards: `bg-[background]` surface, subtle border, `rounded-[2rem]`, drop shadow. Each card has a heading (sans bold) and a brief descriptor.
|
||||||
|
|
||||||
|
### D. PHILOSOPHY — "The Manifesto"
|
||||||
|
- Full-width section with the **dark color** as background.
|
||||||
|
- A parallaxing organic texture image (Unsplash, `imageMood` keywords) at low opacity behind the text.
|
||||||
|
- **Typography:** Two contrasting statements. Pattern:
|
||||||
|
- "Most [industry] focuses on: [common approach]." — neutral, smaller.
|
||||||
|
- "We focus on: [differentiated approach]." — massive, drama serif italic, accent-colored keyword.
|
||||||
|
- **Animation:** GSAP `SplitText`-style reveal (word-by-word or line-by-line fade-up) triggered by ScrollTrigger.
|
||||||
|
|
||||||
|
### E. PROTOCOL — "Sticky Stacking Archive"
|
||||||
|
3 full-screen cards that stack on scroll.
|
||||||
|
- **Stacking Interaction:** Using GSAP ScrollTrigger with `pin: true`. As a new card scrolls into view, the card underneath scales to `0.9`, blurs to `20px`, and fades to `0.5`.
|
||||||
|
- **Each card gets a unique canvas/SVG animation:**
|
||||||
|
1. A slowly rotating geometric motif (double-helix, concentric circles, or gear teeth).
|
||||||
|
2. A scanning horizontal laser-line moving across a grid of dots/cells.
|
||||||
|
3. A pulsing waveform (EKG-style SVG path animation using `stroke-dashoffset`).
|
||||||
|
- Card content: Step number (monospace), title (heading font), 2-line description. Derive from user's brand purpose.
|
||||||
|
|
||||||
|
### F. MEMBERSHIP / PRICING
|
||||||
|
- Three-tier pricing grid. Card names: "Essential", "Performance", "Enterprise" (adjust to fit brand).
|
||||||
|
- **Middle card pops:** Primary-colored background with an accent CTA button. Slightly larger scale or `ring` border.
|
||||||
|
- If pricing doesn't apply, convert this into a "Get Started" section with a single large CTA.
|
||||||
|
|
||||||
|
### G. FOOTER
|
||||||
|
- Deep dark-colored background, `rounded-t-[4rem]`.
|
||||||
|
- Grid layout: Brand name + tagline, navigation columns, legal links.
|
||||||
|
- **"System Operational" status indicator** with a pulsing green dot and monospace label.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Requirements (NEVER CHANGE)
|
||||||
|
|
||||||
|
- **Stack:** React 19, Tailwind CSS v3.4.17, GSAP 3 (with ScrollTrigger plugin), Lucide React for icons.
|
||||||
|
- **Fonts:** Load via Google Fonts `<link>` tags in `index.html` based on the selected preset.
|
||||||
|
- **Images:** Use real Unsplash URLs. Select images matching the preset's `imageMood`. Never use placeholder URLs.
|
||||||
|
- **File structure:** Single `App.jsx` with components defined in the same file (or split into `components/` if >600 lines). Single `index.css` for Tailwind directives + noise overlay + custom utilities.
|
||||||
|
- **No placeholders.** Every card, every label, every animation must be fully implemented and functional.
|
||||||
|
- **Responsive:** Mobile-first. Stack cards vertically on mobile. Reduce hero font sizes. Collapse navbar into a minimal version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
After receiving answers to the 4 questions:
|
||||||
|
|
||||||
|
1. Map the selected preset to its full design tokens (palette, fonts, image mood, identity).
|
||||||
|
2. Generate hero copy using the brand name + purpose + preset's hero line pattern.
|
||||||
|
3. Map the 3 value props to the 3 Feature card patterns (Shuffler, Typewriter, Scheduler).
|
||||||
|
4. Generate Philosophy section contrast statements from the brand purpose.
|
||||||
|
5. Generate Protocol steps from the brand's process/methodology.
|
||||||
|
6. Scaffold the project: `npm create vite@latest`, install deps, write all files.
|
||||||
|
7. Ensure every animation is wired, every interaction works, every image loads.
|
||||||
|
|
||||||
|
**Execution Directive:** "Do not build a website; build a digital instrument. Every scroll should feel intentional, every animation should feel weighted and professional. Eradicate all generic AI patterns."
|
||||||
74
.ai/skills/laravel-bento-saas-builder/SKILL.md
Normal file
74
.ai/skills/laravel-bento-saas-builder/SKILL.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
---
|
||||||
|
name: laravel-bento-saas-builder
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the Laravel Boost tech stack (Laravel 12, Inertia.js v2, React, Tailwind).
|
||||||
|
---
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's ecosystem conventions.
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Build.
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
1. **"What is the SaaS product name and one-line elevator pitch?"**
|
||||||
|
2. **"Is your primary audience Developers or Marketers/Creators?"** (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
3. **"Pick an aesthetic preset:"** "DevTool Dark" (Neon/Dark Mode) or "Bento Light" (Clean/Playful Light Mode).
|
||||||
|
4. **"What are 4 key features we can put into an asymmetric Bento Grid?"**
|
||||||
|
5. **"What is the primary CTA?"**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Component Lifecycles:** Use `framer-motion` for all layout animations. Scroll reveals must use `whileInView={{ opacity: 1, y: 0 }}`. Stagger children components using `transition={{ staggerChildren: 0.1 }}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the following stack and structure. Do not create a standalone Vite React app.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Inertia.js v2, React, Tailwind CSS, Framer Motion, Lucide React.
|
||||||
|
- **Routing:** Define the landing page route in `routes/web.php` using `Route::get('/', function () { return Inertia::render('Welcome'); });`.
|
||||||
|
- **Frontend Directory:** All UI components must be placed in `resources/js/Pages/` (for the main views) and `resources/js/Components/` (for reusable UI parts like the Navbar, BentoGrid, and Footer).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Inertia page.
|
||||||
|
2. Create the main page component at `resources/js/Pages/Welcome.jsx`.
|
||||||
|
3. Scaffold the UI components in `resources/js/Components/` (Hero, BentoGrid, TabSystem, Footer).
|
||||||
|
4. Apply Framer Motion layout animations to the Bento Grid to ensure a premium feel.
|
||||||
|
5. Provide the complete Laravel/Inertia code ready to run.
|
||||||
101
.claude/skills/bento-landing-page-generator/SKILL.md
Normal file
101
.claude/skills/bento-landing-page-generator/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: bento-landing-page-generator
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the native Laravel Boost tech stack (Laravel 12, Livewire 3, Alpine.js, Tailwind, GSAP).
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder (Livewire Edition)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application using the TALL stack. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's native ecosystem conventions.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, you must execute the following steps in exact order:
|
||||||
|
|
||||||
|
1. **Context Initialization (Silent):** Before speaking or generating any code, silently review the global Laravel Boost guidelines present in the workspace (e.g., `.cursorrules`, `.ai/rules`, or `.ai/architecture`). Ensure you understand the specific Laravel configuration for this project.
|
||||||
|
2. **Gather Requirements:** Immediately ask **exactly these questions** using AskUserQuestion in a single call. Do not ask follow-ups.
|
||||||
|
* "What is the SaaS product name and one-line elevator pitch?" (Example: "imail — High-speed ephemeral email API for developers.")
|
||||||
|
* "Is your primary audience Developers or Marketers/Creators?" (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
* "Pick an aesthetic preset: 'DevTool Dark' or 'Bento Light'."
|
||||||
|
* "What are 4 key features we can put into an asymmetric Bento Grid?"
|
||||||
|
* "What is the primary CTA?"
|
||||||
|
3. **Execution:** Build the full site based strictly on the user's answers, the chosen aesthetic preset, and the global Laravel Boost guidelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions & State
|
||||||
|
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Snappy UI (Alpine.js):** All immediate user interactions (tab switching, modal toggles, hover states) MUST be handled client-side using Alpine.js (`x-data`, `x-on:click`, `x-transition`). The UI must never wait for a server round-trip to update visually.
|
||||||
|
- **Cinematic Animations (GSAP):** Use GSAP 3 initialized inside Alpine's `x-init` hook for heavy scroll reveals and timelines (e.g., `x-init="gsap.from($el, { opacity: 0, y: 50, scrollTrigger: $el })"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the native TALL stack. Do not use React, Vue, or Inertia.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Livewire 3, Alpine.js, Tailwind CSS v3.4+, GSAP 3 (with ScrollTrigger), Blade Icons (Lucide).
|
||||||
|
- **Routing:** Define the route in `routes/web.php` pointing to a full-page Livewire component (e.g., `Route::get('/', App\Livewire\LandingPage::class);`).
|
||||||
|
- **Frontend Directory:** - Main page: `app/Livewire/LandingPage.php` and `resources/views/livewire/landing-page.blade.php`.
|
||||||
|
- Reusable anonymous Blade components (Bento cards, Buttons) must go in `resources/views/components/`.
|
||||||
|
- **Livewire Background Syncing:** For state that needs to persist or trigger backend logic (like capturing an email for a waitlist or logging an interaction), use Alpine's `$wire` object to make background calls without interrupting the user's flow (e.g., `@click="$wire.submitEmail(email)"` or using `@entangle`).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Livewire page component.
|
||||||
|
2. Create the Livewire class (`app/Livewire/LandingPage.php`) and its corresponding Blade view.
|
||||||
|
3. Scaffold the UI components in `resources/views/components/` (Hero, BentoGrid, TabSystem, Footer) using anonymous Blade components.
|
||||||
|
4. Wire up Alpine.js for instant UI state changes, ensuring `$wire` is used only for necessary background data syncing.
|
||||||
|
5. Apply GSAP scroll animations via Alpine's `x-init` to ensure a premium, cinematic feel.
|
||||||
|
6. Provide the complete Laravel/Livewire code ready to run.
|
||||||
202
.claude/skills/cinematic-landing-page-builder/SKILL.md
Normal file
202
.claude/skills/cinematic-landing-page-builder/SKILL.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
name: cinematic-landing-page-builder
|
||||||
|
description: Act as a World-Class Senior Creative Technologist to build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Enforces a strict design system, micro-interactions, and GSAP animations.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Cinematic Landing Page Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a World-Class Senior Creative Technologist and Lead Frontend Engineer. You build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Every site you produce should feel like a digital instrument — every scroll intentional, every animation weighted and professional. Eradicate all generic AI patterns.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a site (or this file is loaded into a fresh project), immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Do not over-discuss. Build.
|
||||||
|
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
|
||||||
|
1. **"What's the brand name and one-line purpose?"** — Free text. Example: "Nura Health — precision longevity medicine powered by biological data."
|
||||||
|
2. **"Pick an aesthetic direction"** — Single-select from the presets below. Each preset ships a full design system (palette, typography, image mood, identity label).
|
||||||
|
3. **"What are your 3 key value propositions?"** — Free text. Brief phrases. These become the Features section cards.
|
||||||
|
4. **"What should visitors do?"** — Free text. The primary CTA. Example: "Join the waitlist", "Book a consultation", "Start free trial".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
Each preset defines: `palette`, `typography`, `identity` (the overall feel), and `imageMood` (Unsplash search keywords for hero/texture images).
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "Organic Tech" (Clinical Boutique)
|
||||||
|
|
||||||
|
- **Identity:** A bridge between a biological research lab and an avant-garde luxury magazine.
|
||||||
|
- **Palette:** Moss `#2E4036` (Primary), Clay `#CC5833` (Accent), Cream `#F2F0E9` (Background), Charcoal `#1A1A1A` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Plus Jakarta Sans" + "Outfit" (tight tracking). Drama: "Cormorant Garamond" Italic. Data: `"IBM Plex Mono"`.
|
||||||
|
- **Image Mood:** dark forest, organic textures, moss, ferns, laboratory glassware.
|
||||||
|
- **Hero line pattern:** "[Concept noun] is the" (Bold Sans) / "[Power word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Midnight Luxe" (Dark Editorial)
|
||||||
|
|
||||||
|
- **Identity:** A private members' club meets a high-end watchmaker's atelier.
|
||||||
|
- **Palette:** Obsidian `#0D0D12` (Primary), Champagne `#C9A84C` (Accent), Ivory `#FAF8F5` (Background), Slate `#2A2A35` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Inter" (tight tracking). Drama: "Playfair Display" Italic. Data: `"JetBrains Mono"`.
|
||||||
|
- **Image Mood:** dark marble, gold accents, architectural shadows, luxury interiors.
|
||||||
|
- **Hero line pattern:** "[Aspirational noun] meets" (Bold Sans) / "[Precision word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset C — "Brutalist Signal" (Raw Precision)
|
||||||
|
|
||||||
|
- **Identity:** A control room for the future — no decoration, pure information density.
|
||||||
|
- **Palette:** Paper `#E8E4DD` (Primary), Signal Red `#E63B2E` (Accent), Off-white `#F5F3EE` (Background), Black `#111111` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Space Grotesk" (tight tracking). Drama: "DM Serif Display" Italic. Data: `"Space Mono"`.
|
||||||
|
- **Image Mood:** concrete, brutalist architecture, raw materials, industrial.
|
||||||
|
- **Hero line pattern:** "[Direct verb] the" (Bold Sans) / "[System noun]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset D — "Vapor Clinic" (Neon Biotech)
|
||||||
|
|
||||||
|
- **Identity:** A genome sequencing lab inside a Tokyo nightclub.
|
||||||
|
- **Palette:** Deep Void `#0A0A14` (Primary), Plasma `#7B61FF` (Accent), Ghost `#F0EFF4` (Background), Graphite `#18181B` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Sora" (tight tracking). Drama: "Instrument Serif" Italic. Data: `"Fira Code"`.
|
||||||
|
- **Image Mood:** bioluminescence, dark water, neon reflections, microscopy.
|
||||||
|
- **Hero line pattern:** "[Tech noun] beyond" (Bold Sans) / "[Boundary word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
These rules apply to ALL presets. They are what make the output premium.
|
||||||
|
|
||||||
|
|
||||||
|
### Visual Texture
|
||||||
|
|
||||||
|
- Implement a global CSS noise overlay using an inline SVG `<feTurbulence>` filter at **0.05 opacity** to eliminate flat digital gradients.
|
||||||
|
- Use a `rounded-[2rem]` to `rounded-[3rem]` radius system for all containers. No sharp corners anywhere.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
|
||||||
|
- All buttons must have a **"magnetic" feel**: subtle `scale(1.03)` on hover with `cubic-bezier(0.25, 0.46, 0.45, 0.94)`.
|
||||||
|
- Buttons use `overflow-hidden` with a sliding background `<span>` layer for color transitions on hover.
|
||||||
|
- Links and interactive elements get a `translateY(-1px)` lift on hover.
|
||||||
|
|
||||||
|
|
||||||
|
### Animation Lifecycle
|
||||||
|
|
||||||
|
- Use `gsap.context()` within `useEffect` for ALL animations. Return `ctx.revert()` in the cleanup function.
|
||||||
|
- Default easing: `power3.out` for entrances, `power2.inOut` for morphs.
|
||||||
|
- Stagger value: `0.08` for text, `0.15` for cards/containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Component Architecture (NEVER CHANGE STRUCTURE — only adapt content/colors)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### A. NAVBAR — "The Floating Island"
|
||||||
|
|
||||||
|
A `fixed` pill-shaped container, horizontally centered.
|
||||||
|
- **Morphing Logic:** Transparent with light text at hero top. Transitions to `bg-[background]/60 backdrop-blur-xl` with primary-colored text and a subtle `border` when scrolled past the hero. Use `IntersectionObserver` or ScrollTrigger.
|
||||||
|
- Contains: Logo (brand name as text), 3-4 nav links, CTA button (accent color).
|
||||||
|
|
||||||
|
|
||||||
|
### B. HERO SECTION — "The Opening Shot"
|
||||||
|
|
||||||
|
- `100dvh` height. Full-bleed background image (sourced from Unsplash matching preset's `imageMood`) with a heavy **primary-to-black gradient overlay** (`bg-gradient-to-t`).
|
||||||
|
- **Layout:** Content pushed to the **bottom-left third** using flex + padding.
|
||||||
|
- **Typography:** Large scale contrast following the preset's hero line pattern. First part in bold sans heading font. Second part in massive serif italic drama font (3-5x size difference).
|
||||||
|
- **Animation:** GSAP staggered `fade-up` (y: 40 → 0, opacity: 0 → 1) for all text parts and CTA.
|
||||||
|
- CTA button below the headline, using the accent color.
|
||||||
|
|
||||||
|
|
||||||
|
### C. FEATURES — "Interactive Functional Artifacts"
|
||||||
|
|
||||||
|
Three cards derived from the user's 3 value propositions. These must feel like **functional software micro-UIs**, not static marketing cards. Each card gets one of these interaction patterns:
|
||||||
|
|
||||||
|
**Card 1 — "Diagnostic Shuffler":** 3 overlapping cards that cycle vertically using `array.unshift(array.pop())` logic every 3 seconds with a spring-bounce transition (`cubic-bezier(0.34, 1.56, 0.64, 1)`). Labels derived from user's first value prop (generate 3 sub-labels).
|
||||||
|
|
||||||
|
**Card 2 — "Telemetry Typewriter":** A monospace live-text feed that types out messages character-by-character related to the user's second value prop, with a blinking accent-colored cursor. Include a "Live Feed" label with a pulsing dot.
|
||||||
|
|
||||||
|
**Card 3 — "Cursor Protocol Scheduler":** A weekly grid (S M T W T F S) where an animated SVG cursor enters, moves to a day cell, clicks (visual `scale(0.95)` press), activates the day (accent highlight), then moves to a "Save" button before fading out. Labels from user's third value prop.
|
||||||
|
|
||||||
|
All cards: `bg-[background]` surface, subtle border, `rounded-[2rem]`, drop shadow. Each card has a heading (sans bold) and a brief descriptor.
|
||||||
|
|
||||||
|
|
||||||
|
### D. PHILOSOPHY — "The Manifesto"
|
||||||
|
|
||||||
|
- Full-width section with the **dark color** as background.
|
||||||
|
- A parallaxing organic texture image (Unsplash, `imageMood` keywords) at low opacity behind the text.
|
||||||
|
- **Typography:** Two contrasting statements. Pattern:
|
||||||
|
- "Most [industry] focuses on: [common approach]." — neutral, smaller.
|
||||||
|
- "We focus on: [differentiated approach]." — massive, drama serif italic, accent-colored keyword.
|
||||||
|
- **Animation:** GSAP `SplitText`-style reveal (word-by-word or line-by-line fade-up) triggered by ScrollTrigger.
|
||||||
|
|
||||||
|
|
||||||
|
### E. PROTOCOL — "Sticky Stacking Archive"
|
||||||
|
|
||||||
|
3 full-screen cards that stack on scroll.
|
||||||
|
- **Stacking Interaction:** Using GSAP ScrollTrigger with `pin: true`. As a new card scrolls into view, the card underneath scales to `0.9`, blurs to `20px`, and fades to `0.5`.
|
||||||
|
- **Each card gets a unique canvas/SVG animation:**
|
||||||
|
1. A slowly rotating geometric motif (double-helix, concentric circles, or gear teeth).
|
||||||
|
2. A scanning horizontal laser-line moving across a grid of dots/cells.
|
||||||
|
3. A pulsing waveform (EKG-style SVG path animation using `stroke-dashoffset`).
|
||||||
|
- Card content: Step number (monospace), title (heading font), 2-line description. Derive from user's brand purpose.
|
||||||
|
|
||||||
|
|
||||||
|
### F. MEMBERSHIP / PRICING
|
||||||
|
|
||||||
|
- Three-tier pricing grid. Card names: "Essential", "Performance", "Enterprise" (adjust to fit brand).
|
||||||
|
- **Middle card pops:** Primary-colored background with an accent CTA button. Slightly larger scale or `ring` border.
|
||||||
|
- If pricing doesn't apply, convert this into a "Get Started" section with a single large CTA.
|
||||||
|
|
||||||
|
|
||||||
|
### G. FOOTER
|
||||||
|
|
||||||
|
- Deep dark-colored background, `rounded-t-[4rem]`.
|
||||||
|
- Grid layout: Brand name + tagline, navigation columns, legal links.
|
||||||
|
- **"System Operational" status indicator** with a pulsing green dot and monospace label.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
- **Stack:** React 19, Tailwind CSS v3.4.17, GSAP 3 (with ScrollTrigger plugin), Lucide React for icons.
|
||||||
|
- **Fonts:** Load via Google Fonts `<link>` tags in `index.html` based on the selected preset.
|
||||||
|
- **Images:** Use real Unsplash URLs. Select images matching the preset's `imageMood`. Never use placeholder URLs.
|
||||||
|
- **File structure:** Single `App.jsx` with components defined in the same file (or split into `components/` if >600 lines). Single `index.css` for Tailwind directives + noise overlay + custom utilities.
|
||||||
|
- **No placeholders.** Every card, every label, every animation must be fully implemented and functional.
|
||||||
|
- **Responsive:** Mobile-first. Stack cards vertically on mobile. Reduce hero font sizes. Collapse navbar into a minimal version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 4 questions:
|
||||||
|
|
||||||
|
1. Map the selected preset to its full design tokens (palette, fonts, image mood, identity).
|
||||||
|
2. Generate hero copy using the brand name + purpose + preset's hero line pattern.
|
||||||
|
3. Map the 3 value props to the 3 Feature card patterns (Shuffler, Typewriter, Scheduler).
|
||||||
|
4. Generate Philosophy section contrast statements from the brand purpose.
|
||||||
|
5. Generate Protocol steps from the brand's process/methodology.
|
||||||
|
6. Scaffold the project: `npm create vite@latest`, install deps, write all files.
|
||||||
|
7. Ensure every animation is wired, every interaction works, every image loads.
|
||||||
|
|
||||||
|
**Execution Directive:** "Do not build a website; build a digital instrument. Every scroll should feel intentional, every animation should feel weighted and professional. Eradicate all generic AI patterns."
|
||||||
98
.claude/skills/laravel-bento-saas-builder/SKILL.md
Normal file
98
.claude/skills/laravel-bento-saas-builder/SKILL.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: laravel-bento-saas-builder
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the Laravel Boost tech stack (Laravel 12, Inertia.js v2, React, Tailwind).
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's ecosystem conventions.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Build.
|
||||||
|
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
|
||||||
|
1. **"What is the SaaS product name and one-line elevator pitch?"**
|
||||||
|
2. **"Is your primary audience Developers or Marketers/Creators?"** (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
3. **"Pick an aesthetic preset:"** "DevTool Dark" (Neon/Dark Mode) or "Bento Light" (Clean/Playful Light Mode).
|
||||||
|
4. **"What are 4 key features we can put into an asymmetric Bento Grid?"**
|
||||||
|
5. **"What is the primary CTA?"**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Component Lifecycles:** Use `framer-motion` for all layout animations. Scroll reveals must use `whileInView={{ opacity: 1, y: 0 }}`. Stagger children components using `transition={{ staggerChildren: 0.1 }}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the following stack and structure. Do not create a standalone Vite React app.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Inertia.js v2, React, Tailwind CSS, Framer Motion, Lucide React.
|
||||||
|
- **Routing:** Define the landing page route in `routes/web.php` using `Route::get('/', function () { return Inertia::render('Welcome'); });`.
|
||||||
|
- **Frontend Directory:** All UI components must be placed in `resources/js/Pages/` (for the main views) and `resources/js/Components/` (for reusable UI parts like the Navbar, BentoGrid, and Footer).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Inertia page.
|
||||||
|
2. Create the main page component at `resources/js/Pages/Welcome.jsx`.
|
||||||
|
3. Scaffold the UI components in `resources/js/Components/` (Hero, BentoGrid, TabSystem, Footer).
|
||||||
|
4. Apply Framer Motion layout animations to the Bento Grid to ensure a premium feel.
|
||||||
|
5. Provide the complete Laravel/Inertia code ready to run.
|
||||||
24
.dockerignore
Normal file
24
.dockerignore
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
.git
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
.agents
|
||||||
|
.ai
|
||||||
|
.claude
|
||||||
|
.gemini
|
||||||
|
.junie
|
||||||
|
node_modules
|
||||||
|
vendor
|
||||||
|
npm-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.md
|
||||||
|
!README.md
|
||||||
|
storage/logs/*
|
||||||
|
storage/framework/cache/*
|
||||||
|
storage/framework/sessions/*
|
||||||
|
storage/framework/views/*
|
||||||
|
tests/
|
||||||
|
phpunit.xml
|
||||||
|
phpunit.result.cache
|
||||||
|
docker-compose.yml
|
||||||
41
.env.example
41
.env.example
@@ -2,7 +2,7 @@ APP_NAME=Laravel
|
|||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_KEY=
|
APP_KEY=
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_URL=http://localhost
|
APP_URL=http://imail.test
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
APP_FALLBACK_LOCALE=en
|
APP_FALLBACK_LOCALE=en
|
||||||
@@ -20,12 +20,12 @@ LOG_STACK=single
|
|||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
LOG_LEVEL=debug
|
||||||
|
|
||||||
DB_CONNECTION=sqlite
|
DB_CONNECTION=mysql
|
||||||
# DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
# DB_PORT=3306
|
DB_PORT=3306
|
||||||
# DB_DATABASE=laravel
|
DB_DATABASE=imail
|
||||||
# DB_USERNAME=root
|
DB_USERNAME=root
|
||||||
# DB_PASSWORD=
|
DB_PASSWORD=
|
||||||
|
|
||||||
SESSION_DRIVER=database
|
SESSION_DRIVER=database
|
||||||
SESSION_LIFETIME=120
|
SESSION_LIFETIME=120
|
||||||
@@ -33,9 +33,9 @@ SESSION_ENCRYPT=false
|
|||||||
SESSION_PATH=/
|
SESSION_PATH=/
|
||||||
SESSION_DOMAIN=null
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
BROADCAST_CONNECTION=log
|
BROADCAST_CONNECTION=reverb
|
||||||
FILESYSTEM_DISK=local
|
FILESYSTEM_DISK=local
|
||||||
QUEUE_CONNECTION=database
|
QUEUE_CONNECTION=redis
|
||||||
|
|
||||||
CACHE_STORE=database
|
CACHE_STORE=database
|
||||||
# CACHE_PREFIX=
|
# CACHE_PREFIX=
|
||||||
@@ -47,10 +47,13 @@ REDIS_HOST=127.0.0.1
|
|||||||
REDIS_PASSWORD=null
|
REDIS_PASSWORD=null
|
||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
MONGODB_URI=mongodb://localhost:27017
|
||||||
|
MONGODB_DATABASE=imail
|
||||||
|
|
||||||
MAIL_MAILER=log
|
MAIL_MAILER=log
|
||||||
MAIL_SCHEME=null
|
MAIL_SCHEME=null
|
||||||
MAIL_HOST=127.0.0.1
|
MAIL_HOST=127.0.0.1
|
||||||
MAIL_PORT=2525
|
MAIL_PORT=1025
|
||||||
MAIL_USERNAME=null
|
MAIL_USERNAME=null
|
||||||
MAIL_PASSWORD=null
|
MAIL_PASSWORD=null
|
||||||
MAIL_FROM_ADDRESS="hello@example.com"
|
MAIL_FROM_ADDRESS="hello@example.com"
|
||||||
@@ -64,6 +67,24 @@ AWS_USE_PATH_STYLE_ENDPOINT=false
|
|||||||
|
|
||||||
VITE_APP_NAME="${APP_NAME}"
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
|
||||||
|
WEBHOOK_SECRET=demo_webhook_secret
|
||||||
|
EMAIL_BODY_TTL_SECONDS=259200
|
||||||
|
|
||||||
|
REVERB_APP_ID=imail-local
|
||||||
|
REVERB_APP_KEY=imail-local-key
|
||||||
|
REVERB_APP_SECRET=imail-local-secret
|
||||||
|
REVERB_HOST=imail.app
|
||||||
|
REVERB_PORT=8080
|
||||||
|
REVERB_SCHEME=https
|
||||||
|
REVERB_TLS_CERT="/path/to/ssl/cert.crt"
|
||||||
|
REVERB_TLS_KEY="/path/to/ssl/cert.key"
|
||||||
|
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||||
|
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||||
|
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||||
|
VITE_REVERB_PATH=""
|
||||||
|
|
||||||
ACTIVITY_LOGGER_ENABLED=true
|
ACTIVITY_LOGGER_ENABLED=true
|
||||||
ACTIVITY_LOGGER_TABLE_NAME=activity_log
|
ACTIVITY_LOGGER_TABLE_NAME=activity_log
|
||||||
|
|
||||||
|
|||||||
64
.env.production.example
Normal file
64
.env.production.example
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
APP_NAME=iMail
|
||||||
|
APP_ENV=production
|
||||||
|
APP_KEY=
|
||||||
|
APP_DEBUG=false
|
||||||
|
APP_URL=https://your-domain.com
|
||||||
|
|
||||||
|
LOG_CHANNEL=stderr
|
||||||
|
LOG_LEVEL=info
|
||||||
|
|
||||||
|
# Database (MariaDB via Dokploy/External)
|
||||||
|
DB_CONNECTION=mariadb
|
||||||
|
DB_HOST=mariadb-host
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=imail
|
||||||
|
DB_USERNAME=root
|
||||||
|
DB_PASSWORD=
|
||||||
|
|
||||||
|
# MongoDB (via Dokploy/External)
|
||||||
|
MONGODB_URI=mongodb://mongodb-host:27017
|
||||||
|
|
||||||
|
# Redis (via Dokploy/External)
|
||||||
|
REDIS_CLIENT=phpredis # Required for Laravel Pulse ingest
|
||||||
|
REDIS_HOST=redis-host
|
||||||
|
REDIS_PASSWORD=null
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
|
CACHE_STORE=redis
|
||||||
|
QUEUE_CONNECTION=redis
|
||||||
|
SESSION_DRIVER=redis
|
||||||
|
SESSION_LIFETIME=120
|
||||||
|
SESSION_ENCRYPT=false
|
||||||
|
SESSION_PATH=/
|
||||||
|
SESSION_DOMAIN=null
|
||||||
|
|
||||||
|
BROADCAST_CONNECTION=reverb
|
||||||
|
|
||||||
|
FILESYSTEM_DISK=s3
|
||||||
|
|
||||||
|
# S3 Compatible Storage (RustFS via Dokploy/External)
|
||||||
|
AWS_ACCESS_KEY_ID=
|
||||||
|
AWS_SECRET_ACCESS_KEY=
|
||||||
|
AWS_DEFAULT_REGION=us-east-1
|
||||||
|
AWS_BUCKET=imail
|
||||||
|
AWS_USE_PATH_STYLE_ENDPOINT=true
|
||||||
|
AWS_ENDPOINT=http://rustfs-host:9000
|
||||||
|
|
||||||
|
# Reverb Configuration
|
||||||
|
REVERB_APP_ID=
|
||||||
|
REVERB_APP_KEY=
|
||||||
|
REVERB_APP_SECRET=
|
||||||
|
REVERB_HOST="your-domain.com"
|
||||||
|
REVERB_PORT=443
|
||||||
|
REVERB_SCHEME=https
|
||||||
|
|
||||||
|
# Pulse Configuration
|
||||||
|
PULSE_INGEST_DRIVER=redis
|
||||||
|
PULSE_REDIS_CONNECTION=default
|
||||||
|
|
||||||
|
VITE_APP_NAME="${APP_NAME}"
|
||||||
|
VITE_REVERB_APP_KEY="${REVERB_APP_KEY}"
|
||||||
|
VITE_REVERB_HOST="${REVERB_HOST}"
|
||||||
|
VITE_REVERB_PORT="${REVERB_PORT}"
|
||||||
|
VITE_REVERB_SCHEME="${REVERB_SCHEME}"
|
||||||
|
VITE_REVERB_PATH="/_ws"
|
||||||
45
.github/workflows/tests.yml
vendored
45
.github/workflows/tests.yml
vendored
@@ -15,6 +15,33 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
environment: Testing
|
environment: Testing
|
||||||
|
|
||||||
|
services:
|
||||||
|
mongo:
|
||||||
|
image: mongo:latest
|
||||||
|
ports:
|
||||||
|
- 27017:27017
|
||||||
|
options: >-
|
||||||
|
--health-cmd="mongosh --eval 'db.runCommand({ ping: 1 })'"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=3
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd="redis-cli ping"
|
||||||
|
--health-interval=10s
|
||||||
|
--health-timeout=5s
|
||||||
|
--health-retries=3
|
||||||
|
|
||||||
|
mailhog:
|
||||||
|
image: mailhog/mailhog
|
||||||
|
ports:
|
||||||
|
- 1025:1025
|
||||||
|
- 8025:8025
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -42,7 +69,11 @@ jobs:
|
|||||||
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
run: composer install --no-interaction --prefer-dist --optimize-autoloader
|
||||||
|
|
||||||
- name: Copy Environment File
|
- name: Copy Environment File
|
||||||
run: cp .env.example .env
|
run: |
|
||||||
|
cp .env.example .env
|
||||||
|
sed -i 's/MONGODB_URI=mongodb:\/\/localhost:27017/MONGODB_URI=mongodb:\/\/127.0.0.1:27017/' .env
|
||||||
|
sed -i 's/DB_CONNECTION=mysql/DB_CONNECTION=sqlite/' .env
|
||||||
|
sed -i 's/MAIL_MAILER=log/MAIL_MAILER=smtp/' .env
|
||||||
|
|
||||||
- name: Generate Application Key
|
- name: Generate Application Key
|
||||||
run: php artisan key:generate
|
run: php artisan key:generate
|
||||||
@@ -50,5 +81,17 @@ jobs:
|
|||||||
- name: Build Assets
|
- name: Build Assets
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Wait for services
|
||||||
|
run: |
|
||||||
|
until nc -z 127.0.0.1 27017; do echo "Waiting for MongoDB..."; sleep 2; done
|
||||||
|
until nc -z 127.0.0.1 6379; do echo "Waiting for Redis..."; sleep 2; done
|
||||||
|
until nc -z 127.0.0.1 1025; do echo "Waiting for MailHog..."; sleep 2; done
|
||||||
|
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
run: ./vendor/bin/pest
|
run: ./vendor/bin/pest
|
||||||
|
env:
|
||||||
|
MAIL_HOST: 127.0.0.1
|
||||||
|
MAIL_PORT: 1025
|
||||||
|
REDIS_HOST: 127.0.0.1
|
||||||
|
REDIS_PORT: 6379
|
||||||
|
MONGODB_URI: mongodb://127.0.0.1:27017
|
||||||
@@ -1,4 +1,37 @@
|
|||||||
<laravel-boost-guidelines>
|
<laravel-boost-guidelines>
|
||||||
|
=== .ai/design-system rules ===
|
||||||
|
|
||||||
|
# Design System (CRITICAL — Auto-Read Required)
|
||||||
|
|
||||||
|
|
||||||
|
This project has a comprehensive Design System documented in `DESIGN.md` at the project root.
|
||||||
|
|
||||||
|
|
||||||
|
## Before Any UI/UX Work
|
||||||
|
|
||||||
|
|
||||||
|
**BEFORE** making ANY frontend, UI, UX, styling, component, animation, Blade template, or Tailwind-related change, you MUST read `DESIGN.md` using your file reading tool to understand the established:
|
||||||
|
- Design tokens and color system
|
||||||
|
- Typography scale and font conventions
|
||||||
|
- Component patterns (toasts, modals, buttons, indicators)
|
||||||
|
- Spacing constants and layout architecture
|
||||||
|
- Animation and transition conventions
|
||||||
|
- Responsive breakpoint rules
|
||||||
|
|
||||||
|
This applies to: Blade views, CSS files, Alpine.js interactions, GSAP animations, Livewire component UI, toast notifications, modals, buttons, indicators, and any other visual element.
|
||||||
|
|
||||||
|
|
||||||
|
## After Completing UI/UX Work
|
||||||
|
|
||||||
|
|
||||||
|
**AFTER** completing any UI/UX change, you MUST update `DESIGN.md` per its Section 15 (Maintenance Protocol):
|
||||||
|
- Add a new entry to Section 14 (Iteration History) using the provided template.
|
||||||
|
- Update Section 9 (Component Library) if a component was created or modified.
|
||||||
|
- Update Sections 5/6/7 if design tokens, fonts, or colors were added/changed.
|
||||||
|
- Bump the version number and "Last updated" date at the top of the file.
|
||||||
|
|
||||||
|
Failure to read and follow `DESIGN.md` will result in inconsistent UI, visual regressions, and wasted iteration cycles.
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
@@ -13,8 +46,11 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- filament/filament (FILAMENT) - v4
|
- filament/filament (FILAMENT) - v4
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/horizon (HORIZON) - v5
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/pulse (PULSE) - v1
|
||||||
|
- laravel/reverb (REVERB) - v1
|
||||||
- livewire/flux (FLUXUI_FREE) - v2
|
- livewire/flux (FLUXUI_FREE) - v2
|
||||||
- livewire/livewire (LIVEWIRE) - v3
|
- livewire/livewire (LIVEWIRE) - v3
|
||||||
- livewire/volt (VOLT) - v1
|
- livewire/volt (VOLT) - v1
|
||||||
@@ -26,6 +62,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
- rector/rector (RECTOR) - v2
|
- rector/rector (RECTOR) - v2
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
- laravel-echo (ECHO) - v2
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
@@ -37,6 +74,9 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||||
- `filament-db-config` — Creates database-backed settings pages and config pages with filament-db-config or db-config package. Activates when creating settings page, config page, configuration page, or when user mentions db-config, db_config, DbConfig, database settings, dynamic configuration, runtime config, storing settings in database. ALWAYS use php artisan make:db-config command to scaffold. NEVER create files manually. NEVER create tests.
|
- `filament-db-config` — Creates database-backed settings pages and config pages with filament-db-config or db-config package. Activates when creating settings page, config page, configuration page, or when user mentions db-config, db_config, DbConfig, database settings, dynamic configuration, runtime config, storing settings in database. ALWAYS use php artisan make:db-config command to scaffold. NEVER create files manually. NEVER create tests.
|
||||||
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
|
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
|
||||||
|
- `bento-landing-page-generator` — Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the native Laravel Boost tech stack (Laravel 12, Livewire 3, Alpine.js, Tailwind, GSAP).
|
||||||
|
- `cinematic-landing-page-builder` — Act as a World-Class Senior Creative Technologist to build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Enforces a strict design system, micro-interactions, and GSAP animations.
|
||||||
|
- `laravel-bento-saas-builder` — Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the Laravel Boost tech stack (Laravel 12, Inertia.js v2, React, Tailwind).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
101
.junie/skills/bento-landing-page-generator/SKILL.md
Normal file
101
.junie/skills/bento-landing-page-generator/SKILL.md
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
---
|
||||||
|
name: bento-landing-page-generator
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the native Laravel Boost tech stack (Laravel 12, Livewire 3, Alpine.js, Tailwind, GSAP).
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder (Livewire Edition)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application using the TALL stack. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's native ecosystem conventions.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, you must execute the following steps in exact order:
|
||||||
|
|
||||||
|
1. **Context Initialization (Silent):** Before speaking or generating any code, silently review the global Laravel Boost guidelines present in the workspace (e.g., `.cursorrules`, `.ai/rules`, or `.ai/architecture`). Ensure you understand the specific Laravel configuration for this project.
|
||||||
|
2. **Gather Requirements:** Immediately ask **exactly these questions** using AskUserQuestion in a single call. Do not ask follow-ups.
|
||||||
|
* "What is the SaaS product name and one-line elevator pitch?" (Example: "imail — High-speed ephemeral email API for developers.")
|
||||||
|
* "Is your primary audience Developers or Marketers/Creators?" (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
* "Pick an aesthetic preset: 'DevTool Dark' or 'Bento Light'."
|
||||||
|
* "What are 4 key features we can put into an asymmetric Bento Grid?"
|
||||||
|
* "What is the primary CTA?"
|
||||||
|
3. **Execution:** Build the full site based strictly on the user's answers, the chosen aesthetic preset, and the global Laravel Boost guidelines.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions & State
|
||||||
|
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Snappy UI (Alpine.js):** All immediate user interactions (tab switching, modal toggles, hover states) MUST be handled client-side using Alpine.js (`x-data`, `x-on:click`, `x-transition`). The UI must never wait for a server round-trip to update visually.
|
||||||
|
- **Cinematic Animations (GSAP):** Use GSAP 3 initialized inside Alpine's `x-init` hook for heavy scroll reveals and timelines (e.g., `x-init="gsap.from($el, { opacity: 0, y: 50, scrollTrigger: $el })"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the native TALL stack. Do not use React, Vue, or Inertia.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Livewire 3, Alpine.js, Tailwind CSS v3.4+, GSAP 3 (with ScrollTrigger), Blade Icons (Lucide).
|
||||||
|
- **Routing:** Define the route in `routes/web.php` pointing to a full-page Livewire component (e.g., `Route::get('/', App\Livewire\LandingPage::class);`).
|
||||||
|
- **Frontend Directory:** - Main page: `app/Livewire/LandingPage.php` and `resources/views/livewire/landing-page.blade.php`.
|
||||||
|
- Reusable anonymous Blade components (Bento cards, Buttons) must go in `resources/views/components/`.
|
||||||
|
- **Livewire Background Syncing:** For state that needs to persist or trigger backend logic (like capturing an email for a waitlist or logging an interaction), use Alpine's `$wire` object to make background calls without interrupting the user's flow (e.g., `@click="$wire.submitEmail(email)"` or using `@entangle`).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Livewire page component.
|
||||||
|
2. Create the Livewire class (`app/Livewire/LandingPage.php`) and its corresponding Blade view.
|
||||||
|
3. Scaffold the UI components in `resources/views/components/` (Hero, BentoGrid, TabSystem, Footer) using anonymous Blade components.
|
||||||
|
4. Wire up Alpine.js for instant UI state changes, ensuring `$wire` is used only for necessary background data syncing.
|
||||||
|
5. Apply GSAP scroll animations via Alpine's `x-init` to ensure a premium, cinematic feel.
|
||||||
|
6. Provide the complete Laravel/Livewire code ready to run.
|
||||||
202
.junie/skills/cinematic-landing-page-builder/SKILL.md
Normal file
202
.junie/skills/cinematic-landing-page-builder/SKILL.md
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
---
|
||||||
|
name: cinematic-landing-page-builder
|
||||||
|
description: Act as a World-Class Senior Creative Technologist to build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Enforces a strict design system, micro-interactions, and GSAP animations.
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Cinematic Landing Page Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a World-Class Senior Creative Technologist and Lead Frontend Engineer. You build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Every site you produce should feel like a digital instrument — every scroll intentional, every animation weighted and professional. Eradicate all generic AI patterns.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a site (or this file is loaded into a fresh project), immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Do not over-discuss. Build.
|
||||||
|
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
|
||||||
|
1. **"What's the brand name and one-line purpose?"** — Free text. Example: "Nura Health — precision longevity medicine powered by biological data."
|
||||||
|
2. **"Pick an aesthetic direction"** — Single-select from the presets below. Each preset ships a full design system (palette, typography, image mood, identity label).
|
||||||
|
3. **"What are your 3 key value propositions?"** — Free text. Brief phrases. These become the Features section cards.
|
||||||
|
4. **"What should visitors do?"** — Free text. The primary CTA. Example: "Join the waitlist", "Book a consultation", "Start free trial".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
Each preset defines: `palette`, `typography`, `identity` (the overall feel), and `imageMood` (Unsplash search keywords for hero/texture images).
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "Organic Tech" (Clinical Boutique)
|
||||||
|
|
||||||
|
- **Identity:** A bridge between a biological research lab and an avant-garde luxury magazine.
|
||||||
|
- **Palette:** Moss `#2E4036` (Primary), Clay `#CC5833` (Accent), Cream `#F2F0E9` (Background), Charcoal `#1A1A1A` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Plus Jakarta Sans" + "Outfit" (tight tracking). Drama: "Cormorant Garamond" Italic. Data: `"IBM Plex Mono"`.
|
||||||
|
- **Image Mood:** dark forest, organic textures, moss, ferns, laboratory glassware.
|
||||||
|
- **Hero line pattern:** "[Concept noun] is the" (Bold Sans) / "[Power word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Midnight Luxe" (Dark Editorial)
|
||||||
|
|
||||||
|
- **Identity:** A private members' club meets a high-end watchmaker's atelier.
|
||||||
|
- **Palette:** Obsidian `#0D0D12` (Primary), Champagne `#C9A84C` (Accent), Ivory `#FAF8F5` (Background), Slate `#2A2A35` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Inter" (tight tracking). Drama: "Playfair Display" Italic. Data: `"JetBrains Mono"`.
|
||||||
|
- **Image Mood:** dark marble, gold accents, architectural shadows, luxury interiors.
|
||||||
|
- **Hero line pattern:** "[Aspirational noun] meets" (Bold Sans) / "[Precision word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset C — "Brutalist Signal" (Raw Precision)
|
||||||
|
|
||||||
|
- **Identity:** A control room for the future — no decoration, pure information density.
|
||||||
|
- **Palette:** Paper `#E8E4DD` (Primary), Signal Red `#E63B2E` (Accent), Off-white `#F5F3EE` (Background), Black `#111111` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Space Grotesk" (tight tracking). Drama: "DM Serif Display" Italic. Data: `"Space Mono"`.
|
||||||
|
- **Image Mood:** concrete, brutalist architecture, raw materials, industrial.
|
||||||
|
- **Hero line pattern:** "[Direct verb] the" (Bold Sans) / "[System noun]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
|
||||||
|
### Preset D — "Vapor Clinic" (Neon Biotech)
|
||||||
|
|
||||||
|
- **Identity:** A genome sequencing lab inside a Tokyo nightclub.
|
||||||
|
- **Palette:** Deep Void `#0A0A14` (Primary), Plasma `#7B61FF` (Accent), Ghost `#F0EFF4` (Background), Graphite `#18181B` (Text/Dark)
|
||||||
|
- **Typography:** Headings: "Sora" (tight tracking). Drama: "Instrument Serif" Italic. Data: `"Fira Code"`.
|
||||||
|
- **Image Mood:** bioluminescence, dark water, neon reflections, microscopy.
|
||||||
|
- **Hero line pattern:** "[Tech noun] beyond" (Bold Sans) / "[Boundary word]." (Massive Serif Italic)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
These rules apply to ALL presets. They are what make the output premium.
|
||||||
|
|
||||||
|
|
||||||
|
### Visual Texture
|
||||||
|
|
||||||
|
- Implement a global CSS noise overlay using an inline SVG `<feTurbulence>` filter at **0.05 opacity** to eliminate flat digital gradients.
|
||||||
|
- Use a `rounded-[2rem]` to `rounded-[3rem]` radius system for all containers. No sharp corners anywhere.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
|
||||||
|
- All buttons must have a **"magnetic" feel**: subtle `scale(1.03)` on hover with `cubic-bezier(0.25, 0.46, 0.45, 0.94)`.
|
||||||
|
- Buttons use `overflow-hidden` with a sliding background `<span>` layer for color transitions on hover.
|
||||||
|
- Links and interactive elements get a `translateY(-1px)` lift on hover.
|
||||||
|
|
||||||
|
|
||||||
|
### Animation Lifecycle
|
||||||
|
|
||||||
|
- Use `gsap.context()` within `useEffect` for ALL animations. Return `ctx.revert()` in the cleanup function.
|
||||||
|
- Default easing: `power3.out` for entrances, `power2.inOut` for morphs.
|
||||||
|
- Stagger value: `0.08` for text, `0.15` for cards/containers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Component Architecture (NEVER CHANGE STRUCTURE — only adapt content/colors)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### A. NAVBAR — "The Floating Island"
|
||||||
|
|
||||||
|
A `fixed` pill-shaped container, horizontally centered.
|
||||||
|
- **Morphing Logic:** Transparent with light text at hero top. Transitions to `bg-[background]/60 backdrop-blur-xl` with primary-colored text and a subtle `border` when scrolled past the hero. Use `IntersectionObserver` or ScrollTrigger.
|
||||||
|
- Contains: Logo (brand name as text), 3-4 nav links, CTA button (accent color).
|
||||||
|
|
||||||
|
|
||||||
|
### B. HERO SECTION — "The Opening Shot"
|
||||||
|
|
||||||
|
- `100dvh` height. Full-bleed background image (sourced from Unsplash matching preset's `imageMood`) with a heavy **primary-to-black gradient overlay** (`bg-gradient-to-t`).
|
||||||
|
- **Layout:** Content pushed to the **bottom-left third** using flex + padding.
|
||||||
|
- **Typography:** Large scale contrast following the preset's hero line pattern. First part in bold sans heading font. Second part in massive serif italic drama font (3-5x size difference).
|
||||||
|
- **Animation:** GSAP staggered `fade-up` (y: 40 → 0, opacity: 0 → 1) for all text parts and CTA.
|
||||||
|
- CTA button below the headline, using the accent color.
|
||||||
|
|
||||||
|
|
||||||
|
### C. FEATURES — "Interactive Functional Artifacts"
|
||||||
|
|
||||||
|
Three cards derived from the user's 3 value propositions. These must feel like **functional software micro-UIs**, not static marketing cards. Each card gets one of these interaction patterns:
|
||||||
|
|
||||||
|
**Card 1 — "Diagnostic Shuffler":** 3 overlapping cards that cycle vertically using `array.unshift(array.pop())` logic every 3 seconds with a spring-bounce transition (`cubic-bezier(0.34, 1.56, 0.64, 1)`). Labels derived from user's first value prop (generate 3 sub-labels).
|
||||||
|
|
||||||
|
**Card 2 — "Telemetry Typewriter":** A monospace live-text feed that types out messages character-by-character related to the user's second value prop, with a blinking accent-colored cursor. Include a "Live Feed" label with a pulsing dot.
|
||||||
|
|
||||||
|
**Card 3 — "Cursor Protocol Scheduler":** A weekly grid (S M T W T F S) where an animated SVG cursor enters, moves to a day cell, clicks (visual `scale(0.95)` press), activates the day (accent highlight), then moves to a "Save" button before fading out. Labels from user's third value prop.
|
||||||
|
|
||||||
|
All cards: `bg-[background]` surface, subtle border, `rounded-[2rem]`, drop shadow. Each card has a heading (sans bold) and a brief descriptor.
|
||||||
|
|
||||||
|
|
||||||
|
### D. PHILOSOPHY — "The Manifesto"
|
||||||
|
|
||||||
|
- Full-width section with the **dark color** as background.
|
||||||
|
- A parallaxing organic texture image (Unsplash, `imageMood` keywords) at low opacity behind the text.
|
||||||
|
- **Typography:** Two contrasting statements. Pattern:
|
||||||
|
- "Most [industry] focuses on: [common approach]." — neutral, smaller.
|
||||||
|
- "We focus on: [differentiated approach]." — massive, drama serif italic, accent-colored keyword.
|
||||||
|
- **Animation:** GSAP `SplitText`-style reveal (word-by-word or line-by-line fade-up) triggered by ScrollTrigger.
|
||||||
|
|
||||||
|
|
||||||
|
### E. PROTOCOL — "Sticky Stacking Archive"
|
||||||
|
|
||||||
|
3 full-screen cards that stack on scroll.
|
||||||
|
- **Stacking Interaction:** Using GSAP ScrollTrigger with `pin: true`. As a new card scrolls into view, the card underneath scales to `0.9`, blurs to `20px`, and fades to `0.5`.
|
||||||
|
- **Each card gets a unique canvas/SVG animation:**
|
||||||
|
1. A slowly rotating geometric motif (double-helix, concentric circles, or gear teeth).
|
||||||
|
2. A scanning horizontal laser-line moving across a grid of dots/cells.
|
||||||
|
3. A pulsing waveform (EKG-style SVG path animation using `stroke-dashoffset`).
|
||||||
|
- Card content: Step number (monospace), title (heading font), 2-line description. Derive from user's brand purpose.
|
||||||
|
|
||||||
|
|
||||||
|
### F. MEMBERSHIP / PRICING
|
||||||
|
|
||||||
|
- Three-tier pricing grid. Card names: "Essential", "Performance", "Enterprise" (adjust to fit brand).
|
||||||
|
- **Middle card pops:** Primary-colored background with an accent CTA button. Slightly larger scale or `ring` border.
|
||||||
|
- If pricing doesn't apply, convert this into a "Get Started" section with a single large CTA.
|
||||||
|
|
||||||
|
|
||||||
|
### G. FOOTER
|
||||||
|
|
||||||
|
- Deep dark-colored background, `rounded-t-[4rem]`.
|
||||||
|
- Grid layout: Brand name + tagline, navigation columns, legal links.
|
||||||
|
- **"System Operational" status indicator** with a pulsing green dot and monospace label.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
- **Stack:** React 19, Tailwind CSS v3.4.17, GSAP 3 (with ScrollTrigger plugin), Lucide React for icons.
|
||||||
|
- **Fonts:** Load via Google Fonts `<link>` tags in `index.html` based on the selected preset.
|
||||||
|
- **Images:** Use real Unsplash URLs. Select images matching the preset's `imageMood`. Never use placeholder URLs.
|
||||||
|
- **File structure:** Single `App.jsx` with components defined in the same file (or split into `components/` if >600 lines). Single `index.css` for Tailwind directives + noise overlay + custom utilities.
|
||||||
|
- **No placeholders.** Every card, every label, every animation must be fully implemented and functional.
|
||||||
|
- **Responsive:** Mobile-first. Stack cards vertically on mobile. Reduce hero font sizes. Collapse navbar into a minimal version.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 4 questions:
|
||||||
|
|
||||||
|
1. Map the selected preset to its full design tokens (palette, fonts, image mood, identity).
|
||||||
|
2. Generate hero copy using the brand name + purpose + preset's hero line pattern.
|
||||||
|
3. Map the 3 value props to the 3 Feature card patterns (Shuffler, Typewriter, Scheduler).
|
||||||
|
4. Generate Philosophy section contrast statements from the brand purpose.
|
||||||
|
5. Generate Protocol steps from the brand's process/methodology.
|
||||||
|
6. Scaffold the project: `npm create vite@latest`, install deps, write all files.
|
||||||
|
7. Ensure every animation is wired, every interaction works, every image loads.
|
||||||
|
|
||||||
|
**Execution Directive:** "Do not build a website; build a digital instrument. Every scroll should feel intentional, every animation should feel weighted and professional. Eradicate all generic AI patterns."
|
||||||
98
.junie/skills/laravel-bento-saas-builder/SKILL.md
Normal file
98
.junie/skills/laravel-bento-saas-builder/SKILL.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: laravel-bento-saas-builder
|
||||||
|
description: Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the Laravel Boost tech stack (Laravel 12, Inertia.js v2, React, Tailwind).
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
# Laravel Bento SaaS Builder
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Role
|
||||||
|
|
||||||
|
|
||||||
|
Act as a Senior Full-Stack Laravel Architect and UX Engineer. Your job is to build high-fidelity, production-ready SaaS landing pages integrated directly into a Laravel application. The UI must feature modern "Bento Box" asymmetric grids, code-preview components, glowing border effects, and crisp typography.
|
||||||
|
|
||||||
|
Eradicate all generic AI patterns, placeholder text (`lorem ipsum`), and basic bootstrap-era layouts. You must strictly adhere to Laravel's ecosystem conventions.
|
||||||
|
|
||||||
|
|
||||||
|
## Agent Flow — MUST FOLLOW
|
||||||
|
|
||||||
|
|
||||||
|
When the user asks to build a SaaS site, immediately ask **exactly these questions** using AskUserQuestion in a single call, then build the full site from the answers. Do not ask follow-ups. Build.
|
||||||
|
|
||||||
|
|
||||||
|
### Questions (all in one AskUserQuestion call)
|
||||||
|
|
||||||
|
|
||||||
|
1. **"What is the SaaS product name and one-line elevator pitch?"**
|
||||||
|
2. **"Is your primary audience Developers or Marketers/Creators?"** (Determines if we prioritize Code Snippets or Visual Dashboards).
|
||||||
|
3. **"Pick an aesthetic preset:"** "DevTool Dark" (Neon/Dark Mode) or "Bento Light" (Clean/Playful Light Mode).
|
||||||
|
4. **"What are 4 key features we can put into an asymmetric Bento Grid?"**
|
||||||
|
5. **"What is the primary CTA?"**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Aesthetic Presets
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### Preset A — "DevTool Dark" (Inspired by Appwrite / Vercel)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#09090B`, Surface: `#18181B`, Borders: `#27272A`, Accent: `#EC4899` or `#10B981`. Text: `#FAFAFA`.
|
||||||
|
- **Typography:** Headings & Body: `Inter` or `Geist`. Code/Badges: `JetBrains Mono`.
|
||||||
|
- **UI Vibes:** Subtle radial gradients behind hero text, glowing borders on hover, terminal-style windows.
|
||||||
|
|
||||||
|
|
||||||
|
### Preset B — "Bento Light" (Inspired by BentoNow / Stripe)
|
||||||
|
|
||||||
|
- **Palette:** Background: `#FAFAFA`, Surface: `#FFFFFF`, Borders: `#E4E4E7`, Accent: `#6366F1` or `#F43F5E`. Text: `#18181B`.
|
||||||
|
- **Typography:** Headings: `Plus Jakarta Sans`. Body: `Inter`. Code: `Roboto Mono`.
|
||||||
|
- **UI Vibes:** Soft, diffuse drop shadows (`shadow-xl shadow-zinc-200/50`), pill-shaped tags, rounded geometric icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Fixed Design System (NEVER CHANGE)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### The "Bento" Rules
|
||||||
|
|
||||||
|
- Use asymmetric CSS Grids (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have distinct spans (e.g., `col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards must use `rounded-2xl` or `rounded-3xl` radii.
|
||||||
|
|
||||||
|
|
||||||
|
### Micro-Interactions
|
||||||
|
|
||||||
|
- **Glassmorphism:** Use `bg-white/5 backdrop-blur-md` (Dark) or `bg-white/60 backdrop-blur-md` (Light) for sticky navbars.
|
||||||
|
- **Component Lifecycles:** Use `framer-motion` for all layout animations. Scroll reveals must use `whileInView={{ opacity: 1, y: 0 }}`. Stagger children components using `transition={{ staggerChildren: 0.1 }}`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Technical Requirements & Laravel Architecture (STRICTLY ENFORCED)
|
||||||
|
|
||||||
|
|
||||||
|
You are building within a Laravel Boost environment. You MUST use the following stack and structure. Do not create a standalone Vite React app.
|
||||||
|
|
||||||
|
- **Stack:** Laravel 12, Inertia.js v2, React, Tailwind CSS, Framer Motion, Lucide React.
|
||||||
|
- **Routing:** Define the landing page route in `routes/web.php` using `Route::get('/', function () { return Inertia::render('Welcome'); });`.
|
||||||
|
- **Frontend Directory:** All UI components must be placed in `resources/js/Pages/` (for the main views) and `resources/js/Components/` (for reusable UI parts like the Navbar, BentoGrid, and Footer).
|
||||||
|
- **Code Blocks:** For developer audiences, write realistic, syntax-highlighted code using Tailwind text colors.
|
||||||
|
- **No external images:** Build all visual elements, dashboards, and graphs using HTML `div`s, Tailwind utility classes, and SVG icons.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
||||||
|
## Build Sequence
|
||||||
|
|
||||||
|
|
||||||
|
After receiving answers to the 5 questions:
|
||||||
|
1. Update `routes/web.php` to serve the new Inertia page.
|
||||||
|
2. Create the main page component at `resources/js/Pages/Welcome.jsx`.
|
||||||
|
3. Scaffold the UI components in `resources/js/Components/` (Hero, BentoGrid, TabSystem, Footer).
|
||||||
|
4. Apply Framer Motion layout animations to the Bento Grid to ensure a premium feel.
|
||||||
|
5. Provide the complete Laravel/Inertia code ready to run.
|
||||||
40
CLAUDE.md
40
CLAUDE.md
@@ -1,4 +1,37 @@
|
|||||||
<laravel-boost-guidelines>
|
<laravel-boost-guidelines>
|
||||||
|
=== .ai/design-system rules ===
|
||||||
|
|
||||||
|
# Design System (CRITICAL — Auto-Read Required)
|
||||||
|
|
||||||
|
|
||||||
|
This project has a comprehensive Design System documented in `DESIGN.md` at the project root.
|
||||||
|
|
||||||
|
|
||||||
|
## Before Any UI/UX Work
|
||||||
|
|
||||||
|
|
||||||
|
**BEFORE** making ANY frontend, UI, UX, styling, component, animation, Blade template, or Tailwind-related change, you MUST read `DESIGN.md` using your file reading tool to understand the established:
|
||||||
|
- Design tokens and color system
|
||||||
|
- Typography scale and font conventions
|
||||||
|
- Component patterns (toasts, modals, buttons, indicators)
|
||||||
|
- Spacing constants and layout architecture
|
||||||
|
- Animation and transition conventions
|
||||||
|
- Responsive breakpoint rules
|
||||||
|
|
||||||
|
This applies to: Blade views, CSS files, Alpine.js interactions, GSAP animations, Livewire component UI, toast notifications, modals, buttons, indicators, and any other visual element.
|
||||||
|
|
||||||
|
|
||||||
|
## After Completing UI/UX Work
|
||||||
|
|
||||||
|
|
||||||
|
**AFTER** completing any UI/UX change, you MUST update `DESIGN.md` per its Section 15 (Maintenance Protocol):
|
||||||
|
- Add a new entry to Section 14 (Iteration History) using the provided template.
|
||||||
|
- Update Section 9 (Component Library) if a component was created or modified.
|
||||||
|
- Update Sections 5/6/7 if design tokens, fonts, or colors were added/changed.
|
||||||
|
- Bump the version number and "Last updated" date at the top of the file.
|
||||||
|
|
||||||
|
Failure to read and follow `DESIGN.md` will result in inconsistent UI, visual regressions, and wasted iteration cycles.
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
@@ -13,8 +46,11 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- filament/filament (FILAMENT) - v4
|
- filament/filament (FILAMENT) - v4
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/horizon (HORIZON) - v5
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/pulse (PULSE) - v1
|
||||||
|
- laravel/reverb (REVERB) - v1
|
||||||
- livewire/flux (FLUXUI_FREE) - v2
|
- livewire/flux (FLUXUI_FREE) - v2
|
||||||
- livewire/livewire (LIVEWIRE) - v3
|
- livewire/livewire (LIVEWIRE) - v3
|
||||||
- livewire/volt (VOLT) - v1
|
- livewire/volt (VOLT) - v1
|
||||||
@@ -26,6 +62,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
- rector/rector (RECTOR) - v2
|
- rector/rector (RECTOR) - v2
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
- laravel-echo (ECHO) - v2
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
@@ -37,6 +74,9 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||||
- `filament-db-config` — Creates database-backed settings pages and config pages with filament-db-config or db-config package. Activates when creating settings page, config page, configuration page, or when user mentions db-config, db_config, DbConfig, database settings, dynamic configuration, runtime config, storing settings in database. ALWAYS use php artisan make:db-config command to scaffold. NEVER create files manually. NEVER create tests.
|
- `filament-db-config` — Creates database-backed settings pages and config pages with filament-db-config or db-config package. Activates when creating settings page, config page, configuration page, or when user mentions db-config, db_config, DbConfig, database settings, dynamic configuration, runtime config, storing settings in database. ALWAYS use php artisan make:db-config command to scaffold. NEVER create files manually. NEVER create tests.
|
||||||
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
|
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
|
||||||
|
- `bento-landing-page-generator` — Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the native Laravel Boost tech stack (Laravel 12, Livewire 3, Alpine.js, Tailwind, GSAP).
|
||||||
|
- `cinematic-landing-page-builder` — Act as a World-Class Senior Creative Technologist to build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Enforces a strict design system, micro-interactions, and GSAP animations.
|
||||||
|
- `laravel-bento-saas-builder` — Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the Laravel Boost tech stack (Laravel 12, Inertia.js v2, React, Tailwind).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
902
DESIGN.md
Normal file
902
DESIGN.md
Normal file
@@ -0,0 +1,902 @@
|
|||||||
|
# Zemail (iMail) — Design System & UI/UX Guidelines
|
||||||
|
|
||||||
|
> **Version:** 1.1.0 — Last updated: 2026-03-06
|
||||||
|
>
|
||||||
|
> This document is the single source of truth for every visual, interactive, and architectural decision in the Zemail project. Any developer or AI agent working on this codebase **must** read this document before making any frontend changes, and **must** update it after completing any UI/UX-related work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Activated Skills & References](#1-activated-skills--references)
|
||||||
|
2. [Design Philosophy](#2-design-philosophy)
|
||||||
|
3. [Tech Stack](#3-tech-stack)
|
||||||
|
4. [Aesthetic Preset: "DevTool Dark"](#4-aesthetic-preset-devtool-dark)
|
||||||
|
5. [Design Tokens (CSS Custom Properties)](#5-design-tokens-css-custom-properties)
|
||||||
|
6. [Typography](#6-typography)
|
||||||
|
7. [Color System](#7-color-system)
|
||||||
|
8. [Spacing & Layout](#8-spacing--layout)
|
||||||
|
9. [Component Library](#9-component-library)
|
||||||
|
10. [Animation & Micro-Interactions](#10-animation--micro-interactions)
|
||||||
|
11. [Real-Time UI Patterns](#11-real-time-ui-patterns)
|
||||||
|
12. [Responsive Design Rules](#12-responsive-design-rules)
|
||||||
|
13. [Accessibility](#13-accessibility)
|
||||||
|
14. [Iteration History & Design Decisions](#14-iteration-history--design-decisions)
|
||||||
|
15. [Maintenance Protocol (MANDATORY)](#15-maintenance-protocol-mandatory)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Activated Skills & References
|
||||||
|
|
||||||
|
The following `.agents/skills` were activated during the initial design and development of this project. **Any future developer or AI agent must activate the relevant skill before working in that domain.**
|
||||||
|
|
||||||
|
| Skill | Path | When to Activate |
|
||||||
|
|-------|------|------------------|
|
||||||
|
| **`bento-landing-page-generator`** | `.agents/skills/bento-landing-page-generator/SKILL.md` | Building or modifying Bento-style landing pages, hero sections, feature grids. This was the **primary skill** used to establish the entire visual language of this project. |
|
||||||
|
| **`tailwindcss-development`** | `.agents/skills/tailwindcss-development/SKILL.md` | Any styling work. Uses Tailwind CSS **v4** with CSS-first `@theme` configuration. Never use deprecated v3 utilities. |
|
||||||
|
| **`fluxui-development`** | `.agents/skills/fluxui-development/SKILL.md` | Building forms, modals, inputs, or interactive UI components. Uses the **Flux UI Free** edition. |
|
||||||
|
| **`volt-development`** | `.agents/skills/volt-development/SKILL.md` | Creating single-file Livewire components. Check existing Volt components for functional vs. class-based style before creating new ones. |
|
||||||
|
| **`cinematic-landing-page-builder`** | `.agents/skills/cinematic-landing-page-builder/SKILL.md` | Building cinematic, pixel-perfect landing pages with strict design system enforcement, micro-interactions, and GSAP animations. |
|
||||||
|
| **`pest-testing`** | `.agents/skills/pest-testing/SKILL.md` | Writing or modifying tests. Uses Pest 3. |
|
||||||
|
|
||||||
|
### External References Used
|
||||||
|
|
||||||
|
- **Appwrite Dashboard** — Dark UI inspiration for surface colors, card borders, and glow effects.
|
||||||
|
- **Vercel** — Minimal dark theme, clean typography hierarchy, terminal-style code blocks.
|
||||||
|
- **Stripe / BentoNow** — Asymmetric Bento Grid layout patterns, card span ratios.
|
||||||
|
- **Heroicons** — Default icon set. Search at [heroicons.com](https://heroicons.com/). Never guess icon names.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Design Philosophy
|
||||||
|
|
||||||
|
### Core Principles
|
||||||
|
|
||||||
|
1. **Premium-First**: Every element must feel premium and state-of-the-art. No basic MVPs, no generic layouts. The user should be "wowed" at first glance.
|
||||||
|
2. **Dark Mode Native**: The application is dark-mode-only (`class="dark"` on `<html>`). All design tokens, colors, and contrasts are optimized for dark backgrounds.
|
||||||
|
3. **Cinematic Motion**: Interfaces should feel alive. Use GSAP for scroll-driven reveals, Alpine.js for instant state transitions, and CSS `transition-all` for micro-interactions.
|
||||||
|
4. **Information Density**: Mailbox UIs are information-dense by nature. Use compact typography (`text-[10px]`, `text-[11px]`), uppercase tracking (`tracking-widest`, `tracking-[0.2em]`), and monospace fonts for data.
|
||||||
|
5. **No Placeholders**: Never use placeholder images or `lorem ipsum`. Build all visuals with HTML/CSS/SVG. Use `generate_image` tool if real images are needed.
|
||||||
|
6. **Glassmorphism & Glow**: Frosted glass effects (`backdrop-blur-xl`, `bg-white/5`) and colored glows (`shadow-[0_0_30px_rgba(236,72,153,0.3)]`) are signature visual elements.
|
||||||
|
|
||||||
|
### The "Bento Box" Layout Rules
|
||||||
|
|
||||||
|
- Use **asymmetric CSS Grids** (e.g., `grid-cols-1 md:grid-cols-3` or `md:grid-cols-4`).
|
||||||
|
- Cards must have **distinct spans** (`col-span-2`, `row-span-2`) to create a mosaic effect.
|
||||||
|
- All cards use `rounded-2xl` or `rounded-3xl` border radii.
|
||||||
|
- Cards use `bg-zinc-900` surface color with `border border-white/5` borders.
|
||||||
|
- Background glows inside cards use `blur-2xl` with accent color at very low opacity (`bg-pink-500/5`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology | Version |
|
||||||
|
|-------|-----------|---------|
|
||||||
|
| Backend | Laravel | v12 |
|
||||||
|
| Real-Time | Livewire | v3 |
|
||||||
|
| Client-Side Reactivity | Alpine.js | (bundled with Livewire) |
|
||||||
|
| Styling | Tailwind CSS | v4 |
|
||||||
|
| Animations | GSAP | 3.12.5 |
|
||||||
|
| Component Library | Flux UI Free | v2 |
|
||||||
|
| WebSocket | Laravel Reverb | v1 |
|
||||||
|
| Fonts | Google Fonts (via Bunny) | Inter, JetBrains Mono |
|
||||||
|
| Icons | Heroicons (via Flux) | — |
|
||||||
|
| QR Generator | QRious | 4.0.2 |
|
||||||
|
| Build Tool | Vite | v7 |
|
||||||
|
|
||||||
|
### Asset Pipeline
|
||||||
|
|
||||||
|
```
|
||||||
|
resources/css/app.css → Tailwind v4 with @theme design tokens
|
||||||
|
resources/js/app.js → Laravel Echo + Pusher + WebSocket status dispatching
|
||||||
|
resources/views/ → Blade templates, Livewire components, anonymous components
|
||||||
|
```
|
||||||
|
|
||||||
|
All frontend changes require `npm run build` (or `npm run dev` during development) to take effect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Aesthetic Preset: "DevTool Dark"
|
||||||
|
|
||||||
|
This project uses **Preset A — "DevTool Dark"**, inspired by Appwrite and Vercel.
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Background | `#09090B` (`bg-app-bg`) |
|
||||||
|
| Surface | `#18181B` (`bg-app-surface` / `bg-zinc-900`) |
|
||||||
|
| Borders | `#27272A` (`border-app-border` / `border-white/5`) |
|
||||||
|
| Primary Accent | `#EC4899` (Pink) |
|
||||||
|
| Secondary Accent | `#10B981` (Emerald) |
|
||||||
|
| Text Primary | `#FAFAFA` |
|
||||||
|
| Text Secondary | `#737373` (`text-zinc-500`) |
|
||||||
|
| Text Muted | `#525252` (`text-zinc-600`) |
|
||||||
|
| Selection Highlight | `rgba(236, 72, 153, 0.3)` (`selection:bg-[#EC4899]/30`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Design Tokens (CSS Custom Properties)
|
||||||
|
|
||||||
|
All design tokens are defined in `resources/css/app.css` using Tailwind v4's CSS-first `@theme` directive.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@theme {
|
||||||
|
/* Core Application Colors */
|
||||||
|
--color-app-bg: #09090B;
|
||||||
|
--color-app-surface: #18181B;
|
||||||
|
--color-app-border: #27272A;
|
||||||
|
--color-app-red: #EC6A5F; /* macOS-style traffic light red */
|
||||||
|
--color-app-yellow: #F4BF4F; /* macOS-style traffic light yellow */
|
||||||
|
--color-app-green: #61C554; /* macOS-style traffic light green */
|
||||||
|
|
||||||
|
/* Zinc Scale (Dark Mode Neutrals) */
|
||||||
|
--color-zinc-50: #fafafa;
|
||||||
|
--color-zinc-500: #737373;
|
||||||
|
--color-zinc-600: #525252;
|
||||||
|
--color-zinc-800: #262626;
|
||||||
|
--color-zinc-900: #171717;
|
||||||
|
--color-zinc-950: #0a0a0a;
|
||||||
|
|
||||||
|
/* Primary (Purple/Indigo Scale — used for accents) */
|
||||||
|
--color-primary-500: #787ddc;
|
||||||
|
--color-primary-600: #615dce;
|
||||||
|
|
||||||
|
/* App Primary (oklch scale — for gradients and advanced color) */
|
||||||
|
--color-app-primary-500: oklch(0.589 0.137 290.02);
|
||||||
|
--color-app-primary-600: oklch(0.506 0.16 288.92);
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Important Token Rules
|
||||||
|
|
||||||
|
- **Never hardcode colors** that already exist as tokens. Use `bg-app-bg`, `text-zinc-500`, etc.
|
||||||
|
- **Opacity modifiers** use the Tailwind v4 slash syntax: `bg-pink-500/10`, not the deprecated `bg-opacity-*`.
|
||||||
|
- **New tokens** must be added to `@theme {}` in `app.css`, not in a `tailwind.config.js` file (Tailwind v4 is CSS-first).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Typography
|
||||||
|
|
||||||
|
### Font Stack
|
||||||
|
|
||||||
|
| Usage | Font | Weight | Loaded Via |
|
||||||
|
|-------|------|--------|------------|
|
||||||
|
| Body & UI | **Inter** | 400, 500, 600, 700 | Bunny Fonts CDN |
|
||||||
|
| Code, Data, Addresses | **JetBrains Mono** | 400, 500 | Bunny Fonts CDN |
|
||||||
|
|
||||||
|
### Font Loading
|
||||||
|
|
||||||
|
```html
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700|jetbrains-mono:400,500" rel="stylesheet" />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Typography Scale
|
||||||
|
|
||||||
|
| Element | Classes | Example Usage |
|
||||||
|
|---------|---------|---------------|
|
||||||
|
| Page Heading | `text-2xl font-black tracking-tight uppercase italic` | Confirm modal title |
|
||||||
|
| Section Label | `text-[10px] font-bold text-zinc-500 uppercase tracking-widest` | "Active Mailbox", "Your Sessions" |
|
||||||
|
| Category Label | `text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em]` | Sidebar section headers |
|
||||||
|
| Data Display (email) | `text-[11px] font-mono text-white break-all` | Email address display |
|
||||||
|
| Toast Type Label | `text-[10px] font-black uppercase tracking-[0.2em] opacity-40` | Toast "SUCCESS", "INFO" label |
|
||||||
|
| Toast Message | `text-[11px] font-bold tracking-wide whitespace-pre-wrap` | Toast notification body |
|
||||||
|
| Button Text | `text-xs font-bold` | Primary action buttons |
|
||||||
|
| Compact Button Text | `text-[10px] font-black uppercase tracking-[0.2em]` | Modal confirm/cancel buttons |
|
||||||
|
| Timer Display | `text-[10px] font-mono text-pink-500` | Expiration countdown |
|
||||||
|
| Badge/Count | `text-[10px] font-bold px-1.5 py-0.5` | Unread count badge |
|
||||||
|
|
||||||
|
### Typography Rules
|
||||||
|
|
||||||
|
- **No comments in code** unless logic is exceptionally complex. Use PHPDoc blocks instead.
|
||||||
|
- **Uppercase + Wide Tracking** is the signature style for labels and metadata text.
|
||||||
|
- **Monospace (`font-mono`)** is used for any machine-readable data: email addresses, timers, code blocks.
|
||||||
|
- **`break-all`** must be applied to email addresses to prevent overflow on small screens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Color System
|
||||||
|
|
||||||
|
### Semantic Color Mapping
|
||||||
|
|
||||||
|
These semantic colors are used consistently across all components (toasts, modals, buttons, indicators):
|
||||||
|
|
||||||
|
| Semantic | Background | Border | Text | Icon BG | Glow |
|
||||||
|
|----------|-----------|--------|------|---------|------|
|
||||||
|
| **Success** | `bg-emerald-500/10` | `border-emerald-500/20` | `text-emerald-100` | `bg-emerald-500/20 text-emerald-400` | `bg-emerald-500/10` |
|
||||||
|
| **Info** | `bg-blue-500/10` | `border-blue-500/20` | `text-blue-100` | `bg-blue-500/20 text-blue-400` | `bg-blue-400/10` |
|
||||||
|
| **Warning** | `bg-amber-500/10` | `border-amber-500/20` | `text-amber-100` | `bg-amber-500/20 text-amber-400` | `bg-amber-400/10` |
|
||||||
|
| **Danger** | `bg-rose-500/10` | `border-rose-500/20` | `text-rose-100` | `bg-rose-500/20 text-rose-400` | `bg-rose-400/10` |
|
||||||
|
|
||||||
|
### Important Color Rules
|
||||||
|
|
||||||
|
- **Never use generic red/blue/green**. Always use the curated semantic mapping above.
|
||||||
|
- **Pink (`#EC4899` / `pink-500`)** is the project's primary accent. Used for highlights, CTAs, gradients, and the expiration progress bar.
|
||||||
|
- **Emerald (`emerald-500`)** is the secondary accent. Used for success states and the WebSocket connection indicator.
|
||||||
|
- **Rose (`rose-500`)** is used for destructive actions, errors, and the disconnected WebSocket indicator.
|
||||||
|
- The **expiration progress bar** uses a gradient from pink to emerald: `bg-gradient-to-r from-pink-500 to-emerald-500`.
|
||||||
|
- **Toast notification types**: `success`, `info`, `warning`, `danger`. **Never** use `error` — use `danger` instead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Spacing & Layout
|
||||||
|
|
||||||
|
### Global Spacing Constants
|
||||||
|
|
||||||
|
| Context | Value | Tailwind Class |
|
||||||
|
|---------|-------|----------------|
|
||||||
|
| Card Padding | 16px | `p-4` |
|
||||||
|
| Card Border Radius | 16px | `rounded-2xl` |
|
||||||
|
| Modal Border Radius | 32px | `rounded-[32px]` |
|
||||||
|
| Button Border Radius | 16px | `rounded-2xl` |
|
||||||
|
| Icon Container Radius | 12px | `rounded-xl` |
|
||||||
|
| Section Gap | 24px | `space-y-6` |
|
||||||
|
| Inner Element Gap | 8px | `space-y-2` / `gap-2` |
|
||||||
|
| Toast Container Spacing | 12px | `gap-3` |
|
||||||
|
| Screen Edge Margin | 24px | `bottom-6 left-6 right-6` (responsive) |
|
||||||
|
|
||||||
|
### Layout Architecture
|
||||||
|
|
||||||
|
The main mailbox view (`mailbox.blade.php`) uses a **three-panel layout**:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────┬──────────────┬──────────────────────┐
|
||||||
|
│ Sidebar │ Email List │ Email Detail │
|
||||||
|
│ (280px) │ (flex-1) │ (flex-1, lg only) │
|
||||||
|
│ │ │ │
|
||||||
|
│ - Active │ - Search │ - Header │
|
||||||
|
│ Mailbox│ - Email rows │ - Body (iframe) │
|
||||||
|
│ - Create │ - Pagination │ - Attachments │
|
||||||
|
│ - Sessions │ │
|
||||||
|
└──────────┴──────────────┴──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- Sidebar uses `w-[280px]` when open, collapses on smaller screens.
|
||||||
|
- `overflow-hidden` on `<body>` prevents page-level scrolling. Each panel scrolls independently.
|
||||||
|
- Use `scrollbar-hide` utility class (custom CSS) to hide scrollbars in compact panels.
|
||||||
|
|
||||||
|
### Body-Level Styling
|
||||||
|
|
||||||
|
```html
|
||||||
|
<body class="bg-app-bg text-[#FAFAFA] antialiased selection:bg-[#EC4899]/30 h-full overflow-hidden">
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Component Library
|
||||||
|
|
||||||
|
### 9.1 Global Toast Notification System
|
||||||
|
|
||||||
|
**Location:** `resources/views/components/layouts/app.blade.php`
|
||||||
|
**Trigger:** `$this->dispatch('notify', message: '...', type: 'success')` from Livewire, or `addToast('...', 'success')` from Alpine.js.
|
||||||
|
**Event:** `@notify.window` on `<body>`.
|
||||||
|
|
||||||
|
#### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
<body x-data="{ toasts: [], wsConnected: true, addToast(msg, type) {...} }">
|
||||||
|
└── @notify.window="addToast($event.detail.message, $event.detail.type)"
|
||||||
|
└── @ws-status.window="wsConnected = $event.detail.connected"
|
||||||
|
└── <div class="fixed bottom-6 left-6 right-6 sm:left-auto sm:right-6 z-[100]">
|
||||||
|
└── <template x-for="toast in toasts">
|
||||||
|
└── Toast card with icon, type label, message, close button
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Allowed Types
|
||||||
|
|
||||||
|
| Type | Visual | Use Case |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `success` | Emerald green | Copy to clipboard, successful actions |
|
||||||
|
| `info` | Blue | New email received, informational alerts |
|
||||||
|
| `warning` | Amber/Orange | Cooldown period active, soft warnings |
|
||||||
|
| `danger` | Rose red | Address already in use, destructive errors |
|
||||||
|
|
||||||
|
#### Design Specifications
|
||||||
|
|
||||||
|
- **Container:** `fixed bottom-6 left-6 right-6 sm:left-auto sm:right-6 z-[100]`
|
||||||
|
- Mobile: Full-width with 24px margins on both sides.
|
||||||
|
- Desktop: Anchored to bottom-right.
|
||||||
|
- **Toast Card:** `w-full sm:min-w-[320px] p-4 rounded-2xl border backdrop-blur-xl shadow-2xl`
|
||||||
|
- **Auto-dismiss:** 4000ms timeout.
|
||||||
|
- **Enter Animation:** `transition ease-out duration-500` — slides in from right with scale.
|
||||||
|
- **Leave Animation:** `transition ease-in duration-300` — fades out with scale.
|
||||||
|
- **Multiline Support:** `whitespace-pre-wrap` on message div. Use `\n` in PHP strings for line breaks.
|
||||||
|
- **Icon:** 40×40px container (`w-10 h-10 rounded-xl`) with SVG stroke icon.
|
||||||
|
- **Close Button:** `p-1.5 rounded-lg hover:bg-white/5` with X icon.
|
||||||
|
|
||||||
|
#### Toast Message Formatting Examples
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Single-line (copy confirmation)
|
||||||
|
$this->dispatch('notify', message: 'Address copied to clipboard', type: 'success');
|
||||||
|
|
||||||
|
// Multi-line (new email notification)
|
||||||
|
$this->dispatch('notify',
|
||||||
|
message: "Sender: {$sender}\nSubject: {$subject}",
|
||||||
|
type: 'info'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cooldown warning with precise timer
|
||||||
|
$this->dispatch('notify',
|
||||||
|
message: "Address is in cooldown. Try again in {$remaining}.",
|
||||||
|
type: 'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Error (address conflict)
|
||||||
|
$this->dispatch('notify', message: 'Address already in use.', type: 'danger');
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 Confirm Modal
|
||||||
|
|
||||||
|
**Location:** `resources/views/components/bento/confirm-modal.blade.php`
|
||||||
|
**Trigger:** `$dispatch('open-confirm-modal', { title, message, confirmLabel, type, action })`
|
||||||
|
|
||||||
|
#### Design Specifications
|
||||||
|
|
||||||
|
- **Backdrop:** `bg-zinc-950/80 backdrop-blur-xl` — heavy frosted glass overlay.
|
||||||
|
- **Card:** `max-w-[400px] bg-zinc-900 border border-white/10 rounded-[32px] p-8` — super-rounded.
|
||||||
|
- **Glow:** `w-64 h-64 rounded-full blur-[80px] opacity-20` — type-colored radial glow behind the card.
|
||||||
|
- **Icon:** `w-16 h-16 rounded-2xl` — large, prominent, type-colored icon container.
|
||||||
|
- **Title:** `text-2xl font-black text-white tracking-tight uppercase italic` — bold, dramatic.
|
||||||
|
- **Message:** `text-xs font-bold text-zinc-500 uppercase tracking-widest leading-relaxed` — subdued.
|
||||||
|
- **Buttons:** `grid grid-cols-2 gap-4` — two equal-width buttons with `py-4 rounded-2xl`.
|
||||||
|
- **Cancel:** `bg-white/5 border border-white/10 text-zinc-400` — ghost style.
|
||||||
|
- **Confirm:** Solid type-colored background (e.g., `bg-rose-600 text-white` for danger).
|
||||||
|
|
||||||
|
#### Supported Types
|
||||||
|
|
||||||
|
Same four types as the toast system: `danger`, `info`, `warning`, `success`.
|
||||||
|
|
||||||
|
### 9.3 WebSocket Connection Indicator
|
||||||
|
|
||||||
|
**Location:** `resources/views/livewire/mailbox.blade.php` (Active Mailbox card)
|
||||||
|
**State Source:** Global `wsConnected` from `<body x-data>`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full animate-pulse transition-colors duration-500"
|
||||||
|
:class="wsConnected ? 'bg-emerald-500' : 'bg-rose-500'"></div>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Connected:** Emerald green pulsing dot.
|
||||||
|
- **Disconnected:** Rose red pulsing dot.
|
||||||
|
- **Transition:** `duration-500` smooth color transition between states.
|
||||||
|
- The dot sits next to the "ACTIVE MAILBOX" label in the sidebar card.
|
||||||
|
|
||||||
|
### 9.4 Expiration Progress Bar
|
||||||
|
|
||||||
|
**Location:** `resources/views/livewire/mailbox.blade.php`
|
||||||
|
|
||||||
|
#### Design Specifications
|
||||||
|
|
||||||
|
- **Track:** `h-1 bg-white/5 rounded-full overflow-hidden`
|
||||||
|
- **Fill:** `bg-gradient-to-r from-pink-500 to-emerald-500 transition-all duration-1000 ease-linear`
|
||||||
|
- **Timer Text:** `text-[10px] font-mono text-pink-500`
|
||||||
|
- **Label:** `text-[10px] text-zinc-500 uppercase font-black tracking-tighter` — "EXPIRES IN"
|
||||||
|
|
||||||
|
#### Time Display Rules (User Iteration)
|
||||||
|
|
||||||
|
The expiration timer processes remaining time **in seconds** and displays with adaptive precision:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Display hierarchy (most precise possible):
|
||||||
|
if (days > 0) → "14d 23h 59m 59s"
|
||||||
|
if (hours > 0) → "23h 59m 59s"
|
||||||
|
if (mins > 0) → "59m 59s"
|
||||||
|
else → "59s"
|
||||||
|
```
|
||||||
|
|
||||||
|
> **User Iteration Note:** Originally showed "0 minutes" when only seconds remained. Fixed to always show seconds-level precision and cascade upward to days.
|
||||||
|
|
||||||
|
#### Auto-Expiration Behavior
|
||||||
|
|
||||||
|
When the timer reaches zero:
|
||||||
|
1. Alpine.js sets `isExpired = true` and fires `$wire.deleteMailbox(id)`.
|
||||||
|
2. This triggers a soft delete on the Mailbox model.
|
||||||
|
3. The `deleted` Eloquent event fires cleanup (MongoDB email bodies, MariaDB email metadata).
|
||||||
|
4. UI refreshes to show a new auto-generated mailbox.
|
||||||
|
|
||||||
|
### 9.5 Cinematic Creation Overlay
|
||||||
|
|
||||||
|
**Location:** `resources/views/livewire/mailbox.blade.php`
|
||||||
|
|
||||||
|
Shown when a first-time visitor arrives or all mailboxes are deleted:
|
||||||
|
|
||||||
|
- **Backdrop:** `fixed inset-0 z-[200] bg-zinc-950/90` — full-screen dark overlay.
|
||||||
|
- **Logo:** `animate-pulse` with scale transition (`scale-110 opacity-100` → `scale-90 opacity-0`).
|
||||||
|
- **Spinner:** Custom CSS spinner using `border-4 border-pink-500 border-t-transparent animate-[spin_1.5s_cubic-bezier(0.4,0,0.2,1)_infinite]` with pink glow shadow.
|
||||||
|
- **Auto-dismiss:** `setTimeout(() => $wire.finishAutoCreation(), 1000)` — 1 second cinematic delay.
|
||||||
|
|
||||||
|
### 9.6 Action Button Patterns
|
||||||
|
|
||||||
|
#### Icon Buttons (Toolbar Style)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- Standard action button -->
|
||||||
|
<button class="p-1.5 rounded-lg bg-white/5 text-zinc-500 hover:text-white hover:bg-white/10 transition-all cursor-pointer">
|
||||||
|
<svg class="w-3.5 h-3.5" ...></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Destructive action button -->
|
||||||
|
<button class="p-1.5 rounded-lg bg-rose-500/10 text-rose-500/60 hover:text-rose-500 hover:bg-rose-500/20 transition-all cursor-pointer">
|
||||||
|
<svg class="w-3.5 h-3.5" ...></svg>
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Full-Width CTA Button
|
||||||
|
|
||||||
|
```html
|
||||||
|
<button class="w-full py-3 px-4 rounded-2xl bg-white/5 border border-white/10 text-white text-xs font-bold flex items-center justify-center gap-2 hover:bg-white/10 hover:border-pink-500/30 transition-all group cursor-pointer">
|
||||||
|
<div class="w-5 h-5 rounded-lg bg-pink-500/20 text-pink-500 flex items-center justify-center group-hover:scale-110 transition-transform">
|
||||||
|
<svg class="w-3.5 h-3.5" ...></svg>
|
||||||
|
</div>
|
||||||
|
Create New Mailbox
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Key Button Rules
|
||||||
|
|
||||||
|
- All buttons must have `cursor-pointer`.
|
||||||
|
- Icon containers inside buttons use `group-hover:scale-110 transition-transform` for a micro-interaction.
|
||||||
|
- Destructive buttons use the rose color scale at reduced opacity (`rose-500/10`, `rose-500/60`).
|
||||||
|
- Ghost buttons use `bg-white/5 border border-white/10`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Animation & Micro-Interactions
|
||||||
|
|
||||||
|
### GSAP (Heavy Scroll Animations)
|
||||||
|
|
||||||
|
Loaded via CDN in the layout:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
- Used primarily on the **landing page** for cinematic scroll reveals.
|
||||||
|
- Initialize inside Alpine's `x-init` hook: `x-init="gsap.from($el, { opacity: 0, y: 50, scrollTrigger: $el })"`.
|
||||||
|
- GSAP is **not used** in the mailbox application UI — Alpine.js handles all reactive transitions there.
|
||||||
|
|
||||||
|
### Alpine.js (Instant UI State)
|
||||||
|
|
||||||
|
All immediate user interactions must be handled client-side with Alpine.js:
|
||||||
|
|
||||||
|
| Pattern | Usage |
|
||||||
|
|---------|-------|
|
||||||
|
| `x-show` + `x-transition` | Modals, overlays, conditional panels |
|
||||||
|
| `x-data` + `$watch` | Reactive state management (timers, mobile view switching) |
|
||||||
|
| `@click` + `$wire` | Livewire method calls from the client |
|
||||||
|
| `@click` + `$dispatch` | Triggering global events (toasts, modals) |
|
||||||
|
| `@resize.window` | Responsive sidebar behavior |
|
||||||
|
| `:class` bindings | Dynamic styling (WebSocket indicator, toast types) |
|
||||||
|
|
||||||
|
### CSS Transitions
|
||||||
|
|
||||||
|
| Property | Duration | Easing | Usage |
|
||||||
|
|----------|----------|--------|-------|
|
||||||
|
| `transition-all` | default (150ms) | default | Button hover states |
|
||||||
|
| `transition-colors duration-500` | 500ms | default | WebSocket indicator color change |
|
||||||
|
| `transition-all duration-1000 ease-linear` | 1000ms | linear | Progress bar width |
|
||||||
|
| `transition ease-out duration-500` | 500ms | ease-out | Toast enter |
|
||||||
|
| `transition ease-in duration-300` | 300ms | ease-in | Toast leave |
|
||||||
|
| `transition ease-out duration-700` | 700ms | ease-out | Full-screen overlay enter |
|
||||||
|
| `transition ease-in duration-500` | 500ms | ease-in | Full-screen overlay leave |
|
||||||
|
| `transition ease-out duration-500` | 500ms | ease-out | Modal card enter (with scale + translateY) |
|
||||||
|
|
||||||
|
### Custom Keyframes
|
||||||
|
|
||||||
|
```css
|
||||||
|
@keyframes slide-right {
|
||||||
|
from { opacity: 0; transform: translateX(-10px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(20px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-slide-up {
|
||||||
|
animation: slide-up 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Real-Time UI Patterns
|
||||||
|
|
||||||
|
### WebSocket Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
app.js → Laravel Echo (Reverb broadcaster)
|
||||||
|
→ Pusher connection state listeners
|
||||||
|
→ Dispatches window events: 'ws-status'
|
||||||
|
|
||||||
|
app.blade.php → Listens for @ws-status.window
|
||||||
|
→ Updates global Alpine 'wsConnected' state
|
||||||
|
|
||||||
|
mailbox.blade.php → Reads 'wsConnected' for indicator color
|
||||||
|
```
|
||||||
|
|
||||||
|
### Connection Status Lifecycle
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// resources/js/app.js
|
||||||
|
window.Echo.connector.pusher.connection.bind('connected', () => dispatchStatus(true));
|
||||||
|
window.Echo.connector.pusher.connection.bind('unavailable', () => dispatchStatus(false));
|
||||||
|
window.Echo.connector.pusher.connection.bind('disconnected', () => dispatchStatus(false));
|
||||||
|
window.Echo.connector.pusher.connection.bind('failed', () => dispatchStatus(false));
|
||||||
|
```
|
||||||
|
|
||||||
|
### Conditional Echo Initialization
|
||||||
|
|
||||||
|
Echo is only initialized on pages that need it, controlled by a data attribute:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div data-requires-reverb="true"> ... </div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (document.querySelector('[data-requires-reverb]')) {
|
||||||
|
window.Echo = new Echo({ ... });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Email Real-Time Flow
|
||||||
|
|
||||||
|
1. `ProcessIncomingEmail` job saves email to MariaDB + MongoDB.
|
||||||
|
2. `NewEmailReceived` event broadcasts on `mailbox.{domain}` channel.
|
||||||
|
3. Livewire listener `echo:mailbox.{domain},.new.email` fires `onNewEmail()`.
|
||||||
|
4. `onNewEmail()` dispatches a toast notification with sender + subject.
|
||||||
|
5. `onNewEmail()` dispatches `$refresh` to reload the email list.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Responsive Design Rules
|
||||||
|
|
||||||
|
### Breakpoint Strategy
|
||||||
|
|
||||||
|
| Breakpoint | Prefix | Behavior |
|
||||||
|
|-----------|--------|----------|
|
||||||
|
| < 640px | (none) | Mobile: full-width toast, collapsed sidebar, single-panel view |
|
||||||
|
| ≥ 640px | `sm:` | Toast anchors to bottom-right, min-width restored |
|
||||||
|
| ≥ 1024px | `lg:` | Email detail panel becomes visible. Mobile view switcher deactivated |
|
||||||
|
| ≥ 1280px | `xl:` | Sidebar always open |
|
||||||
|
|
||||||
|
### Mobile View Switching
|
||||||
|
|
||||||
|
On mobile (`< 1024px`), the mailbox uses an Alpine-driven view switcher:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
$watch('selectedId', value => { if (value && window.innerWidth < 1024) mobileView = 'detail' });
|
||||||
|
$watch('mobileView', value => { if (value === 'list' && window.innerWidth < 1024) selectedId = null });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toast Responsiveness (User Iteration)
|
||||||
|
|
||||||
|
> **User Iteration Note:** The toast container originally used `fixed bottom-6 right-6` with a hard `min-w-[320px]`, causing it to overflow and touch the left screen edge on small devices. This was fixed by:
|
||||||
|
> - Container: `left-6 right-6 sm:left-auto sm:right-6` — full-width with margins on mobile, bottom-right anchor on desktop.
|
||||||
|
> - Toast card: `w-full sm:min-w-[320px]` — fluid on mobile, minimum width on desktop.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Accessibility
|
||||||
|
|
||||||
|
### Required Patterns
|
||||||
|
|
||||||
|
- All interactive elements must have `cursor-pointer`.
|
||||||
|
- Icon-only buttons must have a `title` attribute for tooltip/screen-reader context.
|
||||||
|
- `antialiased` is applied globally on `<body>` for smoother font rendering.
|
||||||
|
- `selection:bg-[#EC4899]/30` provides a branded text selection color.
|
||||||
|
- Use semantic HTML elements wherever possible (`<nav>`, `<button>`, `<main>`).
|
||||||
|
- All interactive elements should have unique, descriptive IDs for browser testing.
|
||||||
|
|
||||||
|
### Scrollbar Handling
|
||||||
|
|
||||||
|
```css
|
||||||
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
|
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Iteration History & Design Decisions
|
||||||
|
|
||||||
|
This section documents every significant design iteration from the conversation thread, in chronological order. Each entry includes the rationale so future developers understand **why** a decision was made.
|
||||||
|
|
||||||
|
### Iteration 1: Expiration Timer Precision
|
||||||
|
|
||||||
|
**Problem:** When a mailbox had only seconds remaining, the timer displayed "0 minutes" instead of showing seconds.
|
||||||
|
|
||||||
|
**Solution:** Rewrote the Alpine.js timer to process time in seconds and display with cascading precision: `days → hours → minutes → seconds`. The timer now always shows the most granular unit available.
|
||||||
|
|
||||||
|
**Files Changed:** `resources/views/livewire/mailbox.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 2: Soft Deletes Instead of Hard Deletes
|
||||||
|
|
||||||
|
**Problem:** Clicking the delete button permanently removed the mailbox record from the database, losing all audit trail.
|
||||||
|
|
||||||
|
**Solution:** Added `SoftDeletes` trait to the `Mailbox` model. Created a migration to add `deleted_at` timestamps. All delete operations now soft-delete, preserving records for analytics and potential reclamation.
|
||||||
|
|
||||||
|
**Files Changed:** `app/Models/Mailbox.php`, `database/migrations/2026_03_05_202748_add_soft_deletes_to_mailboxes_table.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 3: Mailbox Duplication Prevention & Cooldown System
|
||||||
|
|
||||||
|
**Problem:** Random address generation could produce duplicates. Users could snipe deleted addresses instantly.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- **Random addresses:** `do-while` loop checks `withTrashed()` to ensure uniqueness.
|
||||||
|
- **Same user reclaiming:** Automatically restores soft-deleted mailbox and updates expiration.
|
||||||
|
- **Different user claiming:** Tier-based cooldown enforced (Guest: 24h, Free: 12h, Pro: 6h, Enterprise: instant).
|
||||||
|
- **Cooldown toast:** Shows precise remaining time down to seconds (e.g., "11h 42m 18s").
|
||||||
|
|
||||||
|
**Files Changed:** `app/Livewire/Mailbox.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 4: Toast Notification Type Fix
|
||||||
|
|
||||||
|
**Problem:** Cooldown and error notifications were dispatched with `type: 'error'`, which didn't match the global toast's four supported types (`success`, `info`, `warning`, `danger`). The toast would render without proper styling.
|
||||||
|
|
||||||
|
**Solution:** Changed "address already in use" to `type: 'danger'` (rose red) and cooldown warnings to `type: 'warning'` (amber). **Rule: Never use `'error'` as a toast type.**
|
||||||
|
|
||||||
|
**Files Changed:** `app/Livewire/Mailbox.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 5: Session Linking on Login
|
||||||
|
|
||||||
|
**Problem:** Guest users creating mailboxes via session would lose access when they registered or logged in.
|
||||||
|
|
||||||
|
**Solution:** Added an `Event::listen(Login::class)` in `AppServiceProvider` to automatically transfer mailboxes from `session_id` to `user_id` when a guest authenticates.
|
||||||
|
|
||||||
|
**Files Changed:** `app/Providers/AppServiceProvider.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 6: Automatic Expiration Cleanup
|
||||||
|
|
||||||
|
**Problem:** If a user closed their browser, expired mailboxes would never be cleaned up.
|
||||||
|
|
||||||
|
**Solution:** Dual approach:
|
||||||
|
1. **Live UI Hook:** Alpine.js timer triggers `$wire.deleteMailbox()` when countdown reaches zero.
|
||||||
|
2. **Background Worker:** `CleanupExpiredMailboxes` Artisan command runs every minute via Laravel Scheduler.
|
||||||
|
3. **Data Cleanup:** Eloquent `deleted` event on `Mailbox` model wipes associated `EmailBody` (MongoDB) and `Email` (MariaDB) records.
|
||||||
|
|
||||||
|
**Files Changed:** `app/Console/Commands/CleanupExpiredMailboxes.php`, `routes/console.php`, `app/Models/Mailbox.php`, `resources/views/livewire/mailbox.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 7: WebSocket Status Indicator
|
||||||
|
|
||||||
|
**Problem:** The "Active Mailbox" pulse indicator was always green, regardless of connection state.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Added `connected/disconnected/unavailable/failed` listeners to Echo in `app.js`.
|
||||||
|
2. Stored `wsConnected` in global Alpine state on `<body>`.
|
||||||
|
3. Bound indicator color dynamically: emerald for connected, rose for disconnected.
|
||||||
|
|
||||||
|
**Files Changed:** `resources/js/app.js`, `resources/views/components/layouts/app.blade.php`, `resources/views/livewire/mailbox.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 8: New Email Toast Notification
|
||||||
|
|
||||||
|
**Problem:** Users had no visual cue when a new email arrived while reading another email.
|
||||||
|
|
||||||
|
**Solution:** Updated `onNewEmail()` to dispatch a toast with sender and subject before refreshing the list. Used `type: 'info'` (blue) for the notification.
|
||||||
|
|
||||||
|
**Files Changed:** `app/Livewire/Mailbox.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 9: Multiline Toast Formatting
|
||||||
|
|
||||||
|
**Problem:** Toast notification for new emails displayed sender and subject on a single line, making it hard to scan.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
1. Changed message format to `"Sender: {$sender}\nSubject: {$subject}"` using `\n`.
|
||||||
|
2. Added `whitespace-pre-wrap` to the toast message div to render line breaks.
|
||||||
|
|
||||||
|
**Files Changed:** `app/Livewire/Mailbox.php`, `resources/views/components/layouts/app.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 10: Mobile Toast Overflow Fix
|
||||||
|
|
||||||
|
**Problem:** On small screens (< 320px viewport), the toast's `min-w-[320px]` caused it to overflow beyond the left edge of the screen.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Container: Changed from `fixed bottom-6 right-6` to `fixed bottom-6 left-6 right-6 sm:left-auto sm:right-6`.
|
||||||
|
- Toast card: Changed from `min-w-[320px]` to `w-full sm:min-w-[320px]`.
|
||||||
|
|
||||||
|
**Files Changed:** `resources/views/components/layouts/app.blade.php`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Iteration 11: Random Address Format
|
||||||
|
|
||||||
|
**Problem:** Random mailbox addresses included an underscore before the number suffix (e.g., `john_doe_42@imail.app`).
|
||||||
|
|
||||||
|
**Solution:** User manually removed the underscore from the format string. Addresses now generate as `johndoe42@imail.app`.
|
||||||
|
|
||||||
|
**Files Changed:** `app/Livewire/Mailbox.php` (user edit)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix: File Reference Map
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `resources/css/app.css` | Tailwind v4 theme tokens, Flux UI imports, dark mode config |
|
||||||
|
| `resources/js/app.js` | Laravel Echo init, WebSocket status dispatching |
|
||||||
|
| `resources/views/components/layouts/app.blade.php` | Global layout: fonts, GSAP, global Alpine state, toast system |
|
||||||
|
| `resources/views/livewire/mailbox.blade.php` | Main mailbox UI: sidebar, email list, detail view, modals |
|
||||||
|
| `resources/views/components/bento/confirm-modal.blade.php` | Reusable confirm modal with type-based theming |
|
||||||
|
| `resources/views/livewire/landing-page.blade.php` | Bento-style SaaS landing page |
|
||||||
|
| `app/Livewire/Mailbox.php` | Mailbox Livewire component: CRUD, WebSocket, analytics |
|
||||||
|
| `app/Models/Mailbox.php` | Mailbox Eloquent model: soft deletes, cleanup events |
|
||||||
|
| `app/Events/NewEmailReceived.php` | WebSocket broadcast event for incoming emails |
|
||||||
|
| `app/Console/Commands/CleanupExpiredMailboxes.php` | Scheduled cleanup of expired mailboxes |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **For AI Agents:** Before making any UI change, re-read the relevant sections of this document. Match existing patterns exactly. When in doubt, check sibling components for conventions. Always activate the appropriate skill from `.agents/skills/` before starting work. **After completing any UI/UX change, update this document per Section 15.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 15. Maintenance Protocol (MANDATORY)
|
||||||
|
|
||||||
|
This document is a **living document**. Every developer or AI agent who makes a UI/UX-related change **must** update it before considering their work complete. This ensures no knowledge is lost and prevents future confusion or hallucination.
|
||||||
|
|
||||||
|
### 15.1 When to Update
|
||||||
|
|
||||||
|
| Scenario | What to Update | Priority |
|
||||||
|
|----------|---------------|----------|
|
||||||
|
| New component created | Add to **§9 Component Library** with full spec | 🔴 Required |
|
||||||
|
| Existing component modified | Update the relevant subsection in **§9** | 🔴 Required |
|
||||||
|
| New design token added | Add to **§5 Design Tokens** | 🔴 Required |
|
||||||
|
| Color or typography changed | Update **§6 Typography** or **§7 Color System** | 🔴 Required |
|
||||||
|
| New animation or transition | Add to **§10 Animation** table | 🟡 Recommended |
|
||||||
|
| New real-time event or flow | Add to **§11 Real-Time UI Patterns** | 🔴 Required |
|
||||||
|
| Responsive behavior changed | Update **§12 Responsive Design Rules** | 🔴 Required |
|
||||||
|
| Any iterative design refinement | Add to **§14 Iteration History** | 🔴 Required |
|
||||||
|
| New file created that affects UI | Add to **Appendix: File Reference Map** | 🟡 Recommended |
|
||||||
|
| New external dependency added | Add to **§3 Tech Stack** table | 🔴 Required |
|
||||||
|
| New skill activated | Add to **§1 Activated Skills** table | 🟡 Recommended |
|
||||||
|
|
||||||
|
### 15.2 Iteration Entry Template
|
||||||
|
|
||||||
|
Every design change must be logged in **§14 Iteration History** using this exact format:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Iteration N: [Short Descriptive Title]
|
||||||
|
|
||||||
|
**Date:** YYYY-MM-DD
|
||||||
|
**Conversation/PR:** [Link or ID if available]
|
||||||
|
|
||||||
|
**Problem:** One sentence describing what was wrong or what the user requested.
|
||||||
|
|
||||||
|
**Why:** One sentence explaining *why* this matters (UX impact, visual regression, accessibility, etc.).
|
||||||
|
|
||||||
|
**Solution:** Bullet points detailing what was changed. Include:
|
||||||
|
- Exact Tailwind classes added/removed/changed.
|
||||||
|
- Any new Alpine.js state or events introduced.
|
||||||
|
- Any new Livewire methods or events.
|
||||||
|
|
||||||
|
**Files Changed:** Comma-separated list of relative file paths.
|
||||||
|
|
||||||
|
**Design Rules Established:** (Optional) Any new rules that emerged from this change.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example — Well-Written Entry
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Iteration 12: Attachment Preview Thumbnails
|
||||||
|
|
||||||
|
**Date:** 2026-03-10
|
||||||
|
**Conversation:** c1ba6ccd-8bd9-4670-94e4-50b936242f39
|
||||||
|
|
||||||
|
**Problem:** Attachments in the email detail view showed only filenames with no visual preview.
|
||||||
|
|
||||||
|
**Why:** Users couldn't quickly assess whether an attachment was an image, document, or archive without downloading it.
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
- Added thumbnail preview for image attachments using `<img>` with `w-16 h-16 rounded-lg object-cover`.
|
||||||
|
- Non-image attachments show a colored icon badge: `bg-blue-500/10 text-blue-400` for documents, `bg-amber-500/10 text-amber-400` for archives.
|
||||||
|
- Thumbnails are lazy-loaded with `loading="lazy"` to avoid blocking the main render.
|
||||||
|
|
||||||
|
**Files Changed:** `resources/views/livewire/mailbox.blade.php`
|
||||||
|
|
||||||
|
**Design Rules Established:** All file-type indicators must use the semantic color mapping from §7. Image previews always use `rounded-lg object-cover`.
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Example — Poorly-Written Entry (DO NOT DO THIS)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### Iteration 12: Fixed attachments
|
||||||
|
|
||||||
|
**Problem:** Attachments looked bad.
|
||||||
|
|
||||||
|
**Solution:** Made them look better.
|
||||||
|
|
||||||
|
**Files Changed:** mailbox.blade.php
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ **This lacks specificity.** It doesn't explain *why* it mattered, *what* CSS classes were used, or *what rules* emerged. A future developer reading this would learn nothing and likely re-implement differently.
|
||||||
|
|
||||||
|
### 15.3 Component Documentation Template
|
||||||
|
|
||||||
|
When adding a new component to **§9 Component Library**, use this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
### 9.N [Component Name]
|
||||||
|
|
||||||
|
**Location:** `path/to/file.blade.php`
|
||||||
|
**Trigger:** How to invoke (dispatch event, Livewire property, Alpine method, etc.)
|
||||||
|
**State Source:** Where the component gets its data.
|
||||||
|
|
||||||
|
#### Design Specifications
|
||||||
|
|
||||||
|
- **Container:** Exact Tailwind classes for the outermost wrapper.
|
||||||
|
- **Card/Body:** Exact Tailwind classes for the main content area.
|
||||||
|
- **[Other visual elements]:** One bullet per distinct visual element.
|
||||||
|
|
||||||
|
#### Behavior
|
||||||
|
|
||||||
|
- How it appears (animation/transition).
|
||||||
|
- How it dismisses.
|
||||||
|
- How it responds to different screen sizes.
|
||||||
|
|
||||||
|
#### Supported Variants
|
||||||
|
|
||||||
|
Table of variants (types, sizes, states) if applicable.
|
||||||
|
|
||||||
|
#### Code Example
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Minimal working example showing how to trigger/use the component.
|
||||||
|
```
|
||||||
|
```
|
||||||
|
|
||||||
|
### 15.4 Update Checklist for AI Agents
|
||||||
|
|
||||||
|
Before marking any UI/UX task as complete, run through this checklist:
|
||||||
|
|
||||||
|
- [ ] **Read** relevant sections of `DESIGN.md` before starting work.
|
||||||
|
- [ ] **Match** existing patterns from this document (colors, spacing, typography, animations).
|
||||||
|
- [ ] **Update §14** with a new Iteration entry if any visual or behavioral change was made.
|
||||||
|
- [ ] **Update §9** if a component was created or modified.
|
||||||
|
- [ ] **Update §5/§6/§7** if design tokens, fonts, or colors were added/changed.
|
||||||
|
- [ ] **Update §3** if a new dependency was added.
|
||||||
|
- [ ] **Update Appendix** if a new file was created.
|
||||||
|
- [ ] **Bump the version** at the top of the file (patch for fixes, minor for features).
|
||||||
|
- [ ] **Update the "Last updated" date** at the top of the file.
|
||||||
|
|
||||||
|
### 15.5 Versioning Convention
|
||||||
|
|
||||||
|
The version number at the top of this file follows **Major.Minor.Patch**:
|
||||||
|
|
||||||
|
| Change Type | Bump | Example |
|
||||||
|
|------------|------|--------|
|
||||||
|
| New section or major restructure | Major | 1.0.0 → 2.0.0 |
|
||||||
|
| New component, new iteration, new design token | Minor | 1.0.0 → 1.1.0 |
|
||||||
|
| Typo fix, clarification, minor class update | Patch | 1.0.0 → 1.0.1 |
|
||||||
|
|
||||||
|
### 15.6 Context Preservation Rules
|
||||||
|
|
||||||
|
To keep entries **light but sufficient** (no confusion, no hallucination):
|
||||||
|
|
||||||
|
1. **Always include exact Tailwind classes.** Don't write "made it rounded" — write `rounded-2xl`.
|
||||||
|
2. **Always include the file path.** Don't write "the modal" — write `resources/views/components/bento/confirm-modal.blade.php`.
|
||||||
|
3. **Always state the user's intent.** Don't write "changed the color" — write "User requested rose for destructive actions instead of generic red."
|
||||||
|
4. **Always state the technical reason.** Don't write "it was broken" — write "The toast type `'error'` had no matching `:class` binding in the Alpine template."
|
||||||
|
5. **Keep it to 3-5 sentences max per entry.** More detail goes in the component spec (§9), not the iteration log (§14).
|
||||||
|
6. **Reference existing sections.** Instead of re-explaining the color system, write "Uses semantic Danger mapping from §7."
|
||||||
81
Dockerfile
Normal file
81
Dockerfile
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
# 1. Composer Builder Stage
|
||||||
|
FROM php:8.4-cli-alpine AS composer-builder
|
||||||
|
RUN apk add --no-cache unzip
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||||
|
WORKDIR /app
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
# Note: ignoring platform requirements since mongo/redis aren't natively in this alpine CLI image
|
||||||
|
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
|
||||||
|
COPY ./ ./
|
||||||
|
RUN composer dump-autoload --optimize --no-dev
|
||||||
|
|
||||||
|
# 2. Node Builder Stage
|
||||||
|
FROM node:22-alpine AS node-builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY ./ ./
|
||||||
|
# Copy vendor from composer-builder because Vite needs to process CSS from packages (e.g. livewire/flux)
|
||||||
|
COPY --from=composer-builder /app/vendor ./vendor
|
||||||
|
# Define build arguments that Vite can use
|
||||||
|
ARG VITE_APP_NAME
|
||||||
|
ARG VITE_REVERB_APP_KEY
|
||||||
|
ARG VITE_REVERB_HOST
|
||||||
|
ARG VITE_REVERB_PORT
|
||||||
|
ARG VITE_REVERB_SCHEME
|
||||||
|
ARG VITE_REVERB_PATH
|
||||||
|
|
||||||
|
# Export them as ENV so the npm run build process can access them
|
||||||
|
ENV VITE_APP_NAME=$VITE_APP_NAME
|
||||||
|
ENV VITE_REVERB_APP_KEY=$VITE_REVERB_APP_KEY
|
||||||
|
ENV VITE_REVERB_HOST=$VITE_REVERB_HOST
|
||||||
|
ENV VITE_REVERB_PORT=$VITE_REVERB_PORT
|
||||||
|
ENV VITE_REVERB_SCHEME=$VITE_REVERB_SCHEME
|
||||||
|
ENV VITE_REVERB_PATH=$VITE_REVERB_PATH
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# 3. Production Stage
|
||||||
|
FROM php:8.4-fpm-alpine
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Install mlocati/php-extension-installer
|
||||||
|
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
nginx \
|
||||||
|
supervisor \
|
||||||
|
git
|
||||||
|
|
||||||
|
# Install PHP extensions (handles all build deps automatically)
|
||||||
|
RUN install-php-extensions \
|
||||||
|
pdo_mysql \
|
||||||
|
gd \
|
||||||
|
zip \
|
||||||
|
intl \
|
||||||
|
bcmath \
|
||||||
|
pcntl \
|
||||||
|
opcache \
|
||||||
|
sockets \
|
||||||
|
mongodb \
|
||||||
|
redis
|
||||||
|
|
||||||
|
# Copy source code and vendor dependencies
|
||||||
|
COPY . .
|
||||||
|
COPY --from=composer-builder /app/vendor ./vendor
|
||||||
|
COPY --from=node-builder /app/public/build ./public/build
|
||||||
|
|
||||||
|
# Copy configuration files
|
||||||
|
COPY docker/nginx.conf /etc/nginx/http.d/default.conf
|
||||||
|
COPY docker/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
|
||||||
|
COPY docker/php.ini /usr/local/etc/php/conf.d/production.ini
|
||||||
|
COPY docker/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
|
||||||
|
COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
# Make entrypoint executable
|
||||||
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||||
40
GEMINI.md
40
GEMINI.md
@@ -1,4 +1,37 @@
|
|||||||
<laravel-boost-guidelines>
|
<laravel-boost-guidelines>
|
||||||
|
=== .ai/design-system rules ===
|
||||||
|
|
||||||
|
# Design System (CRITICAL — Auto-Read Required)
|
||||||
|
|
||||||
|
|
||||||
|
This project has a comprehensive Design System documented in `DESIGN.md` at the project root.
|
||||||
|
|
||||||
|
|
||||||
|
## Before Any UI/UX Work
|
||||||
|
|
||||||
|
|
||||||
|
**BEFORE** making ANY frontend, UI, UX, styling, component, animation, Blade template, or Tailwind-related change, you MUST read `DESIGN.md` using your file reading tool to understand the established:
|
||||||
|
- Design tokens and color system
|
||||||
|
- Typography scale and font conventions
|
||||||
|
- Component patterns (toasts, modals, buttons, indicators)
|
||||||
|
- Spacing constants and layout architecture
|
||||||
|
- Animation and transition conventions
|
||||||
|
- Responsive breakpoint rules
|
||||||
|
|
||||||
|
This applies to: Blade views, CSS files, Alpine.js interactions, GSAP animations, Livewire component UI, toast notifications, modals, buttons, indicators, and any other visual element.
|
||||||
|
|
||||||
|
|
||||||
|
## After Completing UI/UX Work
|
||||||
|
|
||||||
|
|
||||||
|
**AFTER** completing any UI/UX change, you MUST update `DESIGN.md` per its Section 15 (Maintenance Protocol):
|
||||||
|
- Add a new entry to Section 14 (Iteration History) using the provided template.
|
||||||
|
- Update Section 9 (Component Library) if a component was created or modified.
|
||||||
|
- Update Sections 5/6/7 if design tokens, fonts, or colors were added/changed.
|
||||||
|
- Bump the version number and "Last updated" date at the top of the file.
|
||||||
|
|
||||||
|
Failure to read and follow `DESIGN.md` will result in inconsistent UI, visual regressions, and wasted iteration cycles.
|
||||||
|
|
||||||
=== foundation rules ===
|
=== foundation rules ===
|
||||||
|
|
||||||
# Laravel Boost Guidelines
|
# Laravel Boost Guidelines
|
||||||
@@ -13,8 +46,11 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- filament/filament (FILAMENT) - v4
|
- filament/filament (FILAMENT) - v4
|
||||||
- laravel/fortify (FORTIFY) - v1
|
- laravel/fortify (FORTIFY) - v1
|
||||||
- laravel/framework (LARAVEL) - v12
|
- laravel/framework (LARAVEL) - v12
|
||||||
|
- laravel/horizon (HORIZON) - v5
|
||||||
- laravel/pint (PINT) - v1
|
- laravel/pint (PINT) - v1
|
||||||
- laravel/prompts (PROMPTS) - v0
|
- laravel/prompts (PROMPTS) - v0
|
||||||
|
- laravel/pulse (PULSE) - v1
|
||||||
|
- laravel/reverb (REVERB) - v1
|
||||||
- livewire/flux (FLUXUI_FREE) - v2
|
- livewire/flux (FLUXUI_FREE) - v2
|
||||||
- livewire/livewire (LIVEWIRE) - v3
|
- livewire/livewire (LIVEWIRE) - v3
|
||||||
- livewire/volt (VOLT) - v1
|
- livewire/volt (VOLT) - v1
|
||||||
@@ -26,6 +62,7 @@ This application is a Laravel application and its main Laravel ecosystems packag
|
|||||||
- phpunit/phpunit (PHPUNIT) - v11
|
- phpunit/phpunit (PHPUNIT) - v11
|
||||||
- rector/rector (RECTOR) - v2
|
- rector/rector (RECTOR) - v2
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
- tailwindcss (TAILWINDCSS) - v4
|
||||||
|
- laravel-echo (ECHO) - v2
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
@@ -37,6 +74,9 @@ This project has domain-specific skills available. You MUST activate the relevan
|
|||||||
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
- `tailwindcss-development` — Styles applications using Tailwind CSS v4 utilities. Activates when adding styles, restyling components, working with gradients, spacing, layout, flex, grid, responsive design, dark mode, colors, typography, or borders; or when the user mentions CSS, styling, classes, Tailwind, restyle, hero section, cards, buttons, or any visual/UI changes.
|
||||||
- `filament-db-config` — Creates database-backed settings pages and config pages with filament-db-config or db-config package. Activates when creating settings page, config page, configuration page, or when user mentions db-config, db_config, DbConfig, database settings, dynamic configuration, runtime config, storing settings in database. ALWAYS use php artisan make:db-config command to scaffold. NEVER create files manually. NEVER create tests.
|
- `filament-db-config` — Creates database-backed settings pages and config pages with filament-db-config or db-config package. Activates when creating settings page, config page, configuration page, or when user mentions db-config, db_config, DbConfig, database settings, dynamic configuration, runtime config, storing settings in database. ALWAYS use php artisan make:db-config command to scaffold. NEVER create files manually. NEVER create tests.
|
||||||
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
|
- `developing-with-fortify` — Laravel Fortify headless authentication backend development. Activate when implementing authentication features including login, registration, password reset, email verification, two-factor authentication (2FA/TOTP), profile updates, headless auth, authentication scaffolding, or auth guards in Laravel applications.
|
||||||
|
- `bento-landing-page-generator` — Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the native Laravel Boost tech stack (Laravel 12, Livewire 3, Alpine.js, Tailwind, GSAP).
|
||||||
|
- `cinematic-landing-page-builder` — Act as a World-Class Senior Creative Technologist to build high-fidelity, cinematic "1:1 Pixel Perfect" landing pages. Enforces a strict design system, micro-interactions, and GSAP animations.
|
||||||
|
- `laravel-bento-saas-builder` — Act as a Senior Laravel & Frontend Architect. Builds high-fidelity, Bento-style SaaS landing pages strictly using the Laravel Boost tech stack (Laravel 12, Inertia.js v2, React, Tailwind).
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
37
app/Console/Commands/CleanupExpiredMailboxes.php
Normal file
37
app/Console/Commands/CleanupExpiredMailboxes.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
|
||||||
|
class CleanupExpiredMailboxes extends Command
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The name and signature of the console command.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $signature = 'mailboxes:cleanup';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The console command description.
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
protected $description = 'Cleanup expired mailboxes and their associated data';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the console command.
|
||||||
|
*/
|
||||||
|
public function handle()
|
||||||
|
{
|
||||||
|
$mailboxes = \App\Models\Mailbox::where('expires_at', '<=', now())->get();
|
||||||
|
$count = $mailboxes->count();
|
||||||
|
|
||||||
|
foreach ($mailboxes as $mailbox) {
|
||||||
|
$mailbox->delete(); // Triggers soft-delete and the 'deleted' event listener
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->info("Cleaned up {$count} expired mailboxes.");
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Events/NewEmailReceived.php
Normal file
68
app/Events/NewEmailReceived.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Events;
|
||||||
|
|
||||||
|
use App\Models\Email;
|
||||||
|
use Illuminate\Broadcasting\Channel;
|
||||||
|
use Illuminate\Broadcasting\InteractsWithSockets;
|
||||||
|
use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
|
||||||
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class NewEmailReceived implements ShouldBroadcast
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithSockets, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new event instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public Email $email,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the channels the event should broadcast on.
|
||||||
|
*
|
||||||
|
* Broadcasts on a public channel keyed by domain.
|
||||||
|
* Private user channels will be added when user-mailbox assignment is implemented.
|
||||||
|
*
|
||||||
|
* @return array<int, Channel>
|
||||||
|
*/
|
||||||
|
public function broadcastOn(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
new Channel('mailbox.'.$this->email->domain),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's broadcast name.
|
||||||
|
*/
|
||||||
|
public function broadcastAs(): string
|
||||||
|
{
|
||||||
|
return 'new.email';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the data to broadcast.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function broadcastWith(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->email->id,
|
||||||
|
'unique_id_hash' => $this->email->unique_id_hash,
|
||||||
|
'recipient_email' => $this->email->recipient_email,
|
||||||
|
'sender_email' => $this->email->sender_email,
|
||||||
|
'sender_name' => $this->email->sender_name,
|
||||||
|
'domain' => $this->email->domain,
|
||||||
|
'subject' => $this->email->subject,
|
||||||
|
'preview' => $this->email->preview,
|
||||||
|
'attachments_json' => $this->email->attachments_json,
|
||||||
|
'attachment_size' => $this->email->attachment_size,
|
||||||
|
'is_read' => $this->email->is_read,
|
||||||
|
'received_at' => $this->email->received_at?->toISOString(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Filament/Resources/Domains/DomainResource.php
Normal file
58
app/Filament/Resources/Domains/DomainResource.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Domains;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Domains\Pages\CreateDomain;
|
||||||
|
use App\Filament\Resources\Domains\Pages\EditDomain;
|
||||||
|
use App\Filament\Resources\Domains\Pages\ListDomains;
|
||||||
|
use App\Filament\Resources\Domains\Schemas\DomainForm;
|
||||||
|
use App\Filament\Resources\Domains\Tables\DomainsTable;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Resources\Resource;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletingScope;
|
||||||
|
|
||||||
|
class DomainResource extends Resource
|
||||||
|
{
|
||||||
|
protected static ?string $model = Domain::class;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedRectangleStack;
|
||||||
|
|
||||||
|
public static function form(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return DomainForm::configure($schema);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return DomainsTable::configure($table);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRelations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
//
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getPages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'index' => ListDomains::route('/'),
|
||||||
|
'create' => CreateDomain::route('/create'),
|
||||||
|
'edit' => EditDomain::route('/{record}/edit'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function getRecordRouteBindingEloquentQuery(): Builder
|
||||||
|
{
|
||||||
|
return parent::getRecordRouteBindingEloquentQuery()
|
||||||
|
->withoutGlobalScopes([
|
||||||
|
SoftDeletingScope::class,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
11
app/Filament/Resources/Domains/Pages/CreateDomain.php
Normal file
11
app/Filament/Resources/Domains/Pages/CreateDomain.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Domains\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Domains\DomainResource;
|
||||||
|
use Filament\Resources\Pages\CreateRecord;
|
||||||
|
|
||||||
|
class CreateDomain extends CreateRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = DomainResource::class;
|
||||||
|
}
|
||||||
23
app/Filament/Resources/Domains/Pages/EditDomain.php
Normal file
23
app/Filament/Resources/Domains/Pages/EditDomain.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Domains\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Domains\DomainResource;
|
||||||
|
use Filament\Actions\DeleteAction;
|
||||||
|
use Filament\Actions\ForceDeleteAction;
|
||||||
|
use Filament\Actions\RestoreAction;
|
||||||
|
use Filament\Resources\Pages\EditRecord;
|
||||||
|
|
||||||
|
class EditDomain extends EditRecord
|
||||||
|
{
|
||||||
|
protected static string $resource = DomainResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
DeleteAction::make(),
|
||||||
|
ForceDeleteAction::make(),
|
||||||
|
RestoreAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Filament/Resources/Domains/Pages/ListDomains.php
Normal file
19
app/Filament/Resources/Domains/Pages/ListDomains.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Domains\Pages;
|
||||||
|
|
||||||
|
use App\Filament\Resources\Domains\DomainResource;
|
||||||
|
use Filament\Actions\CreateAction;
|
||||||
|
use Filament\Resources\Pages\ListRecords;
|
||||||
|
|
||||||
|
class ListDomains extends ListRecords
|
||||||
|
{
|
||||||
|
protected static string $resource = DomainResource::class;
|
||||||
|
|
||||||
|
protected function getHeaderActions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
CreateAction::make(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Filament/Resources/Domains/Schemas/DomainForm.php
Normal file
34
app/Filament/Resources/Domains/Schemas/DomainForm.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Domains\Schemas;
|
||||||
|
|
||||||
|
use Filament\Forms\Components\TextInput;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Schemas\Schema;
|
||||||
|
|
||||||
|
class DomainForm
|
||||||
|
{
|
||||||
|
public static function configure(Schema $schema): Schema
|
||||||
|
{
|
||||||
|
return $schema
|
||||||
|
->components([
|
||||||
|
TextInput::make('name')
|
||||||
|
->required()
|
||||||
|
->unique(ignoreRecord: true)
|
||||||
|
->placeholder('e.g., imail.com'),
|
||||||
|
\Filament\Forms\Components\Select::make('allowed_types')
|
||||||
|
->multiple()
|
||||||
|
->options([
|
||||||
|
'public' => 'Public',
|
||||||
|
'private' => 'Private',
|
||||||
|
'custom' => 'Custom',
|
||||||
|
'premium' => 'Premium',
|
||||||
|
])
|
||||||
|
->required(),
|
||||||
|
Toggle::make('is_active')
|
||||||
|
->default(true),
|
||||||
|
Toggle::make('is_archived')
|
||||||
|
->default(false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Filament/Resources/Domains/Tables/DomainsTable.php
Normal file
60
app/Filament/Resources/Domains/Tables/DomainsTable.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Filament\Resources\Domains\Tables;
|
||||||
|
|
||||||
|
use Filament\Actions\BulkActionGroup;
|
||||||
|
use Filament\Actions\DeleteBulkAction;
|
||||||
|
use Filament\Actions\EditAction;
|
||||||
|
use Filament\Actions\ForceDeleteBulkAction;
|
||||||
|
use Filament\Actions\RestoreBulkAction;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Filters\TrashedFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
|
||||||
|
class DomainsTable
|
||||||
|
{
|
||||||
|
public static function configure(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('name')
|
||||||
|
->searchable()
|
||||||
|
->sortable(),
|
||||||
|
TextColumn::make('domain_hash')
|
||||||
|
->copyable()
|
||||||
|
->searchable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
TextColumn::make('allowed_types')
|
||||||
|
->badge()
|
||||||
|
->separator(','),
|
||||||
|
TextColumn::make('mailboxes_count')
|
||||||
|
->counts('mailboxes')
|
||||||
|
->label('Mailboxes')
|
||||||
|
->sortable(),
|
||||||
|
IconColumn::make('is_active')
|
||||||
|
->boolean()
|
||||||
|
->label('Active'),
|
||||||
|
IconColumn::make('is_archived')
|
||||||
|
->boolean()
|
||||||
|
->label('Archived'),
|
||||||
|
TextColumn::make('created_at')
|
||||||
|
->dateTime()
|
||||||
|
->sortable()
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->filters([
|
||||||
|
TrashedFilter::make(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
EditAction::make(),
|
||||||
|
])
|
||||||
|
->toolbarActions([
|
||||||
|
BulkActionGroup::make([
|
||||||
|
DeleteBulkAction::make(),
|
||||||
|
ForceDeleteBulkAction::make(),
|
||||||
|
RestoreBulkAction::make(),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Http/Controllers/EmailWebhookController.php
Normal file
23
app/Http/Controllers/EmailWebhookController.php
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers;
|
||||||
|
|
||||||
|
use App\Http\Requests\IncomingEmailRequest;
|
||||||
|
use App\Jobs\ProcessIncomingEmail;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class EmailWebhookController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle an incoming email webhook from MailOps.
|
||||||
|
*
|
||||||
|
* Dispatches the validated payload to a queued job for background
|
||||||
|
* processing and returns immediately with a 200 response.
|
||||||
|
*/
|
||||||
|
public function handle(IncomingEmailRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
ProcessIncomingEmail::dispatch($request->validated());
|
||||||
|
|
||||||
|
return response()->json(['status' => 'queued'], 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Http/Middleware/VerifyWebhookSecret.php
Normal file
30
app/Http/Middleware/VerifyWebhookSecret.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class VerifyWebhookSecret
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Verify the incoming webhook request has a valid Bearer token.
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next): Response
|
||||||
|
{
|
||||||
|
$secret = config('services.mailops.webhook_secret');
|
||||||
|
|
||||||
|
if (empty($secret)) {
|
||||||
|
return response()->json(['error' => 'Webhook secret not configured'], 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $request->bearerToken();
|
||||||
|
|
||||||
|
if (! $token || ! hash_equals($secret, $token)) {
|
||||||
|
return response()->json(['error' => 'Unauthorized'], 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
58
app/Http/Requests/IncomingEmailRequest.php
Normal file
58
app/Http/Requests/IncomingEmailRequest.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class IncomingEmailRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array<mixed>|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'hash' => ['required', 'string', 'max:64'],
|
||||||
|
'metadata.recipientEmail' => ['required', 'email', 'max:255'],
|
||||||
|
'metadata.recipientName' => ['nullable', 'string', 'max:255'],
|
||||||
|
'metadata.senderEmail' => ['required', 'email', 'max:255'],
|
||||||
|
'metadata.senderName' => ['nullable', 'string', 'max:255'],
|
||||||
|
'metadata.domain' => ['required', 'string', 'max:255'],
|
||||||
|
'metadata.subject' => ['nullable', 'string', 'max:500'],
|
||||||
|
'metadata.received_at' => ['required', 'date'],
|
||||||
|
'metadata.attachments' => ['nullable', 'array'],
|
||||||
|
'metadata.attachments.*.filename' => ['required_with:metadata.attachments', 'string'],
|
||||||
|
'metadata.attachments.*.mimeType' => ['required_with:metadata.attachments', 'string'],
|
||||||
|
'metadata.attachments.*.size' => ['required_with:metadata.attachments', 'integer'],
|
||||||
|
'metadata.attachmentSize' => ['nullable', 'integer', 'min:0'],
|
||||||
|
'bodyText' => ['nullable', 'string'],
|
||||||
|
'bodyHtml' => ['nullable', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error messages.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'hash.required' => 'The email hash identifier is required.',
|
||||||
|
'metadata.recipientEmail.required' => 'A recipient email address is required.',
|
||||||
|
'metadata.senderEmail.required' => 'A sender email address is required.',
|
||||||
|
'metadata.domain.required' => 'The recipient domain is required.',
|
||||||
|
'metadata.received_at.required' => 'The email received timestamp is required.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/Jobs/ProcessIncomingEmail.php
Normal file
175
app/Jobs/ProcessIncomingEmail.php
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use App\Events\NewEmailReceived;
|
||||||
|
use App\Models\Email;
|
||||||
|
use App\Models\EmailBody;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\Middleware\WithoutOverlapping;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use MongoDB\Laravel\Collection;
|
||||||
|
|
||||||
|
class ProcessIncomingEmail implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The number of times the job may be attempted.
|
||||||
|
*/
|
||||||
|
public int $tries = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The backoff strategy (seconds) between retries.
|
||||||
|
*
|
||||||
|
* @var array<int, int>
|
||||||
|
*/
|
||||||
|
public array $backoff = [5, 15, 30];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $payload The validated webhook payload.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public array $payload,
|
||||||
|
) {
|
||||||
|
$this->onQueue('emails');
|
||||||
|
$this->onConnection('redis');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the middleware the job should pass through.
|
||||||
|
*
|
||||||
|
* @return array<int, object>
|
||||||
|
*/
|
||||||
|
public function middleware(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
(new WithoutOverlapping($this->payload['hash']))->dontRelease(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
$metadata = $this->payload['metadata'];
|
||||||
|
$bodyText = $this->payload['bodyText'] ?? null;
|
||||||
|
$bodyHtml = $this->payload['bodyHtml'] ?? null;
|
||||||
|
|
||||||
|
$preview = $this->generatePreview($bodyText, $bodyHtml);
|
||||||
|
|
||||||
|
$email = Email::updateOrCreate(
|
||||||
|
['unique_id_hash' => $this->payload['hash']],
|
||||||
|
[
|
||||||
|
'recipient_email' => $metadata['recipientEmail'],
|
||||||
|
'recipient_name' => $metadata['recipientName'] ?? '',
|
||||||
|
'sender_email' => $metadata['senderEmail'],
|
||||||
|
'sender_name' => $metadata['senderName'] ?? '',
|
||||||
|
'domain' => $metadata['domain'],
|
||||||
|
'subject' => $metadata['subject'] ?? '',
|
||||||
|
'preview' => $preview,
|
||||||
|
'attachments_json' => $metadata['attachments'] ?? [],
|
||||||
|
'attachment_size' => $metadata['attachmentSize'] ?? 0,
|
||||||
|
'received_at' => $metadata['received_at'],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
EmailBody::updateOrCreate(
|
||||||
|
['unique_id_hash' => $this->payload['hash']],
|
||||||
|
[
|
||||||
|
'body_text' => $bodyText,
|
||||||
|
'body_html' => $bodyHtml,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Track analytics for receiving email
|
||||||
|
$mailbox = \App\Models\Mailbox::where('address', $email->recipient_email)->first();
|
||||||
|
|
||||||
|
TrackAnalytics::dispatch(
|
||||||
|
eventType: 'email_received',
|
||||||
|
mailboxHash: $mailbox?->mailbox_hash ?? 'unknown',
|
||||||
|
domainHash: $mailbox?->domain_hash ?? 'unknown',
|
||||||
|
metadata: [
|
||||||
|
'email_id' => $email->id,
|
||||||
|
'sender' => $email->sender_email,
|
||||||
|
'recipient' => $email->recipient_email,
|
||||||
|
'attachment_count' => count($metadata['attachments'] ?? []),
|
||||||
|
'found_mailbox' => $mailbox !== null,
|
||||||
|
],
|
||||||
|
ipAddress: '0.0.0.0', // Server-side event
|
||||||
|
userAgent: 'MailOps/IncomingWorker'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->ensureTtlIndex();
|
||||||
|
|
||||||
|
NewEmailReceived::dispatch($email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an excerpt from the email body for the preview column.
|
||||||
|
*
|
||||||
|
* Prefers body_text. Falls back to body_html with tags stripped.
|
||||||
|
*/
|
||||||
|
private function generatePreview(?string $bodyText, ?string $bodyHtml): string
|
||||||
|
{
|
||||||
|
if (! empty($bodyText)) {
|
||||||
|
return mb_substr(trim($bodyText), 0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! empty($bodyHtml)) {
|
||||||
|
// Replace all HTML tags with spaces to prevent words from running together
|
||||||
|
$html = preg_replace('/<[^>]*>/', ' ', $bodyHtml);
|
||||||
|
|
||||||
|
// Decode HTML entities (e.g. , &)
|
||||||
|
$decoded = html_entity_decode($html ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
|
||||||
|
// Collapse multiple spaces into a single space
|
||||||
|
$stripped = preg_replace('/\s+/', ' ', $decoded);
|
||||||
|
|
||||||
|
return mb_substr(trim($stripped ?? ''), 0, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure the MongoDB TTL index exists on the `recent_email_bodies` collection.
|
||||||
|
*
|
||||||
|
* Uses a cache flag to avoid checking on every job execution.
|
||||||
|
*/
|
||||||
|
private function ensureTtlIndex(): void
|
||||||
|
{
|
||||||
|
Cache::rememberForever('mongodb_ttl_index_ensured', function () {
|
||||||
|
$ttlSeconds = config('services.mailops.email_body_ttl_seconds', 259200);
|
||||||
|
|
||||||
|
EmailBody::raw(function ($collection) use ($ttlSeconds) {
|
||||||
|
/* @var \MongoDB\Collection $collection */
|
||||||
|
$collection->createIndex(
|
||||||
|
['created_at' => 1],
|
||||||
|
['expireAfterSeconds' => $ttlSeconds, 'name' => 'ttl_created_at']
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a job failure.
|
||||||
|
*/
|
||||||
|
public function failed(?\Throwable $exception): void
|
||||||
|
{
|
||||||
|
Log::error('ProcessIncomingEmail failed', [
|
||||||
|
'hash' => $this->payload['hash'] ?? 'unknown',
|
||||||
|
'error' => $exception?->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Jobs/TrackAnalytics.php
Normal file
48
app/Jobs/TrackAnalytics.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Jobs;
|
||||||
|
|
||||||
|
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||||
|
use Illuminate\Foundation\Bus\Dispatchable;
|
||||||
|
use Illuminate\Foundation\Queue\Queueable;
|
||||||
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TrackAnalytics implements ShouldQueue
|
||||||
|
{
|
||||||
|
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new job instance.
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public string $eventType,
|
||||||
|
public string $mailboxHash,
|
||||||
|
public ?string $domainHash = null,
|
||||||
|
public array $metadata = [],
|
||||||
|
public ?int $userId = null,
|
||||||
|
public string $userType = 'guest',
|
||||||
|
public ?string $ipAddress = null,
|
||||||
|
public ?string $userAgent = null,
|
||||||
|
) {
|
||||||
|
$this->onQueue('analytics');
|
||||||
|
$this->onConnection('redis');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the job.
|
||||||
|
*/
|
||||||
|
public function handle(): void
|
||||||
|
{
|
||||||
|
\App\Models\AnalyticsEvent::create([
|
||||||
|
'event_type' => $this->eventType,
|
||||||
|
'mailbox_hash' => $this->mailboxHash,
|
||||||
|
'domain_hash' => $this->domainHash,
|
||||||
|
'user_type' => $this->userType,
|
||||||
|
'user_id' => $this->userId,
|
||||||
|
'ip_address' => $this->ipAddress ?? request()->ip(),
|
||||||
|
'user_agent' => $this->userAgent ?? request()->userAgent(),
|
||||||
|
'metadata' => $this->metadata,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ class Register extends Component
|
|||||||
$validated['password'] = Hash::make($validated['password']);
|
$validated['password'] = Hash::make($validated['password']);
|
||||||
|
|
||||||
event(new Registered(($user = User::create($validated))));
|
event(new Registered(($user = User::create($validated))));
|
||||||
|
$user->assignRole('free');
|
||||||
|
|
||||||
Auth::login($user);
|
Auth::login($user);
|
||||||
|
|
||||||
|
|||||||
15
app/Livewire/LandingPage.php
Normal file
15
app/Livewire/LandingPage.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.marketing')]
|
||||||
|
class LandingPage extends Component
|
||||||
|
{
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.landing-page');
|
||||||
|
}
|
||||||
|
}
|
||||||
494
app/Livewire/Mailbox.php
Normal file
494
app/Livewire/Mailbox.php
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use App\Jobs\TrackAnalytics;
|
||||||
|
use App\Models\Domain;
|
||||||
|
use App\Models\Email;
|
||||||
|
use App\Models\EmailBody;
|
||||||
|
use App\Models\Mailbox as MailboxModel;
|
||||||
|
use Illuminate\Support\Facades\Session;
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithPagination;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.app')]
|
||||||
|
class Mailbox extends Component
|
||||||
|
{
|
||||||
|
use WithPagination;
|
||||||
|
|
||||||
|
public $currentMailboxId = null;
|
||||||
|
|
||||||
|
public $activeFolder = 'inbox';
|
||||||
|
|
||||||
|
public $selectedEmailId = null;
|
||||||
|
|
||||||
|
public $search = '';
|
||||||
|
|
||||||
|
public $viewMode = 'text'; // text | html
|
||||||
|
|
||||||
|
public $allowRemoteContent = false;
|
||||||
|
|
||||||
|
// Create State
|
||||||
|
public $showCreateModal = false;
|
||||||
|
|
||||||
|
public $createType = 'random'; // random | custom
|
||||||
|
|
||||||
|
public $customUsername = '';
|
||||||
|
|
||||||
|
public $customDomain = '';
|
||||||
|
|
||||||
|
public bool $isCreatingFirstMailbox = false;
|
||||||
|
|
||||||
|
public function mount()
|
||||||
|
{
|
||||||
|
$this->customDomain = Domain::where('is_active', true)->first()?->name ?? 'imail.app';
|
||||||
|
|
||||||
|
// Load current mailbox from session if exists
|
||||||
|
$savedId = Session::get('current_mailbox_id');
|
||||||
|
if ($savedId && $this->getActiveMailboxesProperty()->contains('id', $savedId)) {
|
||||||
|
$this->currentMailboxId = $savedId;
|
||||||
|
} else {
|
||||||
|
$this->currentMailboxId = $this->getActiveMailboxesProperty()->first()?->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-create a random mailbox if none exist (first-time visitor)
|
||||||
|
if ($this->getActiveMailboxesProperty()->isEmpty()) {
|
||||||
|
$this->isCreatingFirstMailbox = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getActiveMailboxesProperty()
|
||||||
|
{
|
||||||
|
return MailboxModel::query()
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('session_id', Session::getId());
|
||||||
|
if (auth()->check()) {
|
||||||
|
$query->orWhere('user_id', auth()->id());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->where('is_blocked', false)
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAvailableDomainsProperty()
|
||||||
|
{
|
||||||
|
return Domain::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->where('is_archived', false)
|
||||||
|
->accessibleBy(auth()->user())
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Reverb/Echo event listeners for the current mailbox domain.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getListeners(): array
|
||||||
|
{
|
||||||
|
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
|
||||||
|
$domain = $currentMailbox ? explode('@', $currentMailbox->address)[1] ?? '' : '';
|
||||||
|
|
||||||
|
if (empty($domain)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
"echo:mailbox.{$domain},.new.email" => 'onNewEmail',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onNewEmail(array $eventData): void
|
||||||
|
{
|
||||||
|
$sender = ($eventData['sender_name'] ?? null) ?: ($eventData['sender_email'] ?? 'Unknown');
|
||||||
|
$subject = ($eventData['subject'] ?? null) ?: '(No Subject)';
|
||||||
|
|
||||||
|
$this->dispatch('notify',
|
||||||
|
message: "Sender: {$sender}\nSubject: {$subject}",
|
||||||
|
type: 'info'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Simply refresh the list to pick up the new email from MariaDB
|
||||||
|
// Since we order by received_at DESC, it will appear on top.
|
||||||
|
$this->dispatch('$refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEmailsProperty()
|
||||||
|
{
|
||||||
|
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
|
||||||
|
|
||||||
|
if (! $currentMailbox) {
|
||||||
|
return Email::query()->whereRaw('1 = 0')->paginate(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Email::query()
|
||||||
|
->where('recipient_email', $currentMailbox->address)
|
||||||
|
->when($this->search, function ($query) {
|
||||||
|
$query->where(function ($q) {
|
||||||
|
$q->where('subject', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('sender_email', 'like', "%{$this->search}%")
|
||||||
|
->orWhere('sender_name', 'like', "%{$this->search}%");
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orderByDesc('received_at')
|
||||||
|
->paginate(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUnreadCountProperty(): int
|
||||||
|
{
|
||||||
|
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
|
||||||
|
|
||||||
|
if (! $currentMailbox) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Email::where('recipient_email', $currentMailbox->address)
|
||||||
|
->where('is_read', false)
|
||||||
|
->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function selectEmail($id)
|
||||||
|
{
|
||||||
|
$this->selectedEmailId = $id;
|
||||||
|
$this->viewMode = 'text';
|
||||||
|
$this->allowRemoteContent = false;
|
||||||
|
|
||||||
|
$email = Email::find($id);
|
||||||
|
|
||||||
|
if ($email) {
|
||||||
|
if (! $email->is_read) {
|
||||||
|
$email->update(['is_read' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track analytics for reading email
|
||||||
|
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
|
||||||
|
if ($currentMailbox) {
|
||||||
|
TrackAnalytics::dispatch(
|
||||||
|
eventType: 'email_read',
|
||||||
|
mailboxHash: $currentMailbox->mailbox_hash,
|
||||||
|
domainHash: $currentMailbox->domain_hash,
|
||||||
|
metadata: ['email_id' => $email->id, 'subject' => $email->subject],
|
||||||
|
userId: auth()->id(),
|
||||||
|
userType: auth()->check() ? 'authenticated' : 'guest',
|
||||||
|
ipAddress: request()->ip(),
|
||||||
|
userAgent: request()->userAgent()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function switchMailbox($id)
|
||||||
|
{
|
||||||
|
$this->currentMailboxId = $id;
|
||||||
|
$this->selectedEmailId = null;
|
||||||
|
$this->search = '';
|
||||||
|
$this->resetPage();
|
||||||
|
Session::put('current_mailbox_id', $id);
|
||||||
|
|
||||||
|
// Track analytics for switching mailbox
|
||||||
|
$currentMailbox = $this->active_mailboxes->firstWhere('id', $id);
|
||||||
|
if ($currentMailbox) {
|
||||||
|
TrackAnalytics::dispatch(
|
||||||
|
eventType: 'mailbox_accessed',
|
||||||
|
mailboxHash: $currentMailbox->mailbox_hash,
|
||||||
|
domainHash: $currentMailbox->domain_hash,
|
||||||
|
userId: auth()->id(),
|
||||||
|
userType: auth()->check() ? 'authenticated' : 'guest',
|
||||||
|
ipAddress: request()->ip(),
|
||||||
|
userAgent: request()->userAgent()
|
||||||
|
);
|
||||||
|
|
||||||
|
$currentMailbox->update([
|
||||||
|
'last_accessed_at' => now(),
|
||||||
|
'last_accessed_ip' => request()->ip(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createMailbox()
|
||||||
|
{
|
||||||
|
$domainModel = Domain::where('name', $this->customDomain)
|
||||||
|
->where('is_active', true)
|
||||||
|
->accessibleBy(auth()->user())
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $domainModel) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->createType === 'random') {
|
||||||
|
do {
|
||||||
|
$address = fake()->userName().rand(10, 99).'@'.$this->customDomain;
|
||||||
|
} while (MailboxModel::withTrashed()->where('address', $address)->exists());
|
||||||
|
} else {
|
||||||
|
$address = $this->customUsername.'@'.$this->customDomain;
|
||||||
|
|
||||||
|
// Check if address already exists
|
||||||
|
$existing = MailboxModel::withTrashed()->where('address', $address)->first();
|
||||||
|
|
||||||
|
if ($existing) {
|
||||||
|
// Scenario A: Same User Reclaiming
|
||||||
|
$isOwner = (auth()->check() && $existing->user_id === auth()->id())
|
||||||
|
|| ($existing->session_id === Session::getId());
|
||||||
|
|
||||||
|
if ($isOwner) {
|
||||||
|
if ($existing->trashed()) {
|
||||||
|
$existing->restore();
|
||||||
|
}
|
||||||
|
if (now() > $existing->expires_at) {
|
||||||
|
$existing->update([
|
||||||
|
'expires_at' => now()->addDays($this->getValidityDays()),
|
||||||
|
'last_accessed_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->currentMailboxId = $existing->id;
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
$this->customUsername = '';
|
||||||
|
Session::put('last_mailbox_id', $existing->id);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scenario B: Different User Claiming
|
||||||
|
if (! $existing->trashed()) {
|
||||||
|
$this->dispatch('notify', message: 'Address already in use.', type: 'danger');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address is soft-deleted. Check Tier-based Cooldown
|
||||||
|
$user = auth()->user();
|
||||||
|
$hoursRequired = match (true) {
|
||||||
|
! $user => 24, // Guest
|
||||||
|
$user->isEnterprise() || $user->isAdmin() => 0,
|
||||||
|
$user->isPro() => 6,
|
||||||
|
$user->isFree() => 12,
|
||||||
|
default => 12,
|
||||||
|
};
|
||||||
|
|
||||||
|
$cooldownEndsAt = $existing->deleted_at->copy()->addHours($hoursRequired);
|
||||||
|
|
||||||
|
if (now()->lessThan($cooldownEndsAt)) {
|
||||||
|
$diff = now()->diff($cooldownEndsAt);
|
||||||
|
$parts = [];
|
||||||
|
if ($diff->d > 0) $parts[] = $diff->d . 'd';
|
||||||
|
if ($diff->h > 0) $parts[] = $diff->h . 'h';
|
||||||
|
if ($diff->i > 0) $parts[] = $diff->i . 'm';
|
||||||
|
if ($diff->s > 0 || empty($parts)) $parts[] = $diff->s . 's';
|
||||||
|
$remaining = implode(' ', $parts);
|
||||||
|
|
||||||
|
$this->dispatch('notify', message: "Address is in cooldown. Try again in {$remaining}.", type: 'warning');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooldown passed. Permanently delete the old record to sever email history.
|
||||||
|
$existing->forceDelete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$mailbox = MailboxModel::create([
|
||||||
|
'mailbox_hash' => bin2hex(random_bytes(32)),
|
||||||
|
'domain_hash' => $domainModel->domain_hash,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'session_id' => Session::getId(),
|
||||||
|
'address' => $address,
|
||||||
|
'type' => $this->createType === 'random' ? 'public' : 'custom',
|
||||||
|
'created_ip' => request()->ip(),
|
||||||
|
'last_accessed_ip' => request()->ip(),
|
||||||
|
'last_accessed_at' => now(),
|
||||||
|
'expires_at' => now()->addDays($this->getValidityDays()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->currentMailboxId = $mailbox->id;
|
||||||
|
$this->showCreateModal = false;
|
||||||
|
$this->customUsername = '';
|
||||||
|
Session::put('last_mailbox_id', $mailbox->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-create a random mailbox on a public domain.
|
||||||
|
* Called when no mailboxes exist (first visit, or all deleted).
|
||||||
|
*/
|
||||||
|
public function autoCreateRandomMailbox(): void
|
||||||
|
{
|
||||||
|
$domain = Domain::query()
|
||||||
|
->where('is_active', true)
|
||||||
|
->where('is_archived', false)
|
||||||
|
->accessibleBy(auth()->user())
|
||||||
|
->whereJsonContains('allowed_types', 'public')
|
||||||
|
->inRandomOrder()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $domain) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
$address = fake()->userName().rand(10, 99).'@'.$domain->name;
|
||||||
|
} while (MailboxModel::withTrashed()->where('address', $address)->exists());
|
||||||
|
|
||||||
|
$mailbox = MailboxModel::create([
|
||||||
|
'mailbox_hash' => bin2hex(random_bytes(32)),
|
||||||
|
'domain_hash' => $domain->domain_hash,
|
||||||
|
'user_id' => auth()->id(),
|
||||||
|
'session_id' => Session::getId(),
|
||||||
|
'address' => $address,
|
||||||
|
'type' => 'public',
|
||||||
|
'created_ip' => request()->ip(),
|
||||||
|
'last_accessed_ip' => request()->ip(),
|
||||||
|
'last_accessed_at' => now(),
|
||||||
|
'expires_at' => now()->addDays($this->getValidityDays()),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->currentMailboxId = $mailbox->id;
|
||||||
|
$this->isCreatingFirstMailbox = false;
|
||||||
|
Session::put('current_mailbox_id', $mailbox->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get mailbox lifespan in days based on user tier.
|
||||||
|
* Guest: 1 day, Free: 3 days, Pro: 7 days, Enterprise: 14 days.
|
||||||
|
*/
|
||||||
|
protected function getValidityDays(): int
|
||||||
|
{
|
||||||
|
$user = auth()->user();
|
||||||
|
|
||||||
|
if (! $user) {
|
||||||
|
return 1; // Guests get 24 hours
|
||||||
|
}
|
||||||
|
|
||||||
|
return match (true) {
|
||||||
|
$user->isEnterprise() => 14,
|
||||||
|
$user->isAdmin() => 14, // Assuming admins get enterprise limits
|
||||||
|
$user->isPro() => 7,
|
||||||
|
$user->isFree() => 3,
|
||||||
|
default => 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public function finishAutoCreation(): void
|
||||||
|
{
|
||||||
|
if ($this->getActiveMailboxesProperty()->isEmpty()) {
|
||||||
|
$this->autoCreateRandomMailbox();
|
||||||
|
}
|
||||||
|
$this->isCreatingFirstMailbox = false;
|
||||||
|
|
||||||
|
// Ensure the component re-renders fully with the new data
|
||||||
|
$this->dispatch('$refresh');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteMailbox($id)
|
||||||
|
{
|
||||||
|
$mailbox = MailboxModel::find($id);
|
||||||
|
if ($mailbox) {
|
||||||
|
$mailbox->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->currentMailboxId === $id) {
|
||||||
|
$this->currentMailboxId = $this->active_mailboxes->first()?->id;
|
||||||
|
$this->selectedEmailId = null;
|
||||||
|
session(['current_mailbox_id' => $this->currentMailboxId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If all mailboxes deleted, auto-create a new random one
|
||||||
|
// Use a direct query or re-fetch the property to avoid cache issues
|
||||||
|
$hasActive = MailboxModel::query()
|
||||||
|
->where(function ($query) {
|
||||||
|
$query->where('session_id', Session::getId());
|
||||||
|
if (auth()->check()) {
|
||||||
|
$query->orWhere('user_id', auth()->id());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
->where('is_blocked', false)
|
||||||
|
->exists();
|
||||||
|
|
||||||
|
if (! $hasActive) {
|
||||||
|
$this->isCreatingFirstMailbox = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadEmail($id)
|
||||||
|
{
|
||||||
|
// Mock download logic
|
||||||
|
$this->js("alert('Downloading email #{$id}... (Mock Action)')");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function printEmail($id)
|
||||||
|
{
|
||||||
|
// Mock print logic
|
||||||
|
$this->js('window.print()');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function deleteEmail($id)
|
||||||
|
{
|
||||||
|
// Mock delete logic
|
||||||
|
$this->js("alert('Email #{$id} deleted successfully! (Mock Action)')");
|
||||||
|
$this->selectedEmailId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nextPage()
|
||||||
|
{
|
||||||
|
if ($this->page < $this->totalPages) {
|
||||||
|
$this->page++;
|
||||||
|
$this->selectedEmailId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function previousPage()
|
||||||
|
{
|
||||||
|
if ($this->page > 1) {
|
||||||
|
$this->page--;
|
||||||
|
$this->selectedEmailId = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function generateQrCode($address)
|
||||||
|
{
|
||||||
|
// Mock QR generation with a slight delay
|
||||||
|
usleep(800000); // 800ms
|
||||||
|
$this->dispatch('qrCodeGenerated', address: $address);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getProcessedContent($email)
|
||||||
|
{
|
||||||
|
$body = EmailBody::where('unique_id_hash', $email->unique_id_hash)->first();
|
||||||
|
|
||||||
|
if (! $body) {
|
||||||
|
return 'Email body not found.';
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = $body->body_html ?? $body->body_text;
|
||||||
|
$isText = $this->viewMode === 'text';
|
||||||
|
|
||||||
|
// Fallback to HTML if text is selected but body_text is empty
|
||||||
|
if ($isText && ! empty($body->body_text)) {
|
||||||
|
return trim(e($body->body_text));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($isText) {
|
||||||
|
// If fallback occurred, we sanitize the HTML to text
|
||||||
|
return trim(strip_tags($content));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->allowRemoteContent) {
|
||||||
|
// Block remote assets by replacing src with data-src for img tags
|
||||||
|
return preg_replace('/<img\s[^>]*?\bsrc\s*=\s*([\'"])(.*?)\1/i', '<img $2 data-blocked-src=$1$2$1 src="data:image/svg+xml,%3Csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 1 1\'%3E%3C/svg%3E" class="blocked-remote-asset shadow-sm border border-white/5 opacity-50"', $content);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
$currentMailbox = $this->active_mailboxes->firstWhere('id', $this->currentMailboxId);
|
||||||
|
|
||||||
|
return view('livewire.mailbox', [
|
||||||
|
'emails' => $this->getEmailsProperty(),
|
||||||
|
'currentMailbox' => $currentMailbox,
|
||||||
|
'unreadCount' => $this->unread_count,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
15
app/Livewire/PrivacyPolicy.php
Normal file
15
app/Livewire/PrivacyPolicy.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Livewire;
|
||||||
|
|
||||||
|
use Livewire\Attributes\Layout;
|
||||||
|
use Livewire\Component;
|
||||||
|
|
||||||
|
#[Layout('components.layouts.marketing')]
|
||||||
|
class PrivacyPolicy extends Component
|
||||||
|
{
|
||||||
|
public function render()
|
||||||
|
{
|
||||||
|
return view('livewire.privacy-policy');
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Models/AnalyticsEvent.php
Normal file
30
app/Models/AnalyticsEvent.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use MongoDB\Laravel\Eloquent\Model;
|
||||||
|
|
||||||
|
class AnalyticsEvent extends Model
|
||||||
|
{
|
||||||
|
protected $connection = 'mongodb';
|
||||||
|
|
||||||
|
protected $collection = 'analytics_events';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'event_type',
|
||||||
|
'mailbox_hash',
|
||||||
|
'domain_hash',
|
||||||
|
'user_type',
|
||||||
|
'user_id',
|
||||||
|
'ip_address',
|
||||||
|
'user_agent',
|
||||||
|
'metadata',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'metadata' => 'array',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/Models/Domain.php
Normal file
68
app/Models/Domain.php
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Domain extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\DomainFactory> */
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'domain_hash',
|
||||||
|
'name',
|
||||||
|
'allowed_types',
|
||||||
|
'is_active',
|
||||||
|
'is_archived',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'allowed_types' => 'array',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'is_archived' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Domain $domain) {
|
||||||
|
if (empty($domain->domain_hash)) {
|
||||||
|
$domain->domain_hash = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mailboxes()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Mailbox::class, 'domain_hash', 'domain_hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to domains accessible by the given user tier.
|
||||||
|
* For guests (null user), only 'public' domains are returned.
|
||||||
|
*
|
||||||
|
* Uses MySQL's JSON_CONTAINS to check if the domain's `allowed_types`
|
||||||
|
* array includes ANY of the user's allowed types.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Domain::accessibleBy(auth()->user())->get(); // logged-in
|
||||||
|
* Domain::accessibleBy(null)->get(); // guest
|
||||||
|
* Domain::accessibleBy($user)->where('is_active', true)->get();
|
||||||
|
*/
|
||||||
|
public function scopeAccessibleBy(Builder $query, ?User $user = null): Builder
|
||||||
|
{
|
||||||
|
$types = $user ? $user->allowedDomainTypes() : User::guestDomainTypes();
|
||||||
|
|
||||||
|
return $query->where(function (Builder $q) use ($types) {
|
||||||
|
foreach ($types as $type) {
|
||||||
|
$q->orWhereJsonContains('allowed_types', $type);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/Models/Email.php
Normal file
69
app/Models/Email.php
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Email extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\EmailFactory> */
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $table = 'emails';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'unique_id_hash',
|
||||||
|
'recipient_email',
|
||||||
|
'recipient_name',
|
||||||
|
'sender_email',
|
||||||
|
'sender_name',
|
||||||
|
'domain',
|
||||||
|
'subject',
|
||||||
|
'preview',
|
||||||
|
'attachments_json',
|
||||||
|
'attachment_size',
|
||||||
|
'is_read',
|
||||||
|
'received_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the attributes that should be cast.
|
||||||
|
*
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'attachments_json' => 'array',
|
||||||
|
'attachment_size' => 'integer',
|
||||||
|
'is_read' => 'boolean',
|
||||||
|
'received_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter emails by recipient address.
|
||||||
|
*/
|
||||||
|
public function scopeForRecipient(Builder $query, string $email): Builder
|
||||||
|
{
|
||||||
|
return $query->where('recipient_email', $email);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter emails by domain.
|
||||||
|
*/
|
||||||
|
public function scopeForDomain(Builder $query, string $domain): Builder
|
||||||
|
{
|
||||||
|
return $query->where('domain', $domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope: filter unread emails only.
|
||||||
|
*/
|
||||||
|
public function scopeUnread(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where('is_read', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Models/EmailBody.php
Normal file
18
app/Models/EmailBody.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use MongoDB\Laravel\Eloquent\Model;
|
||||||
|
|
||||||
|
class EmailBody extends Model
|
||||||
|
{
|
||||||
|
protected $connection = 'mongodb';
|
||||||
|
|
||||||
|
protected $collection = 'recent_email_bodies';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'unique_id_hash',
|
||||||
|
'body_text',
|
||||||
|
'body_html',
|
||||||
|
];
|
||||||
|
}
|
||||||
89
app/Models/Mailbox.php
Normal file
89
app/Models/Mailbox.php
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class Mailbox extends Model
|
||||||
|
{
|
||||||
|
/** @use HasFactory<\Database\Factories\MailboxFactory> */
|
||||||
|
use HasFactory, SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'mailbox_hash',
|
||||||
|
'domain_hash',
|
||||||
|
'user_id',
|
||||||
|
'session_id',
|
||||||
|
'address',
|
||||||
|
'type',
|
||||||
|
'created_ip',
|
||||||
|
'last_accessed_ip',
|
||||||
|
'last_accessed_at',
|
||||||
|
'is_blocked',
|
||||||
|
'block_reason',
|
||||||
|
'blocked_at',
|
||||||
|
'expires_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_blocked' => 'boolean',
|
||||||
|
'last_accessed_at' => 'datetime',
|
||||||
|
'blocked_at' => 'datetime',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (Mailbox $mailbox) {
|
||||||
|
if (empty($mailbox->mailbox_hash)) {
|
||||||
|
$mailbox->mailbox_hash = bin2hex(random_bytes(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record creation analytics
|
||||||
|
\App\Jobs\TrackAnalytics::dispatch(
|
||||||
|
eventType: 'mailbox_created',
|
||||||
|
mailboxHash: $mailbox->mailbox_hash,
|
||||||
|
domainHash: $mailbox->domain_hash,
|
||||||
|
userId: $mailbox->user_id,
|
||||||
|
userType: $mailbox->user_id ? 'authenticated' : 'guest',
|
||||||
|
ipAddress: request()->ip(),
|
||||||
|
userAgent: request()->userAgent()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
static::deleted(function (Mailbox $mailbox) {
|
||||||
|
// Find all associated emails
|
||||||
|
$hashes = \App\Models\Email::where('recipient_email', $mailbox->address)->pluck('unique_id_hash');
|
||||||
|
|
||||||
|
if ($hashes->isNotEmpty()) {
|
||||||
|
// Clean MongoDB documents
|
||||||
|
\App\Models\EmailBody::whereIn('unique_id_hash', $hashes)->delete();
|
||||||
|
|
||||||
|
// Clean MariaDB records
|
||||||
|
\App\Models\Email::whereIn('unique_id_hash', $hashes)->delete();
|
||||||
|
|
||||||
|
// Future Placeholder: Delete physical attachments from S3/Storage here
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function user()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function emails()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Email::class, 'recipient_email', 'address');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function domain()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Domain::class, 'domain_hash', 'domain_hash');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,23 +2,24 @@
|
|||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
|
||||||
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
|
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthenticationRecovery;
|
||||||
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
|
use Filament\Auth\MultiFactor\Email\Contracts\HasEmailAuthentication;
|
||||||
use Filament\Models\Contracts\FilamentUser;
|
use Filament\Models\Contracts\FilamentUser;
|
||||||
use Filament\Panel;
|
use Filament\Panel;
|
||||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||||
use Spatie\Permission\Traits\HasRoles;
|
use Spatie\Permission\Traits\HasRoles;
|
||||||
use Filament\Auth\MultiFactor\App\Contracts\HasAppAuthentication;
|
|
||||||
|
|
||||||
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication, MustVerifyEmail
|
class User extends Authenticatable implements FilamentUser, HasAppAuthentication, HasAppAuthenticationRecovery, HasEmailAuthentication, MustVerifyEmail
|
||||||
{
|
{
|
||||||
/** @use HasFactory<\Database\Factories\UserFactory> */
|
/** @use HasFactory<\Database\Factories\UserFactory> */
|
||||||
use HasFactory, Notifiable, TwoFactorAuthenticatable, HasRoles;
|
use HasFactory, HasRoles, Notifiable, TwoFactorAuthenticatable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The attributes that are mass assignable.
|
* The attributes that are mass assignable.
|
||||||
@@ -114,4 +115,102 @@ class User extends Authenticatable implements FilamentUser, HasAppAuthentication
|
|||||||
$this->has_email_authentication = $condition;
|
$this->has_email_authentication = $condition;
|
||||||
$this->save();
|
$this->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tier Checking Helpers ───────────────────────────────────
|
||||||
|
|
||||||
|
public function isFree(): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole('free');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isPro(): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole('pro');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnterprise(): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole('enterprise');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isAdmin(): bool
|
||||||
|
{
|
||||||
|
return $this->hasRole('admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the domain `allowed_types` values this user's tier can access.
|
||||||
|
* Used by Domain::scopeAccessibleBy() to filter domains.
|
||||||
|
*
|
||||||
|
* Mapping:
|
||||||
|
* free → ['public']
|
||||||
|
* pro → ['public', 'custom', 'premium']
|
||||||
|
* enterprise → ['public', 'custom', 'premium', 'private']
|
||||||
|
* admin → ['public', 'custom', 'premium', 'private']
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public function allowedDomainTypes(): array
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$this->isAdmin() => ['public', 'custom', 'premium', 'private'],
|
||||||
|
$this->isEnterprise() => ['public', 'custom', 'premium', 'private'],
|
||||||
|
$this->isPro() => ['public', 'custom', 'premium'],
|
||||||
|
default => ['public'], // free or no role
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain types accessible by guest (non-authenticated) users.
|
||||||
|
* Called when auth()->user() is null.
|
||||||
|
*
|
||||||
|
* @return array<string>
|
||||||
|
*/
|
||||||
|
public static function guestDomainTypes(): array
|
||||||
|
{
|
||||||
|
return ['public'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable tier label for sidebar display.
|
||||||
|
* Returns UPPERCASE string like "FREE", "PRO", "ADMIN".
|
||||||
|
*/
|
||||||
|
public function tierLabel(): string
|
||||||
|
{
|
||||||
|
return match (true) {
|
||||||
|
$this->isAdmin() => 'ADMIN',
|
||||||
|
$this->isEnterprise() => 'ENTERPRISE',
|
||||||
|
$this->isPro() => 'PRO',
|
||||||
|
default => 'FREE',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Eloquent Scopes ─────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to users with the 'free' Spatie role.
|
||||||
|
* Usage: User::free()->get()
|
||||||
|
*/
|
||||||
|
public function scopeFree(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->role('free');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to users with the 'pro' Spatie role.
|
||||||
|
* Usage: User::pro()->get()
|
||||||
|
*/
|
||||||
|
public function scopePro(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->role('pro');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scope to users with the 'enterprise' Spatie role.
|
||||||
|
* Usage: User::enterprise()->get()
|
||||||
|
*/
|
||||||
|
public function scopeEnterprise(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->role('enterprise');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
//
|
\Illuminate\Support\Facades\Event::listen(\Illuminate\Auth\Events\Login::class, function (\Illuminate\Auth\Events\Login $event) {
|
||||||
|
\App\Models\Mailbox::where('session_id', \Illuminate\Support\Facades\Session::getId())
|
||||||
|
->whereNull('user_id')
|
||||||
|
->update(['user_id' => $event->user->id]);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
app/Providers/HorizonServiceProvider.php
Normal file
36
app/Providers/HorizonServiceProvider.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
|
use Laravel\Horizon\Horizon;
|
||||||
|
use Laravel\Horizon\HorizonApplicationServiceProvider;
|
||||||
|
|
||||||
|
class HorizonServiceProvider extends HorizonApplicationServiceProvider
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Bootstrap any application services.
|
||||||
|
*/
|
||||||
|
public function boot(): void
|
||||||
|
{
|
||||||
|
parent::boot();
|
||||||
|
|
||||||
|
// Horizon::routeSmsNotificationsTo('15556667777');
|
||||||
|
// Horizon::routeMailNotificationsTo('example@example.com');
|
||||||
|
// Horizon::routeSlackNotificationsTo('slack-webhook-url', '#channel');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Horizon gate.
|
||||||
|
*
|
||||||
|
* This gate determines who can access Horizon in non-local environments.
|
||||||
|
*/
|
||||||
|
protected function gate(): void
|
||||||
|
{
|
||||||
|
Gate::define('viewHorizon', function ($user = null) {
|
||||||
|
return in_array(optional($user)->email, [
|
||||||
|
//
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
"pest-testing",
|
"pest-testing",
|
||||||
"tailwindcss-development",
|
"tailwindcss-development",
|
||||||
"filament-db-config",
|
"filament-db-config",
|
||||||
"developing-with-fortify"
|
"developing-with-fortify",
|
||||||
|
"bento-landing-page-generator",
|
||||||
|
"cinematic-landing-page-builder",
|
||||||
|
"laravel-bento-saas-builder"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Middleware\VerifyWebhookSecret;
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\Exceptions;
|
use Illuminate\Foundation\Configuration\Exceptions;
|
||||||
use Illuminate\Foundation\Configuration\Middleware;
|
use Illuminate\Foundation\Configuration\Middleware;
|
||||||
@@ -7,11 +8,16 @@ use Illuminate\Foundation\Configuration\Middleware;
|
|||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
web: __DIR__.'/../routes/web.php',
|
web: __DIR__.'/../routes/web.php',
|
||||||
|
api: __DIR__.'/../routes/api.php',
|
||||||
commands: __DIR__.'/../routes/console.php',
|
commands: __DIR__.'/../routes/console.php',
|
||||||
|
channels: __DIR__.'/../routes/channels.php',
|
||||||
health: '/up',
|
health: '/up',
|
||||||
)
|
)
|
||||||
->withMiddleware(function (Middleware $middleware) {
|
->withMiddleware(function (Middleware $middleware) {
|
||||||
//
|
$middleware->trustProxies(at: '*');
|
||||||
|
$middleware->alias([
|
||||||
|
'verify.webhook.secret' => VerifyWebhookSecret::class,
|
||||||
|
]);
|
||||||
})
|
})
|
||||||
->withExceptions(function (Exceptions $exceptions) {
|
->withExceptions(function (Exceptions $exceptions) {
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ return [
|
|||||||
App\Providers\DynamicMailConfigServiceProvider::class,
|
App\Providers\DynamicMailConfigServiceProvider::class,
|
||||||
App\Providers\Filament\DashPanelProvider::class,
|
App\Providers\Filament\DashPanelProvider::class,
|
||||||
App\Providers\FortifyServiceProvider::class,
|
App\Providers\FortifyServiceProvider::class,
|
||||||
|
App\Providers\HorizonServiceProvider::class,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,9 +14,13 @@
|
|||||||
"jacobtims/filament-logger": "^1.0",
|
"jacobtims/filament-logger": "^1.0",
|
||||||
"laravel/fortify": "^1.30",
|
"laravel/fortify": "^1.30",
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
|
"laravel/horizon": "^5.45",
|
||||||
|
"laravel/pulse": "^1.6",
|
||||||
|
"laravel/reverb": "^1.8",
|
||||||
"laravel/tinker": "^2.10.1",
|
"laravel/tinker": "^2.10.1",
|
||||||
"livewire/flux": "^2.1.1",
|
"livewire/flux": "^2.1.1",
|
||||||
"livewire/volt": "^1.10",
|
"livewire/volt": "^1.10",
|
||||||
|
"mongodb/laravel-mongodb": "^5.6",
|
||||||
"spatie/laravel-activitylog": "^4.10",
|
"spatie/laravel-activitylog": "^4.10",
|
||||||
"spatie/laravel-permission": "^6.21"
|
"spatie/laravel-permission": "^6.21"
|
||||||
},
|
},
|
||||||
|
|||||||
1470
composer.lock
generated
1470
composer.lock
generated
File diff suppressed because it is too large
Load Diff
82
config/broadcasting.php
Normal file
82
config/broadcasting.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Broadcaster
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default broadcaster that will be used by the
|
||||||
|
| framework when an event needs to be broadcast. You may set this to
|
||||||
|
| any of the connections defined in the "connections" array below.
|
||||||
|
|
|
||||||
|
| Supported: "reverb", "pusher", "ably", "redis", "log", "null"
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('BROADCAST_CONNECTION', 'null'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Broadcast Connections
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define all of the broadcast connections that will be used
|
||||||
|
| to broadcast events to other systems or over WebSockets. Samples of
|
||||||
|
| each available type of connection are provided inside this array.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'connections' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'driver' => 'reverb',
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
'verify' => env('REVERB_SCHEME', 'https') === 'https' && env('APP_ENV') === 'local' ? false : true,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'pusher' => [
|
||||||
|
'driver' => 'pusher',
|
||||||
|
'key' => env('PUSHER_APP_KEY'),
|
||||||
|
'secret' => env('PUSHER_APP_SECRET'),
|
||||||
|
'app_id' => env('PUSHER_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'cluster' => env('PUSHER_APP_CLUSTER'),
|
||||||
|
'host' => env('PUSHER_HOST') ?: 'api-'.env('PUSHER_APP_CLUSTER', 'mt1').'.pusher.com',
|
||||||
|
'port' => env('PUSHER_PORT', 443),
|
||||||
|
'scheme' => env('PUSHER_SCHEME', 'https'),
|
||||||
|
'encrypted' => true,
|
||||||
|
'useTLS' => env('PUSHER_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'client_options' => [
|
||||||
|
// Guzzle client options: https://docs.guzzlephp.org/en/stable/request-options.html
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'ably' => [
|
||||||
|
'driver' => 'ably',
|
||||||
|
'key' => env('ABLY_KEY'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'log' => [
|
||||||
|
'driver' => 'log',
|
||||||
|
],
|
||||||
|
|
||||||
|
'null' => [
|
||||||
|
'driver' => 'null',
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -112,6 +112,12 @@ return [
|
|||||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'mongodb' => [
|
||||||
|
'driver' => 'mongodb',
|
||||||
|
'dsn' => env('MONGODB_URI', 'mongodb://localhost:27017'),
|
||||||
|
'database' => env('MONGODB_DATABASE', 'imail'),
|
||||||
|
],
|
||||||
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
254
config/horizon.php
Normal file
254
config/horizon.php
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Name
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This name appears in notifications and in the Horizon UI. Unique names
|
||||||
|
| can be useful while running multiple instances of Horizon within an
|
||||||
|
| application, allowing you to identify the Horizon you're viewing.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'name' => env('HORIZON_NAME'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the subdomain where Horizon will be accessible from. If this
|
||||||
|
| setting is null, Horizon will reside under the same domain as the
|
||||||
|
| application. Otherwise, this value will serve as the subdomain.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('HORIZON_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the URI path where Horizon will be accessible from. Feel free
|
||||||
|
| to change this path to anything you like. Note that the URI will not
|
||||||
|
| affect the paths of its internal API that aren't exposed to users.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('HORIZON_PATH', 'horizon'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Redis Connection
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the name of the Redis connection where Horizon will store the
|
||||||
|
| meta information required for it to function. It includes the list
|
||||||
|
| of supervisors, failed jobs, job metrics, and other information.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use' => 'default',
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Redis Prefix
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This prefix will be used when storing all Horizon data in Redis. You
|
||||||
|
| may modify the prefix when you are running multiple installations
|
||||||
|
| of Horizon on the same server so that they don't have problems.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'prefix' => env(
|
||||||
|
'HORIZON_PREFIX',
|
||||||
|
Str::slug(env('APP_NAME', 'laravel'), '_').'_horizon:'
|
||||||
|
),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Horizon Route Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These middleware will get attached onto each Horizon route, giving you
|
||||||
|
| the chance to add your own middleware to this list or change any of
|
||||||
|
| the existing middleware. Or, you can simply stick with this list.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => ['web'],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Wait Time Thresholds
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option allows you to configure when the LongWaitDetected event
|
||||||
|
| will be fired. Every connection / queue combination may have its
|
||||||
|
| own, unique threshold (in seconds) before this event is fired.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'waits' => [
|
||||||
|
'redis:default' => 60,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Job Trimming Times
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you can configure for how long (in minutes) you desire Horizon to
|
||||||
|
| persist the recent and failed jobs. Typically, recent jobs are kept
|
||||||
|
| for one hour while all failed jobs are stored for an entire week.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'trim' => [
|
||||||
|
'recent' => 60,
|
||||||
|
'pending' => 60,
|
||||||
|
'completed' => 60,
|
||||||
|
'recent_failed' => 10080,
|
||||||
|
'failed' => 10080,
|
||||||
|
'monitored' => 10080,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Silenced Jobs
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Silencing a job will instruct Horizon to not place the job in the list
|
||||||
|
| of completed jobs within the Horizon dashboard. This setting may be
|
||||||
|
| used to fully remove any noisy jobs from the completed jobs list.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'silenced' => [
|
||||||
|
// App\Jobs\ExampleJob::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
'silenced_tags' => [
|
||||||
|
// 'notifications',
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Metrics
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you can configure how many snapshots should be kept to display in
|
||||||
|
| the metrics graph. This will get used in combination with Horizon's
|
||||||
|
| `horizon:snapshot` schedule to define how long to retain metrics.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'metrics' => [
|
||||||
|
'trim_snapshots' => [
|
||||||
|
'job' => 24,
|
||||||
|
'queue' => 24,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Fast Termination
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| When this option is enabled, Horizon's "terminate" command will not
|
||||||
|
| wait on all of the workers to terminate unless the --wait option
|
||||||
|
| is provided. Fast termination can shorten deployment delay by
|
||||||
|
| allowing a new instance of Horizon to start while the last
|
||||||
|
| instance will continue to terminate each of its workers.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'fast_termination' => false,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Memory Limit (MB)
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This value describes the maximum amount of memory the Horizon master
|
||||||
|
| supervisor may consume before it is terminated and restarted. For
|
||||||
|
| configuring these limits on your workers, see the next section.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'memory_limit' => 64,
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Queue Worker Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define the queue worker settings used by your application
|
||||||
|
| in all environments. These supervisors and settings handle all your
|
||||||
|
| queued jobs and will be provisioned by Horizon during deployment.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'defaults' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'connection' => 'redis',
|
||||||
|
'queue' => ['default'],
|
||||||
|
'balance' => 'auto',
|
||||||
|
'autoScalingStrategy' => 'time',
|
||||||
|
'maxProcesses' => 1,
|
||||||
|
'maxTime' => 0,
|
||||||
|
'maxJobs' => 0,
|
||||||
|
'memory' => 128,
|
||||||
|
'tries' => 1,
|
||||||
|
'timeout' => 60,
|
||||||
|
'nice' => 0,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'environments' => [
|
||||||
|
'production' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'maxProcesses' => 10,
|
||||||
|
'balanceMaxShift' => 1,
|
||||||
|
'balanceCooldown' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
'local' => [
|
||||||
|
'supervisor-1' => [
|
||||||
|
'maxProcesses' => 3,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| File Watcher Configuration
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following list of directories and files will be watched when using
|
||||||
|
| the `horizon:listen` command. Whenever any directories or files are
|
||||||
|
| changed, Horizon will automatically restart to apply all changes.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'watch' => [
|
||||||
|
'app',
|
||||||
|
'bootstrap',
|
||||||
|
'config/**/*.php',
|
||||||
|
'database/**/*.php',
|
||||||
|
'public/**/*.php',
|
||||||
|
'resources/**/*.php',
|
||||||
|
'routes',
|
||||||
|
'composer.lock',
|
||||||
|
'composer.json',
|
||||||
|
'.env',
|
||||||
|
],
|
||||||
|
];
|
||||||
244
config/pulse.php
Normal file
244
config/pulse.php
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Laravel\Pulse\Http\Middleware\Authorize;
|
||||||
|
use Laravel\Pulse\Pulse;
|
||||||
|
use Laravel\Pulse\Recorders;
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Domain
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the subdomain which the Pulse dashboard will be accessible from.
|
||||||
|
| When set to null, the dashboard will reside under the same domain as
|
||||||
|
| the application. Remember to configure your DNS entries correctly.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'domain' => env('PULSE_DOMAIN'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Path
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This is the path which the Pulse dashboard will be accessible from. Feel
|
||||||
|
| free to change this path to anything you'd like. Note that this won't
|
||||||
|
| affect the path of the internal API that is never exposed to users.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'path' => env('PULSE_PATH', 'pulse'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Master Switch
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration option may be used to completely disable all Pulse
|
||||||
|
| data recorders regardless of their individual configurations. This
|
||||||
|
| provides a single option to quickly disable all Pulse recording.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'enabled' => env('PULSE_ENABLED', true),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Storage Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration option determines which storage driver will be used
|
||||||
|
| while storing entries from Pulse's recorders. In addition, you also
|
||||||
|
| may provide any options to configure the selected storage driver.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'storage' => [
|
||||||
|
'driver' => env('PULSE_STORAGE_DRIVER', 'database'),
|
||||||
|
|
||||||
|
'trim' => [
|
||||||
|
'keep' => env('PULSE_STORAGE_KEEP', '7 days'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'database' => [
|
||||||
|
'connection' => env('PULSE_DB_CONNECTION'),
|
||||||
|
'chunk' => 1000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Ingest Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration options determines the ingest driver that will be used
|
||||||
|
| to capture entries from Pulse's recorders. Ingest drivers are great to
|
||||||
|
| free up your request workers quickly by offloading the data storage.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'ingest' => [
|
||||||
|
'driver' => env('PULSE_INGEST_DRIVER', 'storage'),
|
||||||
|
|
||||||
|
'buffer' => env('PULSE_INGEST_BUFFER', 5_000),
|
||||||
|
|
||||||
|
'trim' => [
|
||||||
|
'lottery' => [1, 1_000],
|
||||||
|
'keep' => env('PULSE_INGEST_KEEP', '7 days'),
|
||||||
|
],
|
||||||
|
|
||||||
|
'redis' => [
|
||||||
|
'connection' => env('PULSE_REDIS_CONNECTION'),
|
||||||
|
'chunk' => 1000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Cache Driver
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This configuration option determines the cache driver that will be used
|
||||||
|
| for various tasks, including caching dashboard results, establishing
|
||||||
|
| locks for events that should only occur on one server and signals.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'cache' => env('PULSE_CACHE_DRIVER'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Route Middleware
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| These middleware will be assigned to every Pulse route, giving you the
|
||||||
|
| chance to add your own middleware to this list or change any of the
|
||||||
|
| existing middleware. Of course, reasonable defaults are provided.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'middleware' => [
|
||||||
|
'web',
|
||||||
|
Authorize::class,
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Pulse Recorders
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| The following array lists the "recorders" that will be registered with
|
||||||
|
| Pulse, along with their configuration. Recorders gather application
|
||||||
|
| event data from requests and tasks to pass to your ingest driver.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'recorders' => [
|
||||||
|
Laravel\Reverb\Pulse\Recorders\ReverbConnections::class => [
|
||||||
|
'sample_rate' => 1,
|
||||||
|
],
|
||||||
|
|
||||||
|
Laravel\Reverb\Pulse\Recorders\ReverbMessages::class => [
|
||||||
|
'sample_rate' => 1,
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\CacheInteractions::class => [
|
||||||
|
'enabled' => env('PULSE_CACHE_INTERACTIONS_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_CACHE_INTERACTIONS_SAMPLE_RATE', 1),
|
||||||
|
'ignore' => [
|
||||||
|
...Pulse::defaultVendorCacheKeys(),
|
||||||
|
],
|
||||||
|
'groups' => [
|
||||||
|
'/^job-exceptions:.*/' => 'job-exceptions:*',
|
||||||
|
// '/:\d+/' => ':*',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\Exceptions::class => [
|
||||||
|
'enabled' => env('PULSE_EXCEPTIONS_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_EXCEPTIONS_SAMPLE_RATE', 1),
|
||||||
|
'location' => env('PULSE_EXCEPTIONS_LOCATION', true),
|
||||||
|
'ignore' => [
|
||||||
|
// '/^Package\\\\Exceptions\\\\/',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\Queues::class => [
|
||||||
|
'enabled' => env('PULSE_QUEUES_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_QUEUES_SAMPLE_RATE', 1),
|
||||||
|
'ignore' => [
|
||||||
|
// '/^Package\\\\Jobs\\\\/',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\Servers::class => [
|
||||||
|
'server_name' => env('PULSE_SERVER_NAME', gethostname()),
|
||||||
|
'directories' => explode(':', env('PULSE_SERVER_DIRECTORIES', '/')),
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\SlowJobs::class => [
|
||||||
|
'enabled' => env('PULSE_SLOW_JOBS_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_SLOW_JOBS_SAMPLE_RATE', 1),
|
||||||
|
'threshold' => env('PULSE_SLOW_JOBS_THRESHOLD', 1000),
|
||||||
|
'ignore' => [
|
||||||
|
// '/^Package\\\\Jobs\\\\/',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\SlowOutgoingRequests::class => [
|
||||||
|
'enabled' => env('PULSE_SLOW_OUTGOING_REQUESTS_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_SLOW_OUTGOING_REQUESTS_SAMPLE_RATE', 1),
|
||||||
|
'threshold' => env('PULSE_SLOW_OUTGOING_REQUESTS_THRESHOLD', 1000),
|
||||||
|
'ignore' => [
|
||||||
|
// '#^http://127\.0\.0\.1:13714#', // Inertia SSR...
|
||||||
|
],
|
||||||
|
'groups' => [
|
||||||
|
// '#^https://api\.github\.com/repos/.*$#' => 'api.github.com/repos/*',
|
||||||
|
// '#^https?://([^/]*).*$#' => '\1',
|
||||||
|
// '#/\d+#' => '/*',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\SlowQueries::class => [
|
||||||
|
'enabled' => env('PULSE_SLOW_QUERIES_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_SLOW_QUERIES_SAMPLE_RATE', 1),
|
||||||
|
'threshold' => env('PULSE_SLOW_QUERIES_THRESHOLD', 1000),
|
||||||
|
'location' => env('PULSE_SLOW_QUERIES_LOCATION', true),
|
||||||
|
'max_query_length' => env('PULSE_SLOW_QUERIES_MAX_QUERY_LENGTH'),
|
||||||
|
'ignore' => [
|
||||||
|
'/(["`])pulse_[\w]+?\1/', // Pulse tables...
|
||||||
|
'/(["`])telescope_[\w]+?\1/', // Telescope tables...
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\SlowRequests::class => [
|
||||||
|
'enabled' => env('PULSE_SLOW_REQUESTS_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_SLOW_REQUESTS_SAMPLE_RATE', 1),
|
||||||
|
'threshold' => env('PULSE_SLOW_REQUESTS_THRESHOLD', 1000),
|
||||||
|
'ignore' => [
|
||||||
|
'#^/'.env('PULSE_PATH', 'pulse').'$#', // Pulse dashboard...
|
||||||
|
'#^/telescope#', // Telescope dashboard...
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\UserJobs::class => [
|
||||||
|
'enabled' => env('PULSE_USER_JOBS_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_USER_JOBS_SAMPLE_RATE', 1),
|
||||||
|
'ignore' => [
|
||||||
|
// '/^Package\\\\Jobs\\\\/',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
Recorders\UserRequests::class => [
|
||||||
|
'enabled' => env('PULSE_USER_REQUESTS_ENABLED', true),
|
||||||
|
'sample_rate' => env('PULSE_USER_REQUESTS_SAMPLE_RATE', 1),
|
||||||
|
'ignore' => [
|
||||||
|
'#^/'.env('PULSE_PATH', 'pulse').'$#', // Pulse dashboard...
|
||||||
|
'#^/telescope#', // Telescope dashboard...
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
100
config/reverb.php
Normal file
100
config/reverb.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Default Reverb Server
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| This option controls the default server used by Reverb to handle
|
||||||
|
| incoming messages as well as broadcasting message to all your
|
||||||
|
| connected clients. At this time only "reverb" is supported.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'default' => env('REVERB_SERVER', 'reverb'),
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Servers
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define details for each of the supported Reverb servers.
|
||||||
|
| Each server has its own configuration options that are defined in
|
||||||
|
| the array below. You should ensure all the options are present.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'servers' => [
|
||||||
|
|
||||||
|
'reverb' => [
|
||||||
|
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
|
||||||
|
'port' => env('REVERB_SERVER_PORT', 8080),
|
||||||
|
'path' => env('REVERB_SERVER_PATH', ''),
|
||||||
|
'hostname' => env('REVERB_HOST'),
|
||||||
|
'options' => [
|
||||||
|
'tls' => env('REVERB_TLS_CERT') ? [
|
||||||
|
'local_cert' => env('REVERB_TLS_CERT'),
|
||||||
|
'local_pk' => env('REVERB_TLS_KEY'),
|
||||||
|
'verify_peer' => false,
|
||||||
|
] : [],
|
||||||
|
],
|
||||||
|
'max_request_size' => env('REVERB_MAX_REQUEST_SIZE', 10_000),
|
||||||
|
'scaling' => [
|
||||||
|
'enabled' => env('REVERB_SCALING_ENABLED', false),
|
||||||
|
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
|
||||||
|
'server' => [
|
||||||
|
'url' => env('REDIS_URL'),
|
||||||
|
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||||
|
'port' => env('REDIS_PORT', '6379'),
|
||||||
|
'username' => env('REDIS_USERNAME'),
|
||||||
|
'password' => env('REDIS_PASSWORD'),
|
||||||
|
'database' => env('REDIS_DB', '0'),
|
||||||
|
'timeout' => env('REDIS_TIMEOUT', 60),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'pulse_ingest_interval' => env('REVERB_PULSE_INGEST_INTERVAL', 15),
|
||||||
|
'telescope_ingest_interval' => env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
/*
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
| Reverb Applications
|
||||||
|
|--------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| Here you may define how Reverb applications are managed. If you choose
|
||||||
|
| to use the "config" provider, you may define an array of apps which
|
||||||
|
| your server will support, including their connection credentials.
|
||||||
|
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
|
||||||
|
'provider' => 'config',
|
||||||
|
|
||||||
|
'apps' => [
|
||||||
|
[
|
||||||
|
'key' => env('REVERB_APP_KEY'),
|
||||||
|
'secret' => env('REVERB_APP_SECRET'),
|
||||||
|
'app_id' => env('REVERB_APP_ID'),
|
||||||
|
'options' => [
|
||||||
|
'host' => env('REVERB_HOST'),
|
||||||
|
'port' => env('REVERB_PORT', 443),
|
||||||
|
'scheme' => env('REVERB_SCHEME', 'https'),
|
||||||
|
'useTLS' => env('REVERB_SCHEME', 'https') === 'https',
|
||||||
|
],
|
||||||
|
'allowed_origins' => explode(',', env('REVERB_ALLOWED_ORIGINS', 'imail.app')),
|
||||||
|
'ping_interval' => env('REVERB_APP_PING_INTERVAL', 60),
|
||||||
|
'activity_timeout' => env('REVERB_APP_ACTIVITY_TIMEOUT', 30),
|
||||||
|
'max_connections' => env('REVERB_APP_MAX_CONNECTIONS'),
|
||||||
|
'max_message_size' => env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
|
||||||
|
'accept_client_events_from' => env('REVERB_APP_ACCEPT_CLIENT_EVENTS_FROM', 'members'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
|
||||||
|
],
|
||||||
|
|
||||||
|
];
|
||||||
@@ -35,4 +35,9 @@ return [
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'mailops' => [
|
||||||
|
'webhook_secret' => env('WEBHOOK_SECRET'),
|
||||||
|
'email_body_ttl_seconds' => (int) env('EMAIL_BODY_TTL_SECONDS', 259200),
|
||||||
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|||||||
27
database/factories/DomainFactory.php
Normal file
27
database/factories/DomainFactory.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Domain>
|
||||||
|
*/
|
||||||
|
class DomainFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain_hash' => bin2hex(random_bytes(32)),
|
||||||
|
'name' => $this->faker->unique()->domainName(),
|
||||||
|
'allowed_types' => ['public', 'premium'],
|
||||||
|
'is_active' => true,
|
||||||
|
'is_archived' => false,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
database/factories/EmailBodyFactory.php
Normal file
35
database/factories/EmailBodyFactory.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\EmailBody;
|
||||||
|
|
||||||
|
class EmailBodyFactory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Create a new EmailBody instance with the given attributes.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public static function make(array $attributes = []): EmailBody
|
||||||
|
{
|
||||||
|
return new EmailBody(array_merge([
|
||||||
|
'unique_id_hash' => hash('sha256', fake()->uuid()),
|
||||||
|
'body_text' => fake()->paragraphs(3, true),
|
||||||
|
'body_html' => '<p>'.implode('</p><p>', fake()->paragraphs(3)).'</p>',
|
||||||
|
], $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and persist a new EmailBody instance.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $attributes
|
||||||
|
*/
|
||||||
|
public static function create(array $attributes = []): EmailBody
|
||||||
|
{
|
||||||
|
$body = static::make($attributes);
|
||||||
|
$body->save();
|
||||||
|
|
||||||
|
return $body;
|
||||||
|
}
|
||||||
|
}
|
||||||
77
database/factories/EmailFactory.php
Normal file
77
database/factories/EmailFactory.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Email;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Email>
|
||||||
|
*/
|
||||||
|
class EmailFactory extends Factory
|
||||||
|
{
|
||||||
|
protected $model = Email::class;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
$domain = fake()->domainName();
|
||||||
|
$bodyText = fake()->paragraphs(3, true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'unique_id_hash' => hash('sha256', fake()->uuid()),
|
||||||
|
'recipient_email' => fake()->userName().'@'.$domain,
|
||||||
|
'recipient_name' => fake()->name(),
|
||||||
|
'sender_email' => fake()->safeEmail(),
|
||||||
|
'sender_name' => fake()->name(),
|
||||||
|
'domain' => $domain,
|
||||||
|
'subject' => fake()->sentence(6),
|
||||||
|
'preview' => mb_substr($bodyText, 0, 500),
|
||||||
|
'attachments_json' => [],
|
||||||
|
'attachment_size' => 0,
|
||||||
|
'is_read' => false,
|
||||||
|
'received_at' => fake()->dateTimeBetween('-7 days', 'now'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark the email as read.
|
||||||
|
*/
|
||||||
|
public function read(): static
|
||||||
|
{
|
||||||
|
return $this->state(fn (array $attributes) => [
|
||||||
|
'is_read' => true,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add attachments to the email.
|
||||||
|
*/
|
||||||
|
public function withAttachments(int $count = 1): static
|
||||||
|
{
|
||||||
|
return $this->state(function (array $attributes) use ($count) {
|
||||||
|
$attachments = [];
|
||||||
|
$totalSize = 0;
|
||||||
|
|
||||||
|
for ($i = 0; $i < $count; $i++) {
|
||||||
|
$size = fake()->numberBetween(1024, 5242880);
|
||||||
|
$totalSize += $size;
|
||||||
|
$attachments[] = [
|
||||||
|
'filename' => fake()->word().'.'.fake()->fileExtension(),
|
||||||
|
'mimeType' => fake()->mimeType(),
|
||||||
|
'size' => $size,
|
||||||
|
's3_path' => 'mail-attachments/'.now()->format('Y/m/d').'/'.fake()->sha256().'_'.fake()->word().'.pdf',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'attachments_json' => $attachments,
|
||||||
|
'attachment_size' => $totalSize,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
33
database/factories/MailboxFactory.php
Normal file
33
database/factories/MailboxFactory.php
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\Domain;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\Mailbox>
|
||||||
|
*/
|
||||||
|
class MailboxFactory extends Factory
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Define the model's default state.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function definition(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mailbox_hash' => bin2hex(random_bytes(32)),
|
||||||
|
'domain_hash' => Domain::factory(),
|
||||||
|
'user_id' => null, // Changed from \App\Models\User::factory() to null as per instruction
|
||||||
|
'session_id' => $this->faker->uuid(),
|
||||||
|
'address' => $this->faker->unique()->safeEmail(),
|
||||||
|
'type' => 'public',
|
||||||
|
'created_ip' => $this->faker->ipv4(),
|
||||||
|
'last_accessed_ip' => $this->faker->ipv4(),
|
||||||
|
'last_accessed_at' => now(),
|
||||||
|
'expires_at' => now()->addDays(7),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Factories;
|
namespace Database\Factories;
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
@@ -41,4 +42,24 @@ class UserFactory extends Factory
|
|||||||
'email_verified_at' => null,
|
'email_verified_at' => null,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function free(): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(fn (User $user) => $user->assignRole('free'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pro(): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(fn (User $user) => $user->assignRole('pro'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enterprise(): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(fn (User $user) => $user->assignRole('enterprise'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function admin(): static
|
||||||
|
{
|
||||||
|
return $this->afterCreating(fn (User $user) => $user->assignRole('admin'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('emails', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->char('unique_id_hash', 64);
|
||||||
|
$table->string('recipient_email');
|
||||||
|
$table->string('recipient_name')->default('');
|
||||||
|
$table->string('sender_email');
|
||||||
|
$table->string('sender_name')->default('');
|
||||||
|
$table->string('domain');
|
||||||
|
$table->string('subject', 500)->default('');
|
||||||
|
$table->string('preview', 500)->default('');
|
||||||
|
$table->json('attachments_json')->nullable();
|
||||||
|
$table->unsignedBigInteger('attachment_size')->default(0);
|
||||||
|
$table->boolean('is_read')->default(false);
|
||||||
|
$table->timestamp('received_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->unique('unique_id_hash', 'idx_unique_hash');
|
||||||
|
$table->index('recipient_email', 'idx_recipient');
|
||||||
|
$table->index('domain', 'idx_domain');
|
||||||
|
$table->index('created_at', 'idx_created_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('emails');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('domains', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->char('domain_hash', 64)->unique()->index();
|
||||||
|
$table->string('name')->unique();
|
||||||
|
$table->json('allowed_types'); // ['public', 'private', 'custom', 'premium']
|
||||||
|
$table->boolean('is_active')->default(true);
|
||||||
|
$table->boolean('is_archived')->default(false);
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('domains');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('mailboxes', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->char('mailbox_hash', 64)->unique()->index();
|
||||||
|
$table->char('domain_hash', 64)->index();
|
||||||
|
$table->foreignId('user_id')->nullable()->constrained()->onDelete('set null');
|
||||||
|
$table->string('session_id')->nullable()->index();
|
||||||
|
$table->string('address')->unique()->index();
|
||||||
|
$table->string('type'); // public, private, custom, premium
|
||||||
|
$table->string('created_ip', 45)->nullable();
|
||||||
|
$table->string('last_accessed_ip', 45)->nullable();
|
||||||
|
$table->timestamp('last_accessed_at')->nullable();
|
||||||
|
$table->boolean('is_blocked')->default(false);
|
||||||
|
$table->text('block_reason')->nullable();
|
||||||
|
$table->timestamp('blocked_at')->nullable();
|
||||||
|
$table->timestamp('expires_at')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('mailboxes');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('mailboxes', function (Blueprint $table) {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('mailboxes', function (Blueprint $table) {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Laravel\Pulse\Support\PulseMigration;
|
||||||
|
|
||||||
|
return new class extends PulseMigration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! $this->shouldRun()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::create('pulse_values', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedInteger('timestamp');
|
||||||
|
$table->string('type');
|
||||||
|
$table->mediumText('key');
|
||||||
|
match ($this->driver()) {
|
||||||
|
'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'),
|
||||||
|
'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'),
|
||||||
|
'sqlite' => $table->string('key_hash'),
|
||||||
|
};
|
||||||
|
$table->mediumText('value');
|
||||||
|
|
||||||
|
$table->index('timestamp'); // For trimming...
|
||||||
|
$table->index('type'); // For fast lookups and purging...
|
||||||
|
$table->unique(['type', 'key_hash']); // For data integrity and upserts...
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('pulse_entries', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedInteger('timestamp');
|
||||||
|
$table->string('type');
|
||||||
|
$table->mediumText('key');
|
||||||
|
match ($this->driver()) {
|
||||||
|
'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'),
|
||||||
|
'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'),
|
||||||
|
'sqlite' => $table->string('key_hash'),
|
||||||
|
};
|
||||||
|
$table->bigInteger('value')->nullable();
|
||||||
|
|
||||||
|
$table->index('timestamp'); // For trimming...
|
||||||
|
$table->index('type'); // For purging...
|
||||||
|
$table->index('key_hash'); // For mapping...
|
||||||
|
$table->index(['timestamp', 'type', 'key_hash', 'value']); // For aggregate queries...
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::create('pulse_aggregates', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedInteger('bucket');
|
||||||
|
$table->unsignedMediumInteger('period');
|
||||||
|
$table->string('type');
|
||||||
|
$table->mediumText('key');
|
||||||
|
match ($this->driver()) {
|
||||||
|
'mariadb', 'mysql' => $table->char('key_hash', 16)->charset('binary')->virtualAs('unhex(md5(`key`))'),
|
||||||
|
'pgsql' => $table->uuid('key_hash')->storedAs('md5("key")::uuid'),
|
||||||
|
'sqlite' => $table->string('key_hash'),
|
||||||
|
};
|
||||||
|
$table->string('aggregate');
|
||||||
|
$table->decimal('value', 20, 2);
|
||||||
|
$table->unsignedInteger('count')->nullable();
|
||||||
|
|
||||||
|
$table->unique(['bucket', 'period', 'type', 'aggregate', 'key_hash']); // Force "on duplicate update"...
|
||||||
|
$table->index(['period', 'bucket']); // For trimming...
|
||||||
|
$table->index('type'); // For purging...
|
||||||
|
$table->index(['period', 'type', 'aggregate', 'bucket']); // For aggregate queries...
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('pulse_values');
|
||||||
|
Schema::dropIfExists('pulse_entries');
|
||||||
|
Schema::dropIfExists('pulse_aggregates');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -15,12 +15,14 @@ class FilamentAdminSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create([
|
$user = User::firstOrCreate(
|
||||||
'name' => 'Admin',
|
['email' => 'admin@example.com'],
|
||||||
'email' => 'admin@example.com',
|
[
|
||||||
'password' => Hash::make('password'),
|
'name' => 'Admin',
|
||||||
'email_verified_at' => now(),
|
'password' => Hash::make('password'),
|
||||||
]);
|
'email_verified_at' => now(),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
$user->assignRole('admin');
|
$user->assignRole('admin');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use Illuminate\Database\Console\Seeds\WithoutModelEvents;
|
use App\Models\User;
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Spatie\Permission\Models\Permission;
|
use Spatie\Permission\Models\Permission;
|
||||||
use Spatie\Permission\Models\Role;
|
use Spatie\Permission\Models\Role;
|
||||||
@@ -14,13 +14,30 @@ class RoleSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$role = Role::findOrCreate(name: 'admin', guardName: 'web');
|
// Clear Spatie's permission cache before seeding
|
||||||
Role::findOrCreate(name: 'user', guardName: 'web');
|
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
|
||||||
|
|
||||||
$permissionManageMails = Permission::findOrCreate(name: 'manage mails', guardName: 'web');
|
// --- Create Tier Roles ---
|
||||||
$role->givePermissionTo($permissionManageMails);
|
$admin = Role::findOrCreate('admin', 'web');
|
||||||
|
Role::findOrCreate('free', 'web');
|
||||||
|
Role::findOrCreate('pro', 'web');
|
||||||
|
Role::findOrCreate('enterprise', 'web');
|
||||||
|
|
||||||
$permissionManageFilamentPanel = Permission::findOrCreate(name: 'manage panels', guardName: 'web');
|
// --- Permissions (admin-only) ---
|
||||||
$role->givePermissionTo($permissionManageFilamentPanel);
|
$manageMailsPerm = Permission::findOrCreate('manage mails', 'web');
|
||||||
|
$managePanelsPerm = Permission::findOrCreate('manage panels', 'web');
|
||||||
|
$admin->syncPermissions([$manageMailsPerm, $managePanelsPerm]);
|
||||||
|
|
||||||
|
// --- Migrate legacy 'user' role to 'free' ---
|
||||||
|
$legacyRole = Role::where('name', 'user')->where('guard_name', 'web')->first();
|
||||||
|
if ($legacyRole) {
|
||||||
|
// Move all users with 'user' role to 'free'
|
||||||
|
$usersWithLegacyRole = \App\Models\User::role('user')->get();
|
||||||
|
foreach ($usersWithLegacyRole as $user) {
|
||||||
|
$user->removeRole('user');
|
||||||
|
$user->assignRole('free');
|
||||||
|
}
|
||||||
|
$legacyRole->delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
34
docker/entrypoint.sh
Normal file
34
docker/entrypoint.sh
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Starting iMail container initialization..."
|
||||||
|
|
||||||
|
# Ensure storage directories exist
|
||||||
|
mkdir -p /var/www/html/storage/framework/cache/data
|
||||||
|
mkdir -p /var/www/html/storage/framework/sessions
|
||||||
|
mkdir -p /var/www/html/storage/framework/views
|
||||||
|
mkdir -p /var/www/html/storage/logs
|
||||||
|
mkdir -p /var/www/html/storage/app/public
|
||||||
|
mkdir -p /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
|
# Fix permissions
|
||||||
|
chown -R www-data:www-data /var/www/html/storage
|
||||||
|
chown -R www-data:www-data /var/www/html/bootstrap/cache
|
||||||
|
|
||||||
|
# Cache configuration, routes, views, events
|
||||||
|
echo "Caching configuration and routes..."
|
||||||
|
php artisan config:cache
|
||||||
|
php artisan route:cache
|
||||||
|
php artisan view:cache
|
||||||
|
php artisan event:cache
|
||||||
|
|
||||||
|
# Create storage symlink
|
||||||
|
php artisan storage:link --force
|
||||||
|
|
||||||
|
# Run migrations (non-fatal — don't crash the container if a migration fails)
|
||||||
|
echo "Running migrations..."
|
||||||
|
php artisan migrate --force || echo "WARNING: Migrations failed. The container will continue starting. Please check the migration error above and fix manually."
|
||||||
|
|
||||||
|
echo "Initialization complete. Starting Supervisord..."
|
||||||
|
# Execute supervisord in the foreground
|
||||||
|
exec /usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf
|
||||||
72
docker/nginx.conf
Normal file
72
docker/nginx.conf
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /var/www/html/public;
|
||||||
|
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN";
|
||||||
|
add_header X-Content-Type-Options "nosniff";
|
||||||
|
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
charset utf-8;
|
||||||
|
|
||||||
|
client_max_body_size 64M;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_types text/plain text/css text/xml application/json application/javascript application/rss+xml application/atom+xml image/svg+xml;
|
||||||
|
|
||||||
|
# Laravel routes
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pulse Dashboard
|
||||||
|
location = /pulse {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Horizon Dashboard
|
||||||
|
location = /horizon {
|
||||||
|
try_files $uri $uri/ /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Reverb WebSocket Proxy
|
||||||
|
# The trailing slash on proxy_pass strips the /_ws prefix:
|
||||||
|
# /_ws/app/{key} → /app/{key}
|
||||||
|
location /_ws/ {
|
||||||
|
proxy_pass http://127.0.0.1:8080/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header Scheme $scheme;
|
||||||
|
proxy_set_header SERVER_PORT $server_port;
|
||||||
|
proxy_set_header REMOTE_ADDR $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "Upgrade";
|
||||||
|
proxy_read_timeout 60m;
|
||||||
|
proxy_connect_timeout 60m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pass PHP scripts to FastCGI server
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass 127.0.0.1:9000;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
fastcgi_hide_header X-Powered-By;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Cache static assets (with fallback to PHP for virtual routes like /livewire/livewire.js)
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
try_files $uri /index.php?$query_string;
|
||||||
|
expires 30d;
|
||||||
|
add_header Cache-Control "public, no-transform";
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\.(?!well-known).* {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
21
docker/php-fpm.conf
Normal file
21
docker/php-fpm.conf
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[global]
|
||||||
|
error_log = /dev/stderr
|
||||||
|
|
||||||
|
[www]
|
||||||
|
user = www-data
|
||||||
|
group = www-data
|
||||||
|
|
||||||
|
listen = 127.0.0.1:9000
|
||||||
|
|
||||||
|
pm = dynamic
|
||||||
|
pm.max_children = 50
|
||||||
|
pm.start_servers = 5
|
||||||
|
pm.min_spare_servers = 5
|
||||||
|
pm.max_spare_servers = 35
|
||||||
|
pm.max_requests = 500
|
||||||
|
|
||||||
|
clear_env = no
|
||||||
|
catch_workers_output = yes
|
||||||
|
decorate_workers_output = no
|
||||||
|
|
||||||
|
access.log = /dev/null
|
||||||
21
docker/php.ini
Normal file
21
docker/php.ini
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[PHP]
|
||||||
|
expose_php = Off
|
||||||
|
display_errors = Off
|
||||||
|
display_startup_errors = Off
|
||||||
|
log_errors = On
|
||||||
|
error_log = /dev/stderr
|
||||||
|
memory_limit = 256M
|
||||||
|
upload_max_filesize = 64M
|
||||||
|
post_max_size = 64M
|
||||||
|
max_execution_time = 60
|
||||||
|
max_input_time = 60
|
||||||
|
variables_order = "EGPCS"
|
||||||
|
|
||||||
|
[opcache]
|
||||||
|
opcache.enable=1
|
||||||
|
opcache.memory_consumption=256
|
||||||
|
opcache.interned_strings_buffer=16
|
||||||
|
opcache.max_accelerated_files=20000
|
||||||
|
opcache.validate_timestamps=0
|
||||||
|
opcache.save_comments=1
|
||||||
|
opcache.fast_shutdown=1
|
||||||
83
docker/supervisord.conf
Normal file
83
docker/supervisord.conf
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
[supervisord]
|
||||||
|
nodaemon=true
|
||||||
|
user=root
|
||||||
|
logfile=/dev/null
|
||||||
|
logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:php-fpm]
|
||||||
|
command=php-fpm --nodaemonize --fpm-config /usr/local/etc/php-fpm.d/www.conf
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=5
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:nginx]
|
||||||
|
command=nginx -g "daemon off;"
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=10
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:horizon]
|
||||||
|
command=php /var/www/html/artisan horizon
|
||||||
|
user=www-data
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=15
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=3600
|
||||||
|
|
||||||
|
[program:scheduler]
|
||||||
|
command=/bin/sh -c "while sleep 60; do php /var/www/html/artisan schedule:run; done"
|
||||||
|
user=www-data
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=20
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:reverb]
|
||||||
|
command=php /var/www/html/artisan reverb:start --host=0.0.0.0 --port=8080
|
||||||
|
user=www-data
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=25
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
|
||||||
|
[program:pulse-check]
|
||||||
|
command=php /var/www/html/artisan pulse:check
|
||||||
|
user=www-data
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=30
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=3600
|
||||||
|
|
||||||
|
[program:pulse-work]
|
||||||
|
command=php /var/www/html/artisan pulse:work
|
||||||
|
user=www-data
|
||||||
|
autostart=true
|
||||||
|
autorestart=true
|
||||||
|
priority=35
|
||||||
|
stdout_logfile=/dev/stdout
|
||||||
|
stdout_logfile_maxbytes=0
|
||||||
|
stderr_logfile=/dev/stderr
|
||||||
|
stderr_logfile_maxbytes=0
|
||||||
|
stopwaitsecs=3600
|
||||||
BIN
install.log
Normal file
BIN
install.log
Normal file
Binary file not shown.
162
laravel_webhook_handover.md
Normal file
162
laravel_webhook_handover.md
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
# MailOps Webhook Handover Document
|
||||||
|
|
||||||
|
This document provides the exact specifications needed to implement the receiving end of the MailOps email synchronization system within the Laravel application.
|
||||||
|
|
||||||
|
## 1. Webhook Endpoint Specification
|
||||||
|
|
||||||
|
The MailOps worker will push new emails to this exact endpoint on your Laravel server:
|
||||||
|
|
||||||
|
* **URL:** `POST https://your-laravel-app.com/api/webhooks/incoming_email`
|
||||||
|
* **Headers:**
|
||||||
|
* `Content-Type: application/json`
|
||||||
|
* `Authorization: Bearer <CONFIGURED_WEBHOOK_SECRET>` (You must configure this secret in both MailOps and Laravel).
|
||||||
|
|
||||||
|
### A. Expected JSON Payload (With Attachments)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hash": "a1b2c3d4e5f6g7h8i9j0...",
|
||||||
|
"metadata": {
|
||||||
|
"hash": "a1b2c3d4e5f6g7h8i9j0...",
|
||||||
|
"recipientEmail": "user@example.com",
|
||||||
|
"recipientName": "John Doe",
|
||||||
|
"senderEmail": "alert@service.com",
|
||||||
|
"senderName": "Service Alerts",
|
||||||
|
"domain": "example.com",
|
||||||
|
"subject": "Important Notification",
|
||||||
|
"received_at": "2026-02-26T17:35:00Z",
|
||||||
|
"attachments": [
|
||||||
|
{
|
||||||
|
"filename": "invoice.pdf",
|
||||||
|
"mimeType": "application/pdf",
|
||||||
|
"size": 102400,
|
||||||
|
"s3_path": "mail-attachments/2026/02/26/hash_invoice.pdf"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"attachmentSize": 102400
|
||||||
|
},
|
||||||
|
"bodyText": "Plain text content...",
|
||||||
|
"bodyHtml": "<html>HTML content...</html>"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
*(Note: `received_at` is in ISO 8601 format ending with `Z` to explicitly denote UTC. `bodyHtml` and `bodyText` are completely separated from the metadata to optimize database payload sizes).*
|
||||||
|
|
||||||
|
### B. Expected JSON Payload (NO Attachments)
|
||||||
|
When an email has no attachments, the `attachments` array will be empty and `attachmentSize` will be zero. Also, depending on the email client, `bodyHtml` or `bodyText` might be `null`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"hash": "b2c3d4e5f6g7h8i9j0a1...",
|
||||||
|
"metadata": {
|
||||||
|
"hash": "b2c3d4e5f6g7h8i9j0a1...",
|
||||||
|
"recipientEmail": "user@example.com",
|
||||||
|
"recipientName": "",
|
||||||
|
"senderEmail": "friend@service.com",
|
||||||
|
"senderName": "Friend",
|
||||||
|
"domain": "example.com",
|
||||||
|
"subject": "Quick Question",
|
||||||
|
"received_at": "2026-02-26T17:38:12Z",
|
||||||
|
"attachments": [],
|
||||||
|
"attachmentSize": 0
|
||||||
|
},
|
||||||
|
"bodyText": "Hey, are we still fast approaching the deadline?",
|
||||||
|
"bodyHtml": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Laravel Implementation Checklist
|
||||||
|
|
||||||
|
When you switch to the Laravel project, you need to build the following:
|
||||||
|
|
||||||
|
### Step 1: Route & Middleware
|
||||||
|
Define the API route and protect it with a simple Bearer token check.
|
||||||
|
```php
|
||||||
|
// routes/api.php
|
||||||
|
Route::post('/webhooks/incoming_email', [EmailWebhookController::class, 'handle'])
|
||||||
|
->middleware('verify.webhook.secret');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: The Controller
|
||||||
|
The controller persists the metadata to MariaDB and the heavy body to MongoDB. **Crucially**, it also checks if the MongoDB TTL index exists, and if not, automatically creates it using the value defined in your Laravel `.env` file (e.g., `EMAIL_BODY_TTL_SECONDS=259200`).
|
||||||
|
|
||||||
|
```php
|
||||||
|
// app/Http/Controllers/EmailWebhookController.php
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
|
||||||
|
public function handle(Request $request)
|
||||||
|
{
|
||||||
|
$payload = $request->all();
|
||||||
|
$meta = $payload['metadata'];
|
||||||
|
$hash = $payload['hash'];
|
||||||
|
|
||||||
|
// 1. Auto-Setup MongoDB TTL Index (Executes only once via Cache)
|
||||||
|
$this->ensureMongoTtlIndexExists();
|
||||||
|
|
||||||
|
// 2. MariaDB: Save Metadata
|
||||||
|
Email::updateOrCreate(
|
||||||
|
['unique_id_hash' => $hash],
|
||||||
|
[
|
||||||
|
'recipient_email' => $meta['recipientEmail'],
|
||||||
|
'sender_email' => $meta['senderEmail'],
|
||||||
|
'subject' => $meta['subject'] ?? '',
|
||||||
|
'is_read' => false,
|
||||||
|
// Parse the ISO 8601 UTC timestamp format explicitly for SQL
|
||||||
|
'received_at' => Carbon::parse($meta['received_at'])->setTimezone('UTC')->toDateTimeString(),
|
||||||
|
// Store attachments JSON. If empty, ensure it's saved as an empty array '[]'
|
||||||
|
'attachments' => !empty($meta['attachments']) ? json_encode($meta['attachments']) : '[]',
|
||||||
|
'attachment_size' => $meta['attachmentSize'] ?? 0
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. MongoDB: Save the heavy body with TTL
|
||||||
|
// Assuming you have the jenssegers/mongodb package installed
|
||||||
|
RecentEmailBody::updateOrCreate(
|
||||||
|
['unique_id_hash' => $hash],
|
||||||
|
[
|
||||||
|
// Handle cases where the sender only sends Text or only HTML
|
||||||
|
'body_text' => $payload['bodyText'] ?? '',
|
||||||
|
'body_html' => $payload['bodyHtml'] ?? '',
|
||||||
|
'created_at' => new \MongoDB\BSON\UTCDateTime(now()->timestamp * 1000), // BSON required for TTL
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'success'], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensures the TTL index is created on the MongoDB collection.
|
||||||
|
* Uses Laravel Cache to avoid checking the database on every single webhook.
|
||||||
|
*/
|
||||||
|
private function ensureMongoTtlIndexExists()
|
||||||
|
{
|
||||||
|
Cache::rememberForever('mongo_ttl_index_created', function () {
|
||||||
|
// Fetch TTL from Laravel .env (Default: 72 hours / 259200 seconds)
|
||||||
|
$ttlSeconds = (int) env('EMAIL_BODY_TTL_SECONDS', 259200);
|
||||||
|
|
||||||
|
$collection = DB::connection('mongodb')->getCollection('recent_email_bodies');
|
||||||
|
|
||||||
|
// Background creation prevents locking the database during webhook execution
|
||||||
|
$collection->createIndex(
|
||||||
|
['created_at' => 1],
|
||||||
|
[
|
||||||
|
'expireAfterSeconds' => $ttlSeconds,
|
||||||
|
'background' => true,
|
||||||
|
'name' => 'ttl_created_at_index' // Named index prevents duplicate recreation errors
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Resiliency Notes
|
||||||
|
|
||||||
|
* **Idempotency:** The MailOps worker might retry a webhook if a network timeout occurs even after Laravel successfully saved it. Your Laravel code MUST use `updateOrCreate` or `INSERT IGNORE` (like the example above) so it doesn't create duplicate emails if the same payload hash is received twice.
|
||||||
|
* **Timeouts:** The MailOps worker expects a response within 5 to 10 seconds. Do not perform long-running synchronous tasks (like connecting to external APIs or sending heavy push notifications) inside the webhook controller. Dispatch those to a Laravel Queue instead.
|
||||||
162
package-lock.json
generated
162
package-lock.json
generated
@@ -4,7 +4,6 @@
|
|||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "imail",
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tailwindcss/vite": "^4.1.11",
|
"@tailwindcss/vite": "^4.1.11",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
@@ -14,6 +13,10 @@
|
|||||||
"tailwindcss": "^4.0.7",
|
"tailwindcss": "^4.0.7",
|
||||||
"vite": "^7.0.4"
|
"vite": "^7.0.4"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"laravel-echo": "^2.3.0",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||||
@@ -821,6 +824,14 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@socket.io/component-emitter": {
|
||||||
|
"version": "3.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz",
|
||||||
|
"integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/@tailwindcss/node": {
|
"node_modules/@tailwindcss/node": {
|
||||||
"version": "4.1.11",
|
"version": "4.1.11",
|
||||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.11.tgz",
|
||||||
@@ -1338,6 +1349,25 @@
|
|||||||
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
"url": "https://github.com/open-cli-tools/concurrently?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/delayed-stream": {
|
"node_modules/delayed-stream": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||||
@@ -1382,6 +1412,32 @@
|
|||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/engine.io-client": {
|
||||||
|
"version": "6.6.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz",
|
||||||
|
"integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-parser": "~5.2.1",
|
||||||
|
"ws": "~8.18.3",
|
||||||
|
"xmlhttprequest-ssl": "~2.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/engine.io-parser": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/enhanced-resolve": {
|
"node_modules/enhanced-resolve": {
|
||||||
"version": "5.18.2",
|
"version": "5.18.2",
|
||||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
|
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz",
|
||||||
@@ -1709,6 +1765,20 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/laravel-echo": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/laravel-echo/-/laravel-echo-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-wgHPnnBvfHmu2I58xJ4asZH37Nu6P0472ku6zuoGRLc3zEWwIbpovDLYTiOshDH1SM7rA6AjZTKuu+jYoM1tpQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pusher-js": "*",
|
||||||
|
"socket.io-client": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/laravel-vite-plugin": {
|
"node_modules/laravel-vite-plugin": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/laravel-vite-plugin/-/laravel-vite-plugin-2.0.0.tgz",
|
||||||
@@ -2022,6 +2092,14 @@
|
|||||||
"node": ">= 18"
|
"node": ">= 18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@@ -2113,6 +2191,16 @@
|
|||||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pusher-js": {
|
||||||
|
"version": "8.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pusher-js/-/pusher-js-8.4.0.tgz",
|
||||||
|
"integrity": "sha512-wp3HqIIUc1GRyu1XrP6m2dgyE9MoCsXVsWNlohj0rjSkLf+a0jLvEyVubdg58oMk7bhjBWnFClgp8jfAa6Ak4Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tweetnacl": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -2200,6 +2288,38 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/socket.io-client": {
|
||||||
|
"version": "4.8.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz",
|
||||||
|
"integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1",
|
||||||
|
"engine.io-client": "~6.6.1",
|
||||||
|
"socket.io-parser": "~4.2.4"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/socket.io-parser": {
|
||||||
|
"version": "4.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.5.tgz",
|
||||||
|
"integrity": "sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@socket.io/component-emitter": "~3.1.0",
|
||||||
|
"debug": "~4.4.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@@ -2312,6 +2432,13 @@
|
|||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"license": "0BSD"
|
"license": "0BSD"
|
||||||
},
|
},
|
||||||
|
"node_modules/tweetnacl": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-6rt+RN7aOi1nGMyC4Xa5DdYiukl2UWCbcJft7YhxReBGQD7OAM8Pbxw6YMo4r2diNEA8FEmu32YOn9rhaiE5yw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.1.3",
|
"version": "1.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
|
||||||
@@ -2455,6 +2582,39 @@
|
|||||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.18.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz",
|
||||||
|
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlhttprequest-ssl": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==",
|
||||||
|
"dev": true,
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -18,5 +18,9 @@
|
|||||||
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
"@rollup/rollup-linux-x64-gnu": "4.9.5",
|
||||||
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
"@tailwindcss/oxide-linux-x64-gnu": "^4.0.1",
|
||||||
"lightningcss-linux-x64-gnu": "^1.29.1"
|
"lightningcss-linux-x64-gnu": "^1.29.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"laravel-echo": "^2.3.0",
|
||||||
|
"pusher-js": "^8.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
<svg width="166" height="166" viewBox="0 0 166 166" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M162.041 38.7592C162.099 38.9767 162.129 39.201 162.13 39.4264V74.4524C162.13 74.9019 162.011 75.3435 161.786 75.7325C161.561 76.1216 161.237 76.4442 160.847 76.6678L131.462 93.5935V127.141C131.462 128.054 130.977 128.897 130.186 129.357L68.8474 164.683C68.707 164.763 68.5538 164.814 68.4007 164.868C68.3432 164.887 68.289 164.922 68.2284 164.938C67.7996 165.051 67.3489 165.051 66.9201 164.938C66.8499 164.919 66.7861 164.881 66.7191 164.855C66.5787 164.804 66.4319 164.76 66.2979 164.683L4.97219 129.357C4.58261 129.133 4.2589 128.81 4.0337 128.421C3.8085 128.032 3.68976 127.591 3.68945 127.141L3.68945 22.0634C3.68945 21.8336 3.72136 21.6101 3.7788 21.393C3.79794 21.3196 3.84262 21.2526 3.86814 21.1791C3.91601 21.0451 3.96068 20.9078 4.03088 20.7833C4.07874 20.7003 4.14894 20.6333 4.20638 20.5566C4.27977 20.4545 4.34678 20.3491 4.43293 20.2598C4.50632 20.1863 4.60205 20.1321 4.68501 20.0682C4.77755 19.9916 4.86051 19.9086 4.96581 19.848L35.6334 2.18492C36.0217 1.96139 36.4618 1.84375 36.9098 1.84375C37.3578 1.84375 37.7979 1.96139 38.1862 2.18492L68.8506 19.848H68.857C68.9591 19.9118 69.0452 19.9916 69.1378 20.065C69.2207 20.1289 69.3133 20.1863 69.3867 20.2566C69.476 20.3491 69.5398 20.4545 69.6164 20.5566C69.6707 20.6333 69.7441 20.7003 69.7887 20.7833C69.8621 20.911 69.9036 21.0451 69.9546 21.1791C69.9802 21.2526 70.0248 21.3196 70.044 21.3962C70.1027 21.6138 70.1328 21.8381 70.1333 22.0634V87.6941L95.686 72.9743V39.4232C95.686 39.1997 95.7179 38.9731 95.7753 38.7592C95.7977 38.6826 95.8391 38.6155 95.8647 38.5421C95.9157 38.408 95.9604 38.2708 96.0306 38.1463C96.0785 38.0633 96.1487 37.9962 96.2029 37.9196C96.2795 37.8175 96.3433 37.7121 96.4326 37.6227C96.506 37.5493 96.5986 37.495 96.6815 37.4312C96.7773 37.3546 96.8602 37.2716 96.9623 37.2109L127.633 19.5479C128.021 19.324 128.461 19.2062 128.91 19.2062C129.358 19.2062 129.798 19.324 130.186 19.5479L160.85 37.2109C160.959 37.2748 161.042 37.3546 161.137 37.428C161.217 37.4918 161.31 37.5493 161.383 37.6195C161.473 37.7121 161.536 37.8175 161.613 37.9196C161.67 37.9962 161.741 38.0633 161.785 38.1463C161.859 38.2708 161.9 38.408 161.951 38.5421C161.98 38.6155 162.021 38.6826 162.041 38.7592ZM157.018 72.9743V43.8477L146.287 50.028L131.462 58.5675V87.6941L157.021 72.9743H157.018ZM126.354 125.663V96.5176L111.771 104.85L70.1301 128.626V158.046L126.354 125.663ZM8.80126 26.4848V125.663L65.0183 158.043V128.629L35.6494 112L35.6398 111.994L35.6271 111.988C35.5281 111.93 35.4452 111.847 35.3526 111.777C35.2729 111.713 35.1803 111.662 35.1101 111.592L35.1038 111.582C35.0208 111.502 34.9634 111.403 34.8932 111.314C34.8293 111.228 34.7528 111.154 34.7017 111.065L34.6985 111.055C34.6411 110.96 34.606 110.845 34.5645 110.736C34.523 110.64 34.4688 110.551 34.4432 110.449C34.4113 110.328 34.4049 110.197 34.3922 110.072C34.3794 109.976 34.3539 109.881 34.3539 109.785V109.778V41.2045L19.5322 32.6619L8.80126 26.4848ZM36.913 7.35007L11.3635 22.0634L36.9066 36.7768L62.4529 22.0602L36.9066 7.35007H36.913ZM50.1999 99.1736L65.0215 90.6374V26.4848L54.2906 32.6651L39.4657 41.2045V105.357L50.1999 99.1736ZM128.91 24.713L103.363 39.4264L128.91 54.1397L154.453 39.4232L128.91 24.713ZM126.354 58.5675L111.529 50.028L100.798 43.8477V72.9743L115.619 81.5106L126.354 87.6941V58.5675ZM67.5711 124.205L105.042 102.803L123.772 92.109L98.2451 77.4053L68.8538 94.3341L42.0663 109.762L67.5711 124.205Z" fill="#FF2D20"/>
|
<rect width="32" height="32" rx="8" fill="#18181b"/>
|
||||||
|
<path d="M8 24L24 8M8 24H18M24 8H14" stroke="#ec4899" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<circle cx="26" cy="6" r="2" fill="#10b981"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 334 B |
BIN
public/images/logo-dark.webp
Normal file
BIN
public/images/logo-dark.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -23,6 +23,13 @@
|
|||||||
--color-zinc-900: #171717;
|
--color-zinc-900: #171717;
|
||||||
--color-zinc-950: #0a0a0a;
|
--color-zinc-950: #0a0a0a;
|
||||||
|
|
||||||
|
--color-app-bg: #09090B;
|
||||||
|
--color-app-surface: #18181B;
|
||||||
|
--color-app-border: #27272A;
|
||||||
|
--color-app-red: #EC6A5F;
|
||||||
|
--color-app-yellow: #F4BF4F;
|
||||||
|
--color-app-green: #61C554;
|
||||||
|
|
||||||
--color-accent: var(--color-neutral-800);
|
--color-accent: var(--color-neutral-800);
|
||||||
--color-accent-content: var(--color-neutral-800);
|
--color-accent-content: var(--color-neutral-800);
|
||||||
--color-accent-foreground: var(--color-white);
|
--color-accent-foreground: var(--color-white);
|
||||||
@@ -77,7 +84,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
[data-flux-label] {
|
[data-flux-label] {
|
||||||
@apply !mb-0 !leading-tight;
|
@apply !mb-0 !leading-tight;
|
||||||
}
|
}
|
||||||
|
|
||||||
input:focus[data-flux-control],
|
input:focus[data-flux-control],
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import Echo from 'laravel-echo';
|
||||||
|
|
||||||
|
import Pusher from 'pusher-js';
|
||||||
|
window.Pusher = Pusher;
|
||||||
|
|
||||||
|
if (document.querySelector('[data-requires-reverb]')) {
|
||||||
|
window.Echo = new Echo({
|
||||||
|
broadcaster: 'reverb',
|
||||||
|
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||||
|
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||||
|
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||||
|
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||||
|
wsPath: import.meta.env.VITE_REVERB_PATH ?? '',
|
||||||
|
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? 'https') === 'https',
|
||||||
|
enabledTransports: ['ws', 'wss'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle WebSocket connection status events
|
||||||
|
const dispatchStatus = (connected) => {
|
||||||
|
window.dispatchEvent(new CustomEvent('ws-status', {
|
||||||
|
detail: { connected }
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
window.Echo.connector.pusher.connection.bind('connected', () => dispatchStatus(true));
|
||||||
|
window.Echo.connector.pusher.connection.bind('unavailable', () => dispatchStatus(false));
|
||||||
|
window.Echo.connector.pusher.connection.bind('disconnected', () => dispatchStatus(false));
|
||||||
|
window.Echo.connector.pusher.connection.bind('failed', () => dispatchStatus(false));
|
||||||
|
}
|
||||||
|
|||||||
45
resources/views/components/bento/card.blade.php
Normal file
45
resources/views/components/bento/card.blade.php
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
@props(['span' => 'col-span-1', 'rowSpan' => 'row-span-1', 'title', 'subtitle' => null])
|
||||||
|
<div
|
||||||
|
class="relative group rounded-3xl bg-app-surface border border-app-border p-8 lg:p-10 transition-all {{ $span }} {{ $rowSpan }} opacity-0 translate-y-10"
|
||||||
|
x-data="{ x: 0, y: 0 }"
|
||||||
|
x-on:mousemove="
|
||||||
|
const rect = $el.getBoundingClientRect();
|
||||||
|
x = $event.clientX - rect.left;
|
||||||
|
y = $event.clientY - rect.top;
|
||||||
|
"
|
||||||
|
x-init="gsap.to($el, {
|
||||||
|
scrollTrigger: { trigger: $el, start: 'top 85%' },
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'power3.out'
|
||||||
|
})"
|
||||||
|
>
|
||||||
|
<!-- Hover Spotlight -->
|
||||||
|
<div class="pointer-events-none absolute -inset-px rounded-3xl opacity-0 transition duration-300 group-hover:opacity-100 mix-blend-screen"
|
||||||
|
x-bind:style="`background: radial-gradient(600px circle at ${x}px ${y}px, rgba(236,72,153,0.1), transparent 40%);`">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Spotlight Glow inside border -->
|
||||||
|
<div class="pointer-events-none absolute inset-0 rounded-3xl opacity-0 transition duration-300 group-hover:opacity-100"
|
||||||
|
x-bind:style="`background: radial-gradient(400px circle at ${x}px ${y}px, rgba(255,255,255,0.06), transparent 40%);`">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 flex flex-col h-full">
|
||||||
|
@if(isset($icon))
|
||||||
|
<div class="mb-4 text-pink-500 w-8 h-8">
|
||||||
|
{{ $icon }}
|
||||||
|
</div>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<h3 class="text-xl font-semibold text-white mb-2">{{ $title }}</h3>
|
||||||
|
|
||||||
|
@if($subtitle)
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed mb-6">{{ $subtitle }}</p>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="mt-auto relative w-full flex-grow flex flex-col justify-end">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
19
resources/views/components/bento/code-window.blade.php
Normal file
19
resources/views/components/bento/code-window.blade.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
@props(['filename' => 'api-request.sh'])
|
||||||
|
<div class="rounded-xl overflow-hidden border border-app-border bg-app-bg/80 backdrop-blur-sm shadow-2xl relative w-full mx-auto">
|
||||||
|
<!-- Window Controls -->
|
||||||
|
<div class="flex items-center px-4 py-3 border-b border-app-border bg-app-surface/50">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-red-500/80"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full bg-yellow-500/80"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full bg-green-500/80"></div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex-1 text-center font-mono text-xs text-zinc-500 select-none">
|
||||||
|
{{ $filename }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code Content -->
|
||||||
|
<div class="p-4 sm:p-6 overflow-x-auto font-mono text-sm leading-relaxed text-zinc-300">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
103
resources/views/components/bento/confirm-modal.blade.php
Normal file
103
resources/views/components/bento/confirm-modal.blade.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
@props([
|
||||||
|
'name' => 'confirm-modal',
|
||||||
|
])
|
||||||
|
|
||||||
|
<div x-show="show"
|
||||||
|
x-data="{
|
||||||
|
show: false,
|
||||||
|
title: '',
|
||||||
|
message: '',
|
||||||
|
confirmLabel: 'Confirm',
|
||||||
|
type: 'danger',
|
||||||
|
action: null,
|
||||||
|
open(data) {
|
||||||
|
this.title = data.title || 'Are you sure?';
|
||||||
|
this.message = data.message || 'This action cannot be undone.';
|
||||||
|
this.confirmLabel = data.confirmLabel || 'Confirm';
|
||||||
|
this.type = data.type || 'danger';
|
||||||
|
this.action = data.action || null;
|
||||||
|
this.show = true;
|
||||||
|
},
|
||||||
|
confirm() {
|
||||||
|
if (this.action) {
|
||||||
|
this.action();
|
||||||
|
}
|
||||||
|
this.show = false;
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
x-on:open-{{ $name }}.window="open($event.detail)"
|
||||||
|
class="fixed inset-0 z-[110] flex items-center justify-center p-4 lg:p-8"
|
||||||
|
style="display: none;"
|
||||||
|
x-cloak>
|
||||||
|
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div x-show="show"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0"
|
||||||
|
x-transition:enter-end="opacity-100"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100"
|
||||||
|
x-transition:leave-end="opacity-0"
|
||||||
|
@click="show = false"
|
||||||
|
class="absolute inset-0 bg-zinc-950/80 backdrop-blur-xl"></div>
|
||||||
|
|
||||||
|
<!-- Modal Card -->
|
||||||
|
<div x-show="show"
|
||||||
|
x-transition:enter="transition ease-out duration-500"
|
||||||
|
x-transition:enter-start="opacity-0 scale-95 translate-y-8"
|
||||||
|
x-transition:enter-end="opacity-100 scale-100 translate-y-0"
|
||||||
|
x-transition:leave="transition ease-in duration-300"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100 translate-y-0"
|
||||||
|
x-transition:leave-end="opacity-0 scale-95 translate-y-8"
|
||||||
|
class="w-full max-w-[400px] bg-zinc-900 border border-white/10 rounded-[32px] p-8 relative overflow-hidden shadow-2xl">
|
||||||
|
|
||||||
|
<!-- Background Glow based on type -->
|
||||||
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-64 h-64 rounded-full blur-[80px] -z-10 opacity-20"
|
||||||
|
:class="{
|
||||||
|
'bg-rose-500': type === 'danger',
|
||||||
|
'bg-blue-500': type === 'info',
|
||||||
|
'bg-amber-500': type === 'warning',
|
||||||
|
'bg-emerald-500': type === 'success'
|
||||||
|
}"></div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="w-16 h-16 rounded-2xl flex items-center justify-center mx-auto mb-6 shadow-xl"
|
||||||
|
:class="{
|
||||||
|
'bg-rose-500/10 text-rose-500': type === 'danger',
|
||||||
|
'bg-blue-500/10 text-blue-500': type === 'info',
|
||||||
|
'bg-amber-500/10 text-amber-500': type === 'warning',
|
||||||
|
'bg-emerald-500/10 text-emerald-500': type === 'success'
|
||||||
|
}">
|
||||||
|
<!-- Danger Icon -->
|
||||||
|
<svg x-show="type === 'danger'" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||||
|
<!-- Warning Icon -->
|
||||||
|
<svg x-show="type === 'warning'" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<!-- Info Icon -->
|
||||||
|
<svg x-show="type === 'info'" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
<!-- Success Icon -->
|
||||||
|
<svg x-show="type === 'success'" class="w-8 h-8" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-2xl font-black text-white mb-2 tracking-tight uppercase italic" x-text="title"></h3>
|
||||||
|
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest leading-relaxed mb-8 px-4" x-text="message"></p>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<button @click="show = false"
|
||||||
|
class="py-4 rounded-2xl bg-white/5 border border-white/10 text-zinc-400 font-black text-[10px] uppercase tracking-[0.2em] hover:bg-white/10 hover:text-white transition-all cursor-pointer">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button @click="confirm()"
|
||||||
|
class="py-4 rounded-2xl font-black text-[10px] uppercase tracking-[0.2em] shadow-xl transition-all cursor-pointer"
|
||||||
|
:class="{
|
||||||
|
'bg-rose-600 text-white hover:bg-rose-500 shadow-rose-900/20': type === 'danger',
|
||||||
|
'bg-blue-600 text-white hover:bg-blue-500 shadow-blue-900/20': type === 'info',
|
||||||
|
'bg-amber-600 text-white hover:bg-amber-500 shadow-amber-900/20': type === 'warning',
|
||||||
|
'bg-emerald-600 text-white hover:bg-emerald-500 shadow-emerald-900/20': type === 'success'
|
||||||
|
}"
|
||||||
|
x-text="confirmLabel">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
83
resources/views/components/bento/cta.blade.php
Normal file
83
resources/views/components/bento/cta.blade.php
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<section id="cta" class="pt-16 pb-12 relative overflow-hidden">
|
||||||
|
<!-- Massive Cinematic Background Glows -->
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[1000px] h-[600px] bg-gradient-to-r from-pink-500/10 to-emerald-500/10 rounded-full blur-[160px] -z-10"></div>
|
||||||
|
<div class="absolute -bottom-24 left-1/2 -translate-x-1/2 w-full h-px bg-gradient-to-r from-transparent via-white/10 to-transparent"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<div class="relative p-12 md:p-20 rounded-[40px] bg-zinc-900/40 border border-white/5 backdrop-blur-3xl overflow-hidden flex flex-col items-center text-center shadow-2xl">
|
||||||
|
<!-- Decorative Inner Glow -->
|
||||||
|
<div class="absolute -top-24 -left-24 w-48 h-48 bg-pink-500/10 rounded-full blur-[40px]"></div>
|
||||||
|
<div class="absolute -bottom-24 -right-24 w-48 h-48 bg-emerald-500/10 rounded-full blur-[40px]"></div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 mb-8">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-[pulse_1s_infinite]"></div>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-emerald-500 uppercase">Live Metrics</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2 class="text-4xl md:text-6xl font-black tracking-tighter text-white mb-6 leading-tight">
|
||||||
|
Ready to take back <br class="hidden md:block"> your <span class="bg-gradient-to-r from-pink-500 to-emerald-500 bg-clip-text text-transparent">privacy?</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<p class="text-zinc-400 text-lg md:text-xl max-w-xl mx-auto mb-12 leading-relaxed font-medium">
|
||||||
|
Create your first disposable Gmail in seconds. <br class="hidden sm:block text-zinc-600">
|
||||||
|
No credit card, no registration, no tracking.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Live Counters -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-[1fr_auto_1fr] gap-6 md:gap-12 mb-12 max-w-2xl mx-auto items-center justify-center text-center"
|
||||||
|
x-data="{
|
||||||
|
mailboxes: 1248920,
|
||||||
|
emails: 48291032,
|
||||||
|
numberFormat(val) {
|
||||||
|
return new Intl.NumberFormat().format(val);
|
||||||
|
},
|
||||||
|
init() {
|
||||||
|
setInterval(() => {
|
||||||
|
this.mailboxes += Math.floor(Math.random() * 3) + 1;
|
||||||
|
this.emails += Math.floor(Math.random() * 12) + 2;
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}">
|
||||||
|
<!-- Counter 1: Mailboxes -->
|
||||||
|
<div class="flex flex-col items-center sm:items-end">
|
||||||
|
<div class="text-2xl md:text-4xl font-black text-white tabular-nums tracking-tighter" x-text="numberFormat(mailboxes)">1,248,920</div>
|
||||||
|
<div class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mt-1">Mailboxes Created</div>
|
||||||
|
</div>
|
||||||
|
<!-- Divider -->
|
||||||
|
<div class="hidden sm:block w-px h-12 bg-white/10"></div>
|
||||||
|
<!-- Counter 2: Emails -->
|
||||||
|
<div class="flex flex-col items-center sm:items-start">
|
||||||
|
<div class="text-2xl md:text-4xl font-black text-emerald-500 tabular-nums tracking-tighter" x-text="numberFormat(emails)">48,291,032</div>
|
||||||
|
<div class="text-[10px] font-bold text-zinc-500 uppercase tracking-widest mt-1">Email Received</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Final CTA Button -->
|
||||||
|
<div class="flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||||
|
<a href="#hero" class="group relative px-10 py-5 bg-white text-black font-black text-lg rounded-2xl transition-all hover:scale-105 active:scale-95 shadow-[0_0_30px_rgba(255,255,255,0.2)]">
|
||||||
|
<span class="relative z-10 flex items-center gap-2">
|
||||||
|
Get Started for Free
|
||||||
|
<svg class="w-5 h-5 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke-width="2.5" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trust Badges -->
|
||||||
|
<div class="mt-10 flex items-center justify-center gap-6 text-zinc-500">
|
||||||
|
<div class="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
<svg class="w-4 h-4 text-emerald-500/50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
No CC Required
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1.5 text-[10px] font-bold uppercase tracking-widest">
|
||||||
|
<svg class="w-4 h-4 text-emerald-500/50" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
100% Private
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
76
resources/views/components/bento/dynamic-layout.blade.php
Normal file
76
resources/views/components/bento/dynamic-layout.blade.php
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<div>
|
||||||
|
<x-slot:title>{{ $title }} — Zemail</x-slot:title>
|
||||||
|
|
||||||
|
<div class="flex-1">
|
||||||
|
<!-- Reusable Cinematic Header -->
|
||||||
|
<x-bento.page-header
|
||||||
|
:title="$title"
|
||||||
|
:subtitle="$subtitle"
|
||||||
|
:breadcrumb="$breadcrumb"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Content Area -->
|
||||||
|
<main class="pt-12 pb-12 bg-app-bg relative overflow-hidden">
|
||||||
|
<!-- Background Decorations -->
|
||||||
|
<div class="absolute top-0 right-0 w-[600px] h-[600px] bg-emerald-500/[0.03] rounded-full blur-[120px] -z-10"></div>
|
||||||
|
<div class="absolute bottom-0 left-0 w-[600px] h-[600px] bg-pink-500/[0.03] rounded-full blur-[120px] -z-10"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<!-- Main Legal Content (Full Width Alignment) -->
|
||||||
|
<article class="w-full" x-init="gsap.from($el, { y: 30, opacity: 0, duration: 1, ease: 'power3.out', delay: 0.2 })">
|
||||||
|
<div class="relative p-10 md:p-16 rounded-[40px] bg-zinc-900/40 border border-white/5 backdrop-blur-3xl shadow-2xl overflow-hidden">
|
||||||
|
<!-- Inner Decorative Glows -->
|
||||||
|
<div class="absolute -top-24 -left-24 w-48 h-48 bg-pink-500/10 rounded-full blur-[40px]"></div>
|
||||||
|
|
||||||
|
<!-- Last Updated Badge (Pulsing) -->
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 mb-12">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-pink-500 animate-pulse"></div>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-pink-500 uppercase">Last Updated: {{ $lastUpdated ?? date('F d, Y') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Styled Content Slots -->
|
||||||
|
<div class="legal-content relative z-10">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Subtle "Proof of Secure" Watermark -->
|
||||||
|
<div class="absolute bottom-8 right-8 pointer-events-none opacity-5">
|
||||||
|
<h2 class="text-6xl font-black text-white tracking-widest leading-none rotate-12">SECURE</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Helpful Footer Note -->
|
||||||
|
<div class="mt-12 flex flex-col md:flex-row items-center justify-between gap-6 p-8 rounded-3xl bg-zinc-900/20 border border-white/5 backdrop-blur-xl">
|
||||||
|
<div class="flex items-center gap-4 text-center md:text-left">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-pink-500/20 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-pink-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-white mb-1">Need legal clarification?</h4>
|
||||||
|
<p class="text-xs text-zinc-500 font-medium tracking-tight">Our compliance team is available to answer any questions.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="mailto:legal@imail.me" class="px-6 py-3 rounded-xl bg-white/5 border border-white/10 text-white font-bold text-[11px] uppercase tracking-widest hover:bg-white/10 transition-all">
|
||||||
|
Send Email
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Final CTA Footer -->
|
||||||
|
<x-bento.cta />
|
||||||
|
<x-bento.footer />
|
||||||
|
|
||||||
|
<!-- Enhanced Style Overrides for Legal Content -->
|
||||||
|
<style>
|
||||||
|
.legal-content h2 { @apply text-2xl md:text-3xl font-black text-zinc-200 tracking-tighter mb-6 mt-16 first:mt-0; }
|
||||||
|
.legal-content p { @apply text-zinc-500 leading-relaxed mb-8 text-base md:text-lg; }
|
||||||
|
.legal-content ul { @apply space-y-4 mb-8; }
|
||||||
|
.legal-content li { @apply flex gap-4 text-zinc-500 text-sm md:text-base leading-relaxed; }
|
||||||
|
.legal-content li::before { content: '→'; @apply text-emerald-500/60 font-black mt-1 flex-shrink-0; }
|
||||||
|
.legal-content strong { @apply text-zinc-300 font-bold; }
|
||||||
|
.legal-content a { @apply text-pink-500 decoration-pink-500/30 underline decoration-2 underline-offset-4 hover:text-pink-400 transition-colors; }
|
||||||
|
</style>
|
||||||
|
</div>
|
||||||
178
resources/views/components/bento/faq.blade.php
Normal file
178
resources/views/components/bento/faq.blade.php
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<section id="faq" class="py-24 relative overflow-hidden" x-data="{ active: null }">
|
||||||
|
<!-- Background Accents -->
|
||||||
|
<div class="absolute top-1/2 left-0 w-[500px] h-[500px] bg-pink-500/5 rounded-full blur-[100px] -z-10"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-16 flex flex-col items-center text-center">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 mb-6">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-pink-500 animate-pulse"></div>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-pink-500 uppercase">Support</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold tracking-tight text-white mb-6">
|
||||||
|
Common <span class="text-pink-500">questions.</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-zinc-400 text-sm md:text-lg leading-relaxed">
|
||||||
|
Everything you need to know about Zemail, privacy, and temporary communication.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accordion -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Q1 -->
|
||||||
|
<div class="group border border-white/5 rounded-2xl bg-zinc-900/30 overflow-hidden transition-all duration-300"
|
||||||
|
:class="active === 1 ? 'border-pink-500/30 bg-zinc-900/50 shadow-[0_0_30px_rgba(236,72,153,0.05)]' : 'hover:border-white/10'">
|
||||||
|
<button @click="active = active === 1 ? null : 1"
|
||||||
|
class="w-full px-6 py-5 flex items-center justify-between text-left transition-colors">
|
||||||
|
<span class="font-bold text-zinc-100 group-hover:text-white">Are these real Gmail addresses?</span>
|
||||||
|
<svg class="w-5 h-5 text-zinc-500 transition-transform duration-300"
|
||||||
|
:class="active === 1 ? 'rotate-180 text-pink-500' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="active === 1"
|
||||||
|
x-collapse
|
||||||
|
class="px-6 pb-6">
|
||||||
|
<p class="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
Yes! Zemail uses a sophisticated backend to route communications through temporary, high-reputation Gmail and Outlook nodes. This ensures that you can bypass "Disposable Email" filters while maintaining 100% privacy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q2 -->
|
||||||
|
<div class="group border border-white/5 rounded-2xl bg-zinc-900/30 overflow-hidden transition-all duration-300"
|
||||||
|
:class="active === 2 ? 'border-pink-500/30 bg-zinc-900/50 shadow-[0_0_30px_rgba(236,72,153,0.05)]' : 'hover:border-white/10'">
|
||||||
|
<button @click="active = active === 2 ? null : 2"
|
||||||
|
class="w-full px-6 py-5 flex items-center justify-between text-left transition-colors">
|
||||||
|
<span class="font-bold text-zinc-100 group-hover:text-white">How long do emails stay in my inbox?</span>
|
||||||
|
<svg class="w-5 h-5 text-zinc-500 transition-transform duration-300"
|
||||||
|
:class="active === 2 ? 'rotate-180 text-pink-500' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="active === 2"
|
||||||
|
x-collapse
|
||||||
|
class="px-6 pb-6">
|
||||||
|
<p class="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
By default, emails and their attachments are purged automatically after 24 hours. Pro users can extend this retention period up to 7 days for more complex workflows or testing cycles.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q3 -->
|
||||||
|
<div class="group border border-white/5 rounded-2xl bg-zinc-900/30 overflow-hidden transition-all duration-300"
|
||||||
|
:class="active === 3 ? 'border-pink-500/30 bg-zinc-900/50 shadow-[0_0_30px_rgba(236,72,153,0.05)]' : 'hover:border-white/10'">
|
||||||
|
<button @click="active = active === 3 ? null : 3"
|
||||||
|
class="w-full px-6 py-5 flex items-center justify-between text-left transition-colors">
|
||||||
|
<span class="font-bold text-zinc-100 group-hover:text-white">Is Zemail secure for sensitive data?</span>
|
||||||
|
<svg class="w-5 h-5 text-zinc-500 transition-transform duration-300"
|
||||||
|
:class="active === 3 ? 'rotate-180 text-pink-500' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="active === 3"
|
||||||
|
x-collapse
|
||||||
|
class="px-6 pb-6">
|
||||||
|
<p class="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
Absolutely. All incoming data is encrypted at rest using industry-standard AES-256. Our "Auto-Delete" mechanism is a hard wipe, meaning that once the retention period ends, the data is cryptographically unrecoverable even by us.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q4 (Existing) -->
|
||||||
|
<div class="group border border-white/5 rounded-2xl bg-zinc-900/30 overflow-hidden transition-all duration-300"
|
||||||
|
:class="active === 4 ? 'border-pink-500/30 bg-zinc-900/50 shadow-[0_0_30px_rgba(236,72,153,0.05)]' : 'hover:border-white/10'">
|
||||||
|
<button @click="active = active === 4 ? null : 4"
|
||||||
|
class="w-full px-6 py-5 flex items-center justify-between text-left transition-colors">
|
||||||
|
<span class="font-bold text-zinc-100 group-hover:text-white">Can I use my own domain?</span>
|
||||||
|
<svg class="w-5 h-5 text-zinc-500 transition-transform duration-300"
|
||||||
|
:class="active === 4 ? 'rotate-180 text-pink-500' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="active === 4"
|
||||||
|
x-collapse
|
||||||
|
class="px-6 pb-6">
|
||||||
|
<p class="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
Yes, but this is a Pro feature. Pro users can connect their own domains via custom MX records, allowing you to generate infinite temporary addresses on your own brand.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q5 -->
|
||||||
|
<div class="group border border-white/5 rounded-2xl bg-zinc-900/30 overflow-hidden transition-all duration-300"
|
||||||
|
:class="active === 5 ? 'border-pink-500/30 bg-zinc-900/50 shadow-[0_0_30px_rgba(236,72,153,0.05)]' : 'hover:border-white/10'">
|
||||||
|
<button @click="active = active === 5 ? null : 5"
|
||||||
|
class="w-full px-6 py-5 flex items-center justify-between text-left transition-colors">
|
||||||
|
<span class="font-bold text-zinc-100 group-hover:text-white">Why isn't my email arriving?</span>
|
||||||
|
<svg class="w-5 h-5 text-zinc-500 transition-transform duration-300"
|
||||||
|
:class="active === 5 ? 'rotate-180 text-pink-500' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="active === 5"
|
||||||
|
x-collapse
|
||||||
|
class="px-6 pb-6">
|
||||||
|
<p class="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
Most emails arrive instantly, but high traffic or sender-side filters may cause delays. If it takes longer than 15 minutes, try generating an email using a different domain suffix, as some domains may be temporarily throttled by external providers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q6 -->
|
||||||
|
<div class="group border border-white/5 rounded-2xl bg-zinc-900/30 overflow-hidden transition-all duration-300"
|
||||||
|
:class="active === 6 ? 'border-pink-500/30 bg-zinc-900/50 shadow-[0_0_30px_rgba(236,72,153,0.05)]' : 'hover:border-white/10'">
|
||||||
|
<button @click="active = active === 6 ? null : 6"
|
||||||
|
class="w-full px-6 py-5 flex items-center justify-between text-left transition-colors">
|
||||||
|
<span class="font-bold text-zinc-100 group-hover:text-white">Do I need to install any software?</span>
|
||||||
|
<svg class="w-5 h-5 text-zinc-500 transition-transform duration-300"
|
||||||
|
:class="active === 6 ? 'rotate-180 text-pink-500' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="active === 6"
|
||||||
|
x-collapse
|
||||||
|
class="px-6 pb-6">
|
||||||
|
<p class="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
No. Zemail is a 100% web-based service. It is accessible across all modern browsers and devices (Desktop, Mobile, Tablet) without the need for any apps or browser extensions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Q7 -->
|
||||||
|
<div class="group border border-white/5 rounded-2xl bg-zinc-900/30 overflow-hidden transition-all duration-300"
|
||||||
|
:class="active === 7 ? 'border-pink-500/30 bg-zinc-900/50 shadow-[0_0_30px_rgba(236,72,153,0.05)]' : 'hover:border-white/10'">
|
||||||
|
<button @click="active = active === 7 ? null : 7"
|
||||||
|
class="w-full px-6 py-5 flex items-center justify-between text-left transition-colors">
|
||||||
|
<span class="font-bold text-zinc-100 group-hover:text-white">How many email addresses can I create?</span>
|
||||||
|
<svg class="w-5 h-5 text-zinc-500 transition-transform duration-300"
|
||||||
|
:class="active === 7 ? 'rotate-180 text-pink-500' : ''"
|
||||||
|
fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<div x-show="active === 7"
|
||||||
|
x-collapse
|
||||||
|
class="px-6 pb-6">
|
||||||
|
<p class="text-sm text-zinc-400 leading-relaxed">
|
||||||
|
Free users can have up to 5 active addresses simultaneously. If you need more, you can either delete old ones or upgrade to **Pro** for unlimited active addresses and dedicated premium domains.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer Note -->
|
||||||
|
<div class="mt-12 text-center">
|
||||||
|
<p class="text-sm text-zinc-500">
|
||||||
|
Still have questions?
|
||||||
|
<a href="mailto:support@imail.me" class="text-pink-500/80 hover:text-pink-500 font-bold transition-colors underline decoration-pink-500/20 underline-offset-4">Chat with our team</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
77
resources/views/components/bento/footer.blade.php
Normal file
77
resources/views/components/bento/footer.blade.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<footer class="relative pb-20 pt-16 mt-12 overflow-hidden border-t border-white/5 bg-zinc-950">
|
||||||
|
<!-- Final Bottom Glow (Emerald Bookend) -->
|
||||||
|
<div class="absolute -bottom-48 -right-48 w-96 h-96 bg-emerald-500/10 rounded-full blur-[100px] pointer-events-none -z-10"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-12 gap-12 mb-16">
|
||||||
|
<!-- Brand & Status -->
|
||||||
|
<div class="col-span-1 md:col-span-5">
|
||||||
|
<div class="flex items-center gap-6 mb-6">
|
||||||
|
<x-bento.logo size="sm" />
|
||||||
|
<div class="flex items-center gap-2 px-2 py-0.5 rounded-md bg-emerald-500/10 border border-emerald-500/20">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-[pulse_1.5s_infinite]"></div>
|
||||||
|
<span class="text-[9px] font-bold text-emerald-500 uppercase tracking-widest">Systems Operational</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 max-w-sm mb-8 leading-relaxed">
|
||||||
|
High-performance disposable email infrastructure for modern developers.
|
||||||
|
Built for privacy, automation, and speed.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Social Links -->
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="https://github.com" class="p-2 rounded-lg bg-zinc-900 border border-white/5 text-zinc-400 hover:text-white hover:border-white/20 transition-all" title="GitHub">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>
|
||||||
|
</a>
|
||||||
|
<a href="https://twitter.com" class="p-2 rounded-lg bg-zinc-900 border border-white/5 text-zinc-400 hover:text-white hover:border-white/20 transition-all" title="X (Twitter)">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/></svg>
|
||||||
|
</a>
|
||||||
|
<a href="https://discord.com" class="p-2 rounded-lg bg-zinc-900 border border-white/5 text-zinc-400 hover:text-white hover:border-white/20 transition-all" title="Discord">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037 19.736 19.736 0 0 0-4.885 1.515.069.069 0 0 0-.032.027C.533 9.048-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.23 10.23 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03z"/></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Links Grid -->
|
||||||
|
<div class="col-span-1 md:col-span-7 grid grid-cols-2 sm:grid-cols-3 gap-8">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-white text-sm uppercase tracking-widest mb-6">Product</h3>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li><a href="#features" class="text-zinc-500 hover:text-emerald-500 transition-colors text-sm font-medium">Features</a></li>
|
||||||
|
<li><a href="#how-it-works" class="text-zinc-500 hover:text-emerald-500 transition-colors text-sm font-medium">How it Works</a></li>
|
||||||
|
<li><a href="#reviews" class="text-zinc-500 hover:text-emerald-500 transition-colors text-sm font-medium">Reviews</a></li>
|
||||||
|
<li><a href="#pricing" class="text-zinc-500 hover:text-emerald-500 transition-colors text-sm font-medium">Pricing</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-white text-sm uppercase tracking-widest mb-6">Resources</h3>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li><a href="#faq" class="text-zinc-500 hover:text-pink-500 transition-colors text-sm font-medium">FAQ</a></li>
|
||||||
|
<li><a href="/api-docs" class="text-zinc-500 hover:text-pink-500 transition-colors text-sm font-medium">API Docs</a></li>
|
||||||
|
<li><a href="/blog" class="text-zinc-500 hover:text-pink-500 transition-colors text-sm font-medium">Privacy Blog</a></li>
|
||||||
|
<li><a href="mailto:support@imail.me" class="text-zinc-500 hover:text-pink-500 transition-colors text-sm font-medium">Help Center</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-white text-sm uppercase tracking-widest mb-6">Legal</h3>
|
||||||
|
<ul class="space-y-4">
|
||||||
|
<li><a href="/privacy-policy" class="text-zinc-500 hover:text-white transition-colors text-sm font-medium">Privacy Policy</a></li>
|
||||||
|
<li><a href="/terms" class="text-zinc-500 hover:text-white transition-colors text-sm font-medium">Terms of Service</a></li>
|
||||||
|
<li><a href="/refund" class="text-zinc-500 hover:text-white transition-colors text-sm font-medium">Refund Policy</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pt-8 border-t border-white/5 flex flex-col md:flex-row justify-between items-center gap-6">
|
||||||
|
<p class="text-zinc-600 text-[11px] font-bold tracking-widest uppercase">
|
||||||
|
© 2026 IMAIL. ALL RIGHTS RESERVED.
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-4 text-zinc-600 text-[10px] font-bold uppercase tracking-tight">
|
||||||
|
<span>V2.4.1</span>
|
||||||
|
<span class="w-1 h-1 rounded-full bg-zinc-800"></span>
|
||||||
|
<span>GLOBAL EDGE DEPLOYMENT</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
22
resources/views/components/bento/grid.blade.php
Normal file
22
resources/views/components/bento/grid.blade.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<section id="features" class="py-20 relative z-10">
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<!-- Section Header -->
|
||||||
|
<div class="mb-12 md:mb-20 flex flex-col items-center text-center">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 mb-6 group/badge">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-pink-500 animate-pulse ring-4 ring-pink-500/20"></div>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-pink-500 uppercase">Capabilities</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl lg:text-6xl font-bold tracking-tight text-white mb-6">
|
||||||
|
Everything you need, <span class="text-zinc-500">nothing you don't.</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-zinc-400 max-w-2xl text-sm md:text-lg leading-relaxed">
|
||||||
|
Zemail is built for the modern web. From instant API access to premium Gmail domains,
|
||||||
|
we've automated the friction out of temporary email management.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 md:gap-6 auto-rows-auto md:auto-rows-[minmax(320px,auto)]">
|
||||||
|
{{ $slot }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
223
resources/views/components/bento/hero.blade.php
Normal file
223
resources/views/components/bento/hero.blade.php
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
<section id="hero" class="relative pt-32 lg:pt-48 pb-20 overflow-hidden">
|
||||||
|
<!-- Background Glow - shift to the left for the new layout -->
|
||||||
|
<div class="absolute inset-x-0 top-0 -z-10 h-full w-full bg-app-bg bg-[radial-gradient(ellipse_80%_80%_at_20%_-20%,rgba(236,72,153,0.15),rgba(255,255,255,0))]"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 items-center">
|
||||||
|
|
||||||
|
<!-- Left Side: Copy -->
|
||||||
|
<div class="flex flex-col items-start text-left relative z-10">
|
||||||
|
<!-- Badge -->
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 text-sm text-pink-400 bg-pink-500/10 border border-pink-500/20 rounded-full mb-6 opacity-0 translate-y-8" x-init="gsap.to($el, {opacity: 1, y: 0, duration: 1, ease: 'power3.out'})">
|
||||||
|
<span class="flex h-2 w-2 relative">
|
||||||
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-pink-400 opacity-75"></span>
|
||||||
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-pink-500"></span>
|
||||||
|
</span>
|
||||||
|
Zemail Beta Live
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 class="text-4xl md:text-6xl lg:text-7xl font-bold tracking-tight text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-500 mb-6 opacity-0 translate-y-8 leading-tight" x-init="gsap.to($el, {opacity: 1, y: 0, duration: 1, delay: 0.1, ease: 'power3.out'})">
|
||||||
|
Built for the modern tester_
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p class="text-lg md:text-xl text-zinc-400 max-w-xl mb-10 opacity-0 translate-y-8" x-init="gsap.to($el, {opacity: 1, y: 0, duration: 1, delay: 0.2, ease: 'power3.out'})">
|
||||||
|
Generate instant, secure, and ephemeral temporary inboxes for Developers, Privacy Users, and QA Teams. Lightning fast Websocket delivery built-in.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex flex-col sm:flex-row items-center gap-4 opacity-0" x-init="gsap.to($el, {opacity: 1, duration: 1, delay: 0.4})">
|
||||||
|
<a href="/mailbox" class="group relative inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white bg-pink-600 rounded-full overflow-hidden transition-all hover:bg-pink-500 hover:shadow-[0_0_20px_rgba(236,72,153,0.5)]">
|
||||||
|
<span class="relative flex items-center gap-2">
|
||||||
|
Create mailbox for free
|
||||||
|
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<a href="/api-docs" class="inline-flex items-center justify-center px-8 py-3.5 text-base font-semibold text-white bg-white/5 border border-white/10 rounded-full transition-all hover:bg-white/10">
|
||||||
|
Zemail in 100 seconds <svg class="w-4 h-4 ml-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side: Animated Screen -->
|
||||||
|
<div class="relative opacity-0 perspective-1000 mt-12 lg:mt-0" x-init="gsap.fromTo($el, {opacity: 0, rotateY: 15, x: 50}, {opacity: 1, rotateY: 0, x: 0, duration: 1.2, delay: 0.3, ease: 'power3.out'})">
|
||||||
|
<!-- App Window Wrapper -->
|
||||||
|
<div class="relative rounded-xl border border-white/10 shadow-2xl overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] bg-gradient-to-br from-app-surface to-app-bg">
|
||||||
|
<!-- Window Header -->
|
||||||
|
<div class="flex items-center px-4 py-3 border-b border-white/5 bg-app-surface/50">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-app-red"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full bg-app-yellow"></div>
|
||||||
|
<div class="w-3 h-3 rounded-full bg-app-green"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mx-auto flex items-center space-x-2 px-3 py-1.5 bg-black/40 rounded-md border border-white/5">
|
||||||
|
<svg class="w-3 h-3 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4" /></svg>
|
||||||
|
<span class="text-xs text-zinc-400 font-mono tracking-wider">imail.local/inbox</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- App UI Body -->
|
||||||
|
<div class="flex h-[420px]" x-data="mailApp()">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="w-[30%] border-r border-white/5 bg-app-surface/30 p-4 hidden sm:block">
|
||||||
|
<div class="space-y-2 text-sm font-medium text-zinc-400">
|
||||||
|
<div class="flex items-center gap-3 text-white px-3 py-2 bg-white/5 rounded-lg border border-white/5">
|
||||||
|
<svg class="w-4 h-4 text-pink-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" /></svg>
|
||||||
|
Inbox
|
||||||
|
<span class="ml-auto text-[10px] font-mono bg-pink-500/20 text-pink-400 border border-pink-500/30 rounded-full px-2" x-text="emails.length"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 px-3 py-2 hover:bg-white/5 rounded-lg cursor-pointer transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
|
||||||
|
Drafts
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 px-3 py-2 hover:bg-white/5 rounded-lg cursor-pointer transition-colors">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" /></svg>
|
||||||
|
Sent
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content Area -->
|
||||||
|
<div class="flex-1 flex flex-col relative overflow-hidden bg-app-bg/50">
|
||||||
|
<!-- INBOX LIST VIEW -->
|
||||||
|
<div x-show="view === 'inbox'" x-transition:enter="transition ease-out duration-300" x-transition:enter-start="opacity-0 translate-x-4" x-transition:enter-end="opacity-100 translate-x-0" class="absolute inset-0 overflow-y-auto custom-scrollbar">
|
||||||
|
<div class="p-4 border-b border-white/5 flex items-center justify-between sticky top-0 bg-app-bg z-20">
|
||||||
|
<h3 class="text-white font-medium flex items-center gap-2">
|
||||||
|
Active Inbox
|
||||||
|
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
|
||||||
|
</h3>
|
||||||
|
<div class="text-[10px] uppercase tracking-wider text-pink-400 font-semibold bg-pink-500/10 px-2 py-1 rounded border border-pink-500/20">Auto-refresh</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col relative pb-4">
|
||||||
|
<template x-for="(email, index) in emails" :key="email.id">
|
||||||
|
<div @click="openEmail(email)"
|
||||||
|
class="p-4 border-b border-white/5 hover:bg-zinc-800/40 cursor-pointer transition-all group relative overflow-hidden"
|
||||||
|
x-transition:enter="transition ease-out duration-500 transform"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-4 bg-emerald-500/10"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0 bg-transparent"
|
||||||
|
x-transition:leave="transition ease-in duration-300 transform"
|
||||||
|
x-transition:leave-start="opacity-100 translate-x-0"
|
||||||
|
x-transition:leave-end="opacity-0 translate-x-10">
|
||||||
|
|
||||||
|
<!-- Hover Highlight -->
|
||||||
|
<div class="absolute left-0 top-0 bottom-0 w-1 bg-pink-500 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-baseline mb-1 relative z-10">
|
||||||
|
<span class="text-sm font-semibold text-zinc-100 truncate pr-4" x-text="email.sender"></span>
|
||||||
|
<span class="text-xs text-zinc-500 whitespace-nowrap" x-text="email.time"></span>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-zinc-300 font-medium mb-1 relative z-10" x-text="email.subject"></div>
|
||||||
|
<div class="text-[13px] text-zinc-500 truncate relative z-10" x-text="email.snippet"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EMAIL READING VIEW -->
|
||||||
|
<div x-show="view === 'reading'" x-transition:enter="transition ease-out duration-300 transform" x-transition:enter-start="opacity-0 translate-x-8" x-transition:enter-end="opacity-100 translate-x-0" x-transition:leave="transition ease-in duration-200" x-transition:leave-start="opacity-100 translate-y-0" x-transition:leave-end="opacity-0 translate-y-4" class="absolute inset-0 bg-app-bg flex flex-col z-20" style="display: none;">
|
||||||
|
<div class="px-4 py-3 border-b border-white/5 flex items-center justify-between bg-app-surface/40">
|
||||||
|
<button @click="view = 'inbox'; selectedEmail = null" class="flex items-center text-sm font-medium text-zinc-400 hover:text-white transition-colors bg-white/5 hover:bg-white/10 px-3 py-1.5 rounded-md border border-white/5">
|
||||||
|
<svg class="w-4 h-4 mr-1.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" /></svg>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button @click="deleteEmail()" class="flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold text-red-400 bg-red-500/10 hover:bg-red-500/20 hover:text-red-300 border border-red-500/20 rounded-md transition-all" title="Permanently Delete">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="px-5 py-6 flex-1 overflow-y-auto custom-scrollbar">
|
||||||
|
<template x-if="selectedEmail">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-6 leading-tight" x-text="selectedEmail.subject"></h2>
|
||||||
|
<div class="flex items-center gap-4 mb-8">
|
||||||
|
<div class="w-10 h-10 rounded-full bg-gradient-to-br from-pink-500 to-indigo-500 flex items-center justify-center text-white font-bold text-lg shadow-[0_0_15px_rgba(236,72,153,0.3)]" x-text="selectedEmail.sender.charAt(0).toUpperCase()"></div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm font-semibold text-zinc-200" x-text="selectedEmail.sender"></div>
|
||||||
|
<div class="text-xs text-zinc-500 mt-0.5">to me · <span x-text="selectedEmail.time"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="prose prose-sm prose-invert text-zinc-300 max-w-none leading-relaxed">
|
||||||
|
<p x-html="selectedEmail.content"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Alpine JS Component Logic for Mail App -->
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('mailApp', () => ({
|
||||||
|
view: 'inbox',
|
||||||
|
selectedEmail: null,
|
||||||
|
emails: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
sender: 'admin@imail.local',
|
||||||
|
subject: 'Hello World! I am Zemail',
|
||||||
|
snippet: 'Click me to discover what makes Zemail so special...',
|
||||||
|
time: '1m ago',
|
||||||
|
content: 'Welcome to Zemail! ✨<br><br>We provide instantly created, highly reliable temporary email addresses that just work.<br><br><strong>Features:</strong><br><ul class="list-disc pl-5 mt-2 space-y-1"><li>Instant Inbox creation without sign-up.</li><li>Access to Premium & Generic Domains.</li><li>Real-time Websocket syncing for 0 delay.</li><li>Developer APIs ready for E2E automated testing.</li></ul><br>Enjoy your clean, spam-free inbox today!'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
counter: 2,
|
||||||
|
init() {
|
||||||
|
// Simulate receiving new emails to show off "auto refresh" capability
|
||||||
|
setInterval(() => {
|
||||||
|
if(this.view === 'inbox' && this.emails.length < 5) {
|
||||||
|
const senders = ['security@github.com', 'noreply@slack.com', 'auth@stripe.com', 'billing@aws.amazon.com'];
|
||||||
|
const subjects = ['Please verify your new device', 'Confirm your email address', 'Your receipt is ready', 'Login attempt blocked'];
|
||||||
|
const snippets = ['We noticed a new login attempt...', 'Click the link below to verify...', 'Thanks for your purchase...', 'We prevented a suspicious login...'];
|
||||||
|
|
||||||
|
const idx = Math.floor(Math.random() * senders.length);
|
||||||
|
|
||||||
|
this.emails.unshift({
|
||||||
|
id: this.counter++,
|
||||||
|
sender: senders[idx],
|
||||||
|
subject: subjects[idx],
|
||||||
|
snippet: snippets[idx],
|
||||||
|
time: 'Just now',
|
||||||
|
content: `This is a simulated email received via our ultra-fast websocket delivery system.<br><br><strong>Action:</strong> ${subjects[idx]}<br><strong>Time Received:</strong> ${new Date().toLocaleTimeString()}<br><br>In a real Zemail inbox, your emails appear instantly like this as soon as they hit our SMTP servers, bypassing typical polling delays.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
},
|
||||||
|
openEmail(email) {
|
||||||
|
this.selectedEmail = email;
|
||||||
|
this.view = 'reading';
|
||||||
|
},
|
||||||
|
deleteEmail() {
|
||||||
|
if (this.selectedEmail) {
|
||||||
|
this.emails = this.emails.filter(e => e.id !== this.selectedEmail.id);
|
||||||
|
this.selectedEmail = null;
|
||||||
|
this.view = 'inbox';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
/* Custom Scrollbar for Mail App */
|
||||||
|
.custom-scrollbar::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
51
resources/views/components/bento/logo.blade.php
Normal file
51
resources/views/components/bento/logo.blade.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
@props([
|
||||||
|
'size' => 'md', // sm, md, lg
|
||||||
|
'withText' => true,
|
||||||
|
'class' => ''
|
||||||
|
])
|
||||||
|
|
||||||
|
@php
|
||||||
|
$dimensions = match($size) {
|
||||||
|
'sm' => ['box' => 'w-8 h-8', 'icon' => 'w-4 h-4', 'text' => 'text-base'],
|
||||||
|
'md' => ['box' => 'w-10 h-10', 'icon' => 'w-5 h-5', 'text' => 'text-xl'],
|
||||||
|
'lg' => ['box' => 'w-12 h-12', 'icon' => 'w-6 h-6', 'text' => 'text-2xl'],
|
||||||
|
default => ['box' => 'w-10 h-10', 'icon' => 'w-5 h-5', 'text' => 'text-xl'],
|
||||||
|
};
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div x-data="{ showText: {{ $withText ? 'true' : 'false' }} }" {{ $attributes }} class="flex items-center gap-3 {{ $class }}">
|
||||||
|
<!-- Logo Symbol -->
|
||||||
|
<div class="relative group">
|
||||||
|
<!-- Outer Glow -->
|
||||||
|
<div class="absolute -inset-2 bg-gradient-to-tr from-pink-500/20 to-emerald-500/20 rounded-2xl blur-lg opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||||
|
|
||||||
|
<!-- Icon Container -->
|
||||||
|
<div class="{{ $dimensions['box'] }} bg-zinc-900 border border-white/10 rounded-xl flex items-center justify-center relative overflow-hidden shadow-xl">
|
||||||
|
<!-- Subtle Mesh Background -->
|
||||||
|
<div class="absolute inset-0 bg-[radial-gradient(circle_at_50%_0%,rgba(236,72,153,0.1),transparent)]"></div>
|
||||||
|
|
||||||
|
@php $gradientId = 'logo-gradient-' . Str::random(6); @endphp
|
||||||
|
<!-- Stylized "Z" / Envelope Fold -->
|
||||||
|
<svg class="{{ $dimensions['icon'] }} text-transparent" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="{{ $gradientId }}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#ec4899" />
|
||||||
|
<stop offset="100%" style="stop-color:#be185d" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path d="M4 19L20 5M4 19H14M20 5H10" stroke="url(#{{ $gradientId }})" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
<path d="M4 5L20 19" stroke="white" stroke-opacity="0.1" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<!-- Activity Pulsar (Top Right) -->
|
||||||
|
<div class="absolute top-1.5 right-1.5 w-1.5 h-1.5 bg-emerald-500 rounded-full shadow-[0_0_8px_rgba(16,185,129,0.5)]">
|
||||||
|
<div class="absolute inset-0 bg-emerald-500 rounded-full animate-ping opacity-75"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center" x-show="showText" x-transition:opacity>
|
||||||
|
<span class="{{ $dimensions['text'] }} font-black tracking-tighter text-white italic uppercase">zemail</span>
|
||||||
|
<span class="{{ $dimensions['text'] }} font-black tracking-tighter text-pink-500 leading-none italic uppercase">.me</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
81
resources/views/components/bento/nav.blade.php
Normal file
81
resources/views/components/bento/nav.blade.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<div class="fixed top-0 inset-x-0 z-50 w-full transition-all duration-300"
|
||||||
|
x-data="{ scrolled: false, mobileMenuOpen: false }"
|
||||||
|
@scroll.window="scrolled = (window.pageYOffset > 20)"
|
||||||
|
:class="scrolled ? 'bg-app-bg/80 backdrop-blur-xl border-b border-white/10 shadow-lg' : 'bg-app-bg/40 backdrop-blur-md border-b border-white/5'">
|
||||||
|
|
||||||
|
<nav class="container mx-auto px-4 lg:px-8 py-4 flex items-center justify-between max-w-7xl">
|
||||||
|
|
||||||
|
<!-- Left Side: Logo + Links -->
|
||||||
|
<div class="flex items-center gap-8">
|
||||||
|
<!-- Logo -->
|
||||||
|
<a href="/" class="z-50">
|
||||||
|
<x-bento.logo size="sm" />
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Desktop Links -->
|
||||||
|
<div class="hidden md:flex items-center space-x-6">
|
||||||
|
<a href="#features" class="text-sm font-medium text-zinc-300 hover:text-white transition-colors">Features</a>
|
||||||
|
<a href="#how-it-works" class="text-sm font-medium text-zinc-300 hover:text-white transition-colors">How it Works</a>
|
||||||
|
<a href="#reviews" class="text-sm font-medium text-zinc-300 hover:text-white transition-colors">Reviews</a>
|
||||||
|
<a href="#pricing" class="text-sm font-medium text-zinc-300 hover:text-white transition-colors">Pricing</a>
|
||||||
|
<a href="#faq" class="text-sm font-medium text-zinc-300 hover:text-white transition-colors">FAQ</a>
|
||||||
|
<a href="/api-docs" class="text-sm font-medium text-zinc-300 hover:text-white transition-colors">API</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right Side: CTA + Mobile Toggle -->
|
||||||
|
<div class="flex items-center gap-4 z-50">
|
||||||
|
<!-- CTA Button (Desktop & Mobile) -->
|
||||||
|
<a href="/mailbox" class="relative inline-flex items-center justify-center px-5 py-2.5 text-sm font-semibold text-white bg-zinc-900 border border-zinc-700/80 rounded-full overflow-hidden transition-all group hover:border-pink-500 hover:shadow-[0_0_15px_rgba(236,72,153,0.4)]">
|
||||||
|
<span class="absolute inset-0 w-full h-full transition-all duration-300 ease-out opacity-0 bg-gradient-to-r from-pink-500 to-purple-500 group-hover:opacity-20"></span>
|
||||||
|
<span class="relative flex items-center gap-2 text-zinc-100 group-hover:text-white group-hover:drop-shadow-[0_0_8px_rgba(255,255,255,0.5)]">
|
||||||
|
<span class="hidden sm:inline">Get Temporary Email</span>
|
||||||
|
<span class="sm:hidden">Get Email</span>
|
||||||
|
<svg class="w-4 h-4 group-hover:translate-x-1 transition-transform" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5L21 12m0 0l-7.5 7.5M21 12H3" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<!-- Mobile Menu Button -->
|
||||||
|
<button @click="mobileMenuOpen = !mobileMenuOpen" class="md:hidden p-2 -mr-2 text-zinc-400 hover:text-white focus:outline-none transition-colors">
|
||||||
|
<svg x-show="!mobileMenuOpen" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
|
||||||
|
</svg>
|
||||||
|
<svg x-show="mobileMenuOpen" style="display: none;" class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Mobile Drawer -->
|
||||||
|
<div x-show="mobileMenuOpen"
|
||||||
|
style="display: none;"
|
||||||
|
x-transition:enter="transition ease-out duration-300"
|
||||||
|
x-transition:enter-start="opacity-0 -translate-y-full"
|
||||||
|
x-transition:enter-end="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave="transition ease-in duration-200"
|
||||||
|
x-transition:leave-start="opacity-100 translate-y-0"
|
||||||
|
x-transition:leave-end="opacity-0 -translate-y-full"
|
||||||
|
class="absolute top-0 left-0 w-full h-screen bg-app-bg/95 backdrop-blur-2xl border-b border-white/10 pt-24 px-6 md:hidden">
|
||||||
|
|
||||||
|
<div class="flex flex-col space-y-6 text-center">
|
||||||
|
<a href="#features" @click="mobileMenuOpen = false" class="text-2xl font-semibold text-zinc-300 hover:text-white transition-colors">Features</a>
|
||||||
|
<a href="#how-it-works" @click="mobileMenuOpen = false" class="text-2xl font-semibold text-zinc-300 hover:text-white transition-colors">How it Works</a>
|
||||||
|
<a href="#reviews" @click="mobileMenuOpen = false" class="text-2xl font-semibold text-zinc-300 hover:text-white transition-colors">Reviews</a>
|
||||||
|
<a href="#pricing" @click="mobileMenuOpen = false" class="text-2xl font-semibold text-zinc-300 hover:text-white transition-colors">Pricing</a>
|
||||||
|
<a href="#faq" @click="mobileMenuOpen = false" class="text-2xl font-semibold text-zinc-300 hover:text-white transition-colors">FAQ</a>
|
||||||
|
<a href="/api-docs" @click="mobileMenuOpen = false" class="text-2xl font-semibold text-zinc-300 hover:text-white transition-colors">API</a>
|
||||||
|
|
||||||
|
@if (Route::has('login'))
|
||||||
|
<div class="h-px w-12 bg-white/10 mx-auto my-4"></div>
|
||||||
|
@auth
|
||||||
|
<a href="{{ url('/dashboard') }}" @click="mobileMenuOpen = false" class="text-xl font-medium text-zinc-400 hover:text-white transition-colors">Dashboard</a>
|
||||||
|
@else
|
||||||
|
<a href="{{ route('login') }}" @click="mobileMenuOpen = false" class="text-xl font-medium text-zinc-400 hover:text-white transition-colors">Log in</a>
|
||||||
|
@endauth
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
35
resources/views/components/bento/page-header.blade.php
Normal file
35
resources/views/components/bento/page-header.blade.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
@props([
|
||||||
|
'title',
|
||||||
|
'subtitle' => null,
|
||||||
|
'breadcrumb' => null,
|
||||||
|
])
|
||||||
|
|
||||||
|
<div class="relative pt-32 pb-8 md:pt-48 md:pb-12 overflow-hidden border-b border-white/5 bg-zinc-950/50">
|
||||||
|
<!-- Cinematic Background Glow -->
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[400px] bg-gradient-to-r from-pink-500/10 via-emerald-500/10 to-pink-500/10 rounded-full blur-[120px] -z-10"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl relative z-10"
|
||||||
|
x-init="gsap.from($el, { y: 20, opacity: 0, duration: 1, ease: 'power3.out' })">
|
||||||
|
|
||||||
|
<!-- Breadcrumbs -->
|
||||||
|
@if($breadcrumb)
|
||||||
|
<nav class="flex items-center gap-2 mb-8" aria-label="Breadcrumb">
|
||||||
|
<a href="/" class="text-[10px] font-bold tracking-widest text-zinc-500 uppercase hover:text-pink-500 transition-colors">Home</a>
|
||||||
|
<svg class="w-3 h-3 text-zinc-800" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-pink-500 uppercase">{{ $breadcrumb }}</span>
|
||||||
|
</nav>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
<div class="max-w-3xl">
|
||||||
|
<h1 class="text-4xl md:text-6xl lg:text-7xl font-black tracking-tighter text-white mb-6 leading-[0.9]">
|
||||||
|
{{ $title }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
@if($subtitle)
|
||||||
|
<p class="text-zinc-400 text-lg md:text-xl leading-relaxed font-medium">
|
||||||
|
{{ $subtitle }}
|
||||||
|
</p>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
170
resources/views/components/bento/pricing.blade.php
Normal file
170
resources/views/components/bento/pricing.blade.php
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
<section id="pricing" class="py-24 relative overflow-hidden" x-data="{ yearly: false }">
|
||||||
|
<!-- Background Accents -->
|
||||||
|
<div class="absolute top-0 right-0 w-[600px] h-[600px] bg-pink-500/5 rounded-full blur-[120px] -z-10"></div>
|
||||||
|
<div class="absolute bottom-0 left-0 w-[600px] h-[600px] bg-emerald-500/5 rounded-full blur-[120px] -z-10"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-16 md:mb-20 flex flex-col items-center text-center">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 mb-6">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-pink-500 animate-pulse"></div>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-pink-500 uppercase">Pricing</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold tracking-tight text-white mb-6">
|
||||||
|
Simple, <span class="text-emerald-500">transparent</span> pricing.
|
||||||
|
</h2>
|
||||||
|
<p class="text-zinc-400 max-w-2xl text-sm md:text-lg leading-relaxed mb-10">
|
||||||
|
Choose the plan that fits your needs. No hidden fees, no complicated contracts.
|
||||||
|
Switch or cancel anytime.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Billing Toggle -->
|
||||||
|
<div class="flex items-center gap-4 p-1 bg-zinc-900 border border-white/5 rounded-full">
|
||||||
|
<button
|
||||||
|
@click="yearly = false"
|
||||||
|
class="px-6 py-2 rounded-full text-sm font-medium transition-all duration-300"
|
||||||
|
:class="!yearly ? 'bg-zinc-800 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'"
|
||||||
|
>
|
||||||
|
Monthly
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="yearly = true"
|
||||||
|
class="px-6 py-2 rounded-full text-sm font-medium transition-all duration-300 relative"
|
||||||
|
:class="yearly ? 'bg-zinc-800 text-white shadow-lg' : 'text-zinc-500 hover:text-zinc-300'"
|
||||||
|
>
|
||||||
|
Yearly
|
||||||
|
<span class="absolute -top-6 -right-4 px-2 py-0.5 bg-emerald-500 text-[10px] font-bold text-black rounded-md rotate-12 animate-bounce">
|
||||||
|
SAVE 20%
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pricing Cards -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
|
<!-- Free Plan -->
|
||||||
|
<div class="relative group">
|
||||||
|
<div class="h-full p-8 rounded-3xl bg-zinc-950/50 border border-white/5 backdrop-blur-xl flex flex-col transition-all duration-500 hover:border-white/10 hover:translate-y-[-4px]">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-2">Free</h3>
|
||||||
|
<p class="text-zinc-500 text-sm">Perfect for individuals starting out.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 flex items-baseline gap-1">
|
||||||
|
<span class="text-4xl font-bold text-white">$0</span>
|
||||||
|
<span class="text-zinc-500 text-sm">/mo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-4 mb-10 flex-1">
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Up to 5 active addresses
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
24h retention period
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500/60" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Standard delivery speeds
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300/40">
|
||||||
|
<svg class="w-5 h-5 text-zinc-800" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||||
|
Custom domain support
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="w-full py-4 rounded-xl bg-white/5 border border-white/10 text-white font-bold text-sm transition-all hover:bg-white/10">
|
||||||
|
Get Started
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pro Plan (Most Popular) -->
|
||||||
|
<div class="relative group md:scale-105 z-10">
|
||||||
|
<!-- Glowing Border Effect (Balanced) -->
|
||||||
|
<div class="absolute -inset-[1px] bg-gradient-to-b from-pink-500 via-pink-500/40 to-emerald-500/40 rounded-[25px] blur-[3px] opacity-40 group-hover:opacity-100 transition-opacity duration-500"></div>
|
||||||
|
|
||||||
|
<div class="h-full p-8 rounded-3xl bg-zinc-900 border border-white/10 backdrop-blur-2xl flex flex-col relative transition-all duration-500 hover:translate-y-[-4px]">
|
||||||
|
<div class="absolute top-4 right-4 px-3 py-1 rounded-full bg-pink-500 text-[10px] font-black text-white uppercase tracking-tighter shadow-[0_0_15px_rgba(236,72,153,0.5)]">
|
||||||
|
Most Popular
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-2">Pro</h3>
|
||||||
|
<p class="text-zinc-500 text-sm">Advanced privacy for power users.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 flex items-baseline gap-1">
|
||||||
|
<span class="text-4xl font-bold text-white" x-text="yearly ? '$12' : '$15'">$15</span>
|
||||||
|
<span class="text-zinc-500 text-sm" x-text="yearly ? '/mo (billed yearly)' : '/mo'">/mo</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-4 mb-10 flex-1">
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-200">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Unlimited active addresses
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-200">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
7-day retention period
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-200">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Blazing fast SMTP delivery
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-200">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Custom domain support
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-200">
|
||||||
|
<svg class="w-5 h-5 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
API access for developers
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="w-full py-4 rounded-xl bg-white text-black font-black text-sm transition-all hover:bg-zinc-200 shadow-[0_0_20px_rgba(255,255,255,0.2)]">
|
||||||
|
Upgrade to Pro
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enterprise Plan -->
|
||||||
|
<div class="relative group">
|
||||||
|
<div class="h-full p-8 rounded-3xl bg-zinc-950/50 border border-white/5 backdrop-blur-xl flex flex-col transition-all duration-500 hover:border-white/10 hover:translate-y-[-4px]">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-2">Enterprise</h3>
|
||||||
|
<p class="text-zinc-500 text-sm">Custom solutions for organizations.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8 flex items-baseline gap-1">
|
||||||
|
<span class="text-4xl font-bold text-white">Custom</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="space-y-4 mb-10 flex-1">
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300">
|
||||||
|
<svg class="w-5 h-5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Everything in Pro
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300">
|
||||||
|
<svg class="w-5 h-5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
SLA-backed uptime
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300">
|
||||||
|
<svg class="w-5 h-5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Dedicated support manager
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-3 text-sm text-zinc-300">
|
||||||
|
<svg class="w-5 h-5 text-zinc-500" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/></svg>
|
||||||
|
Custom security audits
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button class="w-full py-4 rounded-xl bg-white/5 border border-white/10 text-white font-bold text-sm transition-all hover:bg-white/10">
|
||||||
|
Contact Sales
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
132
resources/views/components/bento/steps.blade.php
Normal file
132
resources/views/components/bento/steps.blade.php
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<section id="how-it-works" class="py-24 relative overflow-hidden">
|
||||||
|
<!-- Background Accents -->
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-pink-500/5 rounded-full blur-[120px] -z-10"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-16 md:mb-24 flex flex-col items-center text-center">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-emerald-500/10 border border-emerald-500/20 mb-6">
|
||||||
|
<div class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-pulse"></div>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-emerald-500 uppercase">Process</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold tracking-tight text-white mb-6">
|
||||||
|
Three steps to <span class="text-pink-500">total privacy.</span>
|
||||||
|
</h2>
|
||||||
|
<p class="text-zinc-400 max-w-2xl text-sm md:text-lg leading-relaxed">
|
||||||
|
We've distilled complex identity protection into a seamless,
|
||||||
|
automated workflow that takes seconds to master.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Steps Grid / Timeline -->
|
||||||
|
<div class="relative grid grid-cols-1 md:grid-cols-3 gap-12 md:gap-12">
|
||||||
|
<!-- Connecting Line (Desktop: Horizontal) -->
|
||||||
|
<div class="hidden md:block absolute top-[45px] left-[10%] right-[10%] h-px bg-gradient-to-r from-transparent via-zinc-800 to-transparent -z-10">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-transparent via-pink-500/50 to-transparent animate-[data-flow-h_3s_linear_infinite]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connecting Line (Mobile: Vertical) -->
|
||||||
|
<div class="md:hidden absolute left-[45px] top-4 bottom-4 w-px bg-gradient-to-b from-transparent via-zinc-800 to-transparent -z-10">
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-b from-transparent via-pink-500/50 to-transparent animate-[data-flow-v_4s_linear_infinite]"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 1: Generate -->
|
||||||
|
<div class="flex flex-row gap-6 md:flex-col md:items-center md:text-center group" x-data="{ email: 'generating...', active: true }">
|
||||||
|
<div class="shrink-0 w-[90px] h-[90px] rounded-2xl bg-zinc-900 border border-white/10 flex items-center justify-center relative transition-all duration-500 group-hover:border-pink-500/50 group-hover:shadow-[0_0_30px_rgba(236,72,153,0.15)] shadow-xl">
|
||||||
|
<div class="absolute -top-3 -right-3 w-8 h-8 rounded-full bg-zinc-900 border border-white/10 flex items-center justify-center text-xs font-bold text-zinc-500 group-hover:text-pink-500 transition-colors">01</div>
|
||||||
|
<svg class="w-10 h-10 text-pink-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col pt-2 md:items-center">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-3">Instant Generate</h3>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed mb-6 md:px-4">
|
||||||
|
Click one button to mint a unique, randomized Gmail address instantly. No signups required.
|
||||||
|
</p>
|
||||||
|
<div class="w-full max-w-[200px] h-10 bg-white/5 border border-white/10 rounded-lg flex items-center justify-center px-4 overflow-hidden relative md:mx-auto">
|
||||||
|
<span class="text-[11px] font-mono text-pink-400/80 truncate italic"
|
||||||
|
x-init="setInterval(() => {
|
||||||
|
const domains = ['@gmail.com', '@outlook.com', '@imail.com'];
|
||||||
|
const prefix = Math.random().toString(36).substring(7);
|
||||||
|
email = prefix + domains[Math.floor(Math.random()*domains.length)];
|
||||||
|
}, 2500)" x-text="email"></span>
|
||||||
|
<div class="absolute inset-x-0 bottom-0 h-[1px] bg-pink-500/30 scale-x-0 group-hover:scale-x-100 transition-transform duration-700"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 2: Receive -->
|
||||||
|
<div class="flex flex-row gap-6 md:flex-col md:items-center md:text-center group" x-data="{ items: [1] }">
|
||||||
|
<div class="shrink-0 w-[90px] h-[90px] rounded-2xl bg-zinc-900 border border-white/10 flex items-center justify-center relative transition-all duration-500 group-hover:border-emerald-500/50 group-hover:shadow-[0_0_30px_rgba(16,185,129,0.15)] shadow-xl">
|
||||||
|
<div class="absolute -top-3 -right-3 w-8 h-8 rounded-full bg-zinc-900 border border-white/10 flex items-center justify-center text-xs font-bold text-zinc-500 group-hover:text-emerald-500 transition-colors">02</div>
|
||||||
|
<svg class="w-10 h-10 text-emerald-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col pt-2 md:items-center">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-3">Live Reception</h3>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed mb-6 md:px-4">
|
||||||
|
Emails arrive in real-time. View attachments, links, and HTML content securely in your dashboard.
|
||||||
|
</p>
|
||||||
|
<div class="w-full max-w-[240px] md:mx-auto min-h-[40px]">
|
||||||
|
<template x-for="i in items" :key="i">
|
||||||
|
<div class="h-10 bg-white/5 border border-white/10 rounded-lg flex items-center gap-3 px-3 animate-[fade-in-right_0.5s_ease-out]">
|
||||||
|
<div class="w-2 h-2 rounded-full bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.5)]"></div>
|
||||||
|
<div class="flex-1 space-y-1 text-left">
|
||||||
|
<div class="w-16 h-1.5 bg-zinc-700 rounded-full"></div>
|
||||||
|
<div class="w-24 h-1 bg-zinc-800 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-[9px] font-bold text-emerald-500 uppercase tracking-tighter">New</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div x-init="setInterval(() => { items = [Date.now()] }, 5000)"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Step 3: Auto-Delete -->
|
||||||
|
<div class="flex flex-row gap-6 md:flex-col md:items-center md:text-center group" x-data="{ progress: 100 }">
|
||||||
|
<div class="shrink-0 w-[90px] h-[90px] rounded-2xl bg-zinc-900 border border-white/10 flex items-center justify-center relative transition-all duration-500 group-hover:border-zinc-500/50 group-hover:shadow-[0_0_30px_rgba(255,255,255,0.05)] shadow-xl">
|
||||||
|
<div class="absolute -top-3 -right-3 w-8 h-8 rounded-full bg-zinc-900 border border-white/10 flex items-center justify-center text-xs font-bold text-zinc-500 group-hover:text-white transition-colors">03</div>
|
||||||
|
<svg class="w-10 h-10 text-zinc-400 group-hover:rotate-12 transition-transform duration-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col pt-2 md:items-center">
|
||||||
|
<h3 class="text-xl font-bold text-white mb-3">Self Destruction</h3>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed mb-6 md:px-4">
|
||||||
|
After 24 hours, all data is purged from our servers forever. No digital footprint left behind.
|
||||||
|
</p>
|
||||||
|
<div class="w-full max-w-[200px] h-2 bg-white/5 border border-white/10 rounded-full overflow-hidden md:mx-auto">
|
||||||
|
<div class="h-full bg-gradient-to-r from-pink-500 to-emerald-500"
|
||||||
|
:style="`width: ${progress}%; transition: width ${progress === 0 ? '0s' : '5s'} linear`"
|
||||||
|
x-init="
|
||||||
|
progress = 0;
|
||||||
|
setTimeout(() => progress = 100, 100);
|
||||||
|
setInterval(() => {
|
||||||
|
progress = 0;
|
||||||
|
setTimeout(() => progress = 100, 100);
|
||||||
|
}, 6000);
|
||||||
|
"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-[10px] font-mono text-zinc-500 uppercase tracking-widest leading-none">Wiping Data...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
@keyframes data-flow-h {
|
||||||
|
0% { transform: translateX(-100%); }
|
||||||
|
100% { transform: translateX(100%); }
|
||||||
|
}
|
||||||
|
@keyframes data-flow-v {
|
||||||
|
0% { transform: translateY(-100%); }
|
||||||
|
100% { transform: translateY(100%); }
|
||||||
|
}
|
||||||
|
@keyframes fade-in-right {
|
||||||
|
from { opacity: 0; transform: translateX(-10px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</section>
|
||||||
151
resources/views/components/bento/testimonials.blade.php
Normal file
151
resources/views/components/bento/testimonials.blade.php
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<section id="reviews" class="py-24 relative overflow-hidden">
|
||||||
|
<!-- Background Accents -->
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] bg-emerald-500/5 rounded-full blur-[120px] -z-10"></div>
|
||||||
|
|
||||||
|
<div class="container mx-auto px-4 lg:px-8 max-w-7xl">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="mb-16 md:mb-24 flex flex-col items-center text-center">
|
||||||
|
<div class="inline-flex items-center gap-2 px-3 py-1 rounded-full bg-pink-500/10 border border-pink-500/20 mb-6">
|
||||||
|
<svg class="w-3 h-3 text-pink-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
<span class="text-[10px] font-bold tracking-widest text-pink-500 uppercase">Wall of Love</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-3xl md:text-5xl font-bold tracking-tight text-white mb-6">
|
||||||
|
Trusted by <span class="text-emerald-500">thousands</span> of devs.
|
||||||
|
</h2>
|
||||||
|
<p class="text-zinc-400 max-w-2xl text-sm md:text-lg leading-relaxed">
|
||||||
|
Join the community of privacy-first developers, QA testers, and power users who rely on Zemail every day.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonials Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8">
|
||||||
|
<!-- Testimonial 1 -->
|
||||||
|
<div class="group p-8 rounded-3xl bg-zinc-900/30 border border-white/5 backdrop-blur-xl relative transition-all duration-500 hover:border-pink-500/30 hover:shadow-[0_0_40px_rgba(236,72,153,0.05)] hover:-translate-y-1">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-zinc-800 border border-white/10 flex items-center justify-center text-zinc-500 overflow-hidden">
|
||||||
|
<img src="https://ui-avatars.com/api/?name=Alex+Rivers&background=18181b&color=ec4899" alt="Alex Rivers">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-white text-sm">Alex Rivers</h4>
|
||||||
|
<p class="text-[11px] text-zinc-500 uppercase tracking-widest font-medium">Security Researcher</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed italic">
|
||||||
|
"Zemail is my go-to for checking transactional emails on our staging environment. The attachments scanning gives me peace of mind when testing file delivery systems."
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex gap-1">
|
||||||
|
@for($i=0; $i<5; $i++)
|
||||||
|
<svg class="w-3 h-3 text-pink-500/80" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonial 2 -->
|
||||||
|
<div class="group p-8 rounded-3xl bg-zinc-900/30 border border-white/5 backdrop-blur-xl relative transition-all duration-500 hover:border-emerald-500/30 hover:shadow-[0_0_40px_rgba(16,185,129,0.05)] hover:-translate-y-1">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-zinc-800 border border-white/10 flex items-center justify-center text-zinc-500 overflow-hidden">
|
||||||
|
<img src="https://ui-avatars.com/api/?name=Sarah+Chen&background=18181b&color=10b981" alt="Sarah Chen">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-white text-sm">Sarah Chen</h4>
|
||||||
|
<p class="text-[11px] text-zinc-500 uppercase tracking-widest font-medium">Full-stack Engineer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed italic">
|
||||||
|
"Setting up the API for our automated test suite took minutes. We can now spin up unique addresses for every CI/CD run. Truly a game-changer for our QA flow."
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex gap-1">
|
||||||
|
@for($i=0; $i<5; $i++)
|
||||||
|
<svg class="w-3 h-3 text-emerald-500/80" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonial 3 -->
|
||||||
|
<div class="group p-8 rounded-3xl bg-zinc-900/30 border border-white/5 backdrop-blur-xl relative transition-all duration-500 hover:border-pink-500/30 hover:shadow-[0_0_40px_rgba(236,72,153,0.05)] hover:-translate-y-1">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-zinc-800 border border-white/10 flex items-center justify-center text-zinc-500 overflow-hidden">
|
||||||
|
<img src="https://ui-avatars.com/api/?name=Marco+V&background=18181b&color=ec4899" alt="Marco V">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-white text-sm">Marco V.</h4>
|
||||||
|
<p class="text-[11px] text-zinc-500 uppercase tracking-widest font-medium">Privacy Advocate</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed italic">
|
||||||
|
"I love that I can have addresses on real Gmail nodes. It makes signing up for newsletters and trials so much smoother as they never get blocked by standard heuristics."
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex gap-1">
|
||||||
|
@for($i=0; $i<5; $i++)
|
||||||
|
<svg class="w-3 h-3 text-pink-500/80" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonial 4 -->
|
||||||
|
<div class="group p-8 rounded-3xl bg-zinc-900/30 border border-white/5 backdrop-blur-xl relative transition-all duration-500 hover:border-emerald-500/30 hover:shadow-[0_0_40px_rgba(16,185,129,0.05)] hover:-translate-y-1">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-zinc-800 border border-white/10 flex items-center justify-center text-zinc-500 overflow-hidden">
|
||||||
|
<img src="https://ui-avatars.com/api/?name=James+Wynn&background=18181b&color=10b981" alt="James Wynn">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-white text-sm">James Wynn</h4>
|
||||||
|
<p class="text-[11px] text-zinc-500 uppercase tracking-widest font-medium">Head of QA at DevCo</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed italic">
|
||||||
|
"The Premium Domains feature is worth every penny. Being able to use our internal domain for staging tests while keeping the emails temporary and private is exactly what we needed."
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex gap-1">
|
||||||
|
@for($i=0; $i<5; $i++)
|
||||||
|
<svg class="w-3 h-3 text-emerald-500/80" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonial 5 -->
|
||||||
|
<div class="group p-8 rounded-3xl bg-zinc-900/30 border border-white/5 backdrop-blur-xl relative transition-all duration-500 hover:border-pink-500/30 hover:shadow-[0_0_40px_rgba(236,72,153,0.05)] hover:-translate-y-1">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-zinc-800 border border-white/10 flex items-center justify-center text-zinc-500 overflow-hidden">
|
||||||
|
<img src="https://ui-avatars.com/api/?name=Lila+K&background=18181b&color=ec4899" alt="Lila K">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-white text-sm">Lila K.</h4>
|
||||||
|
<p class="text-[11px] text-zinc-500 uppercase tracking-widest font-medium">Independent Developer</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed italic">
|
||||||
|
"I love the clean UI. No cluttered ads, just my inbox and the emails I need. The auto-delete feature is perfect for someone like me who forgets to clear their test data."
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex gap-1">
|
||||||
|
@for($i=0; $i<5; $i++)
|
||||||
|
<svg class="w-3 h-3 text-pink-500/80" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Testimonial 6 -->
|
||||||
|
<div class="group p-8 rounded-3xl bg-zinc-900/30 border border-white/5 backdrop-blur-xl relative transition-all duration-500 hover:border-emerald-500/30 hover:shadow-[0_0_40px_rgba(16,185,129,0.05)] hover:-translate-y-1">
|
||||||
|
<div class="flex items-center gap-4 mb-6">
|
||||||
|
<div class="w-12 h-12 rounded-full bg-zinc-800 border border-white/10 flex items-center justify-center text-zinc-500 overflow-hidden">
|
||||||
|
<img src="https://ui-avatars.com/api/?name=David+L&background=18181b&color=10b981" alt="David L">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4 class="font-bold text-white text-sm">David L.</h4>
|
||||||
|
<p class="text-[11px] text-zinc-500 uppercase tracking-widest font-medium">Platform Architect</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-zinc-400 text-sm leading-relaxed italic">
|
||||||
|
"Reliability is key for our automation suite, and Zemail hasn't let us down. The speed of reception is nearly identical to our production Gmail setup."
|
||||||
|
</p>
|
||||||
|
<div class="mt-6 flex gap-1">
|
||||||
|
@for($i=0; $i<5; $i++)
|
||||||
|
<svg class="w-3 h-3 text-emerald-500/80" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" /></svg>
|
||||||
|
@endfor
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1,5 +1,98 @@
|
|||||||
<x-layouts.app.sidebar :title="$title ?? null">
|
<!DOCTYPE html>
|
||||||
<flux:main>
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark h-full">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ $title ?? 'Mailbox — Zemail' }}</title>
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700|jetbrains-mono:400,500" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Vite -->
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
<!-- GSAP -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-app-bg text-[#FAFAFA] antialiased selection:bg-[#EC4899]/30 h-full overflow-hidden"
|
||||||
|
x-data="{
|
||||||
|
toasts: [],
|
||||||
|
wsConnected: true,
|
||||||
|
addToast(msg, type = 'success') {
|
||||||
|
const id = Date.now();
|
||||||
|
this.toasts.push({ id, msg, type });
|
||||||
|
setTimeout(() => {
|
||||||
|
this.toasts = this.toasts.filter(t => t.id !== id);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
@notify.window="addToast($event.detail.message, $event.detail.type)"
|
||||||
|
@ws-status.window="wsConnected = $event.detail.connected">
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
</flux:main>
|
|
||||||
</x-layouts.app.sidebar>
|
<!-- Global Toast Notifications -->
|
||||||
|
<div class="fixed bottom-6 left-6 right-6 sm:left-auto sm:right-6 z-[100] flex flex-col gap-3 pointer-events-none">
|
||||||
|
<template x-for="toast in toasts" :key="toast.id">
|
||||||
|
<div x-show="true"
|
||||||
|
x-transition:enter="transition ease-out duration-500"
|
||||||
|
x-transition:enter-start="opacity-0 translate-x-12 scale-95"
|
||||||
|
x-transition:enter-end="opacity-100 translate-x-0 scale-100"
|
||||||
|
x-transition:leave="transition ease-in duration-300"
|
||||||
|
x-transition:leave-start="opacity-100 scale-100"
|
||||||
|
x-transition:leave-end="opacity-0 scale-90"
|
||||||
|
class="pointer-events-auto w-full sm:min-w-[320px] p-4 rounded-2xl border backdrop-blur-xl shadow-2xl flex items-center gap-4 relative overflow-hidden group"
|
||||||
|
:class="{
|
||||||
|
'bg-emerald-500/10 border-emerald-500/20 text-emerald-100': toast.type === 'success',
|
||||||
|
'bg-blue-500/10 border-blue-500/20 text-blue-100': toast.type === 'info',
|
||||||
|
'bg-amber-500/10 border-amber-500/20 text-amber-100': toast.type === 'warning',
|
||||||
|
'bg-rose-500/10 border-rose-500/20 text-rose-100': toast.type === 'danger'
|
||||||
|
}">
|
||||||
|
<!-- Background Glow -->
|
||||||
|
<div class="absolute inset-0 opacity-20 group-hover:opacity-30 transition-opacity"
|
||||||
|
:class="{
|
||||||
|
'bg-emerald-500/10': toast.type === 'success',
|
||||||
|
'bg-blue-400/10': toast.type === 'info',
|
||||||
|
'bg-amber-400/10': toast.type === 'warning',
|
||||||
|
'bg-rose-400/10': toast.type === 'danger'
|
||||||
|
}"></div>
|
||||||
|
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0 shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'bg-emerald-500/20 text-emerald-400': toast.type === 'success',
|
||||||
|
'bg-blue-500/20 text-blue-400': toast.type === 'info',
|
||||||
|
'bg-amber-500/20 text-amber-400': toast.type === 'warning',
|
||||||
|
'bg-rose-500/20 text-rose-400': toast.type === 'danger'
|
||||||
|
}">
|
||||||
|
<svg x-show="toast.type === 'success'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M5 13l4 4L19 7" /></svg>
|
||||||
|
<svg x-show="toast.type === 'info'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||||
|
<svg x-show="toast.type === 'warning'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
<svg x-show="toast.type === 'danger'" class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2.5" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-[10px] font-black uppercase tracking-[0.2em] opacity-40 mb-0.5" x-text="toast.type"></div>
|
||||||
|
<div class="text-[11px] font-bold tracking-wide whitespace-pre-wrap" x-text="toast.msg"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button @click="toasts = toasts.filter(t => t.id !== toast.id)"
|
||||||
|
class="p-1.5 rounded-lg hover:bg-white/5 text-zinc-600 hover:text-white transition-all cursor-pointer pointer-events-auto z-10 relative">
|
||||||
|
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Lightweight QR Code Library -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/qrious/4.0.2/qrious.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|||||||
56
resources/views/components/layouts/marketing.blade.php
Normal file
56
resources/views/components/layouts/marketing.blade.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" class="dark scroll-smooth">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{ $title ?? 'Zemail — Instant Disposable Gmail & Temporary Email' }}</title>
|
||||||
|
<link rel="icon" href="/favicon.ico" sizes="any">
|
||||||
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||||
|
|
||||||
|
<!-- Fonts -->
|
||||||
|
<link rel="preconnect" href="https://fonts.bunny.net">
|
||||||
|
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700|jetbrains-mono:400,500" rel="stylesheet" />
|
||||||
|
|
||||||
|
<!-- Vite -->
|
||||||
|
@vite(['resources/css/app.css', 'resources/js/app.js'])
|
||||||
|
|
||||||
|
<!-- GSAP -->
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/ScrollTrigger.min.js"></script>
|
||||||
|
<script>
|
||||||
|
// Clean-URL Smooth Scrolling
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const anchor = e.target.closest('a[href^="#"]');
|
||||||
|
if (anchor && anchor.getAttribute('href') !== '#') {
|
||||||
|
e.preventDefault();
|
||||||
|
const targetId = anchor.getAttribute('href').substring(1);
|
||||||
|
const target = document.getElementById(targetId);
|
||||||
|
if (target) {
|
||||||
|
target.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
body { font-family: 'Inter', sans-serif; }
|
||||||
|
.font-mono { font-family: 'JetBrains Mono', monospace; }
|
||||||
|
|
||||||
|
/* Hide scrollbar for bento grid if needed */
|
||||||
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.no-scrollbar {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-app-bg text-[#FAFAFA] antialiased selection:bg-[#EC4899]/30 min-h-screen flex flex-col" x-data>
|
||||||
|
<x-bento.nav />
|
||||||
|
{{ $slot }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user